Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2518209
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
255 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/docker/kolab/utils/15-create-hosted-domain.sh b/docker/kolab/utils/15-create-hosted-domain.sh
index 6305ca82..9e15ae0f 100755
--- a/docker/kolab/utils/15-create-hosted-domain.sh
+++ b/docker/kolab/utils/15-create-hosted-domain.sh
@@ -1,99 +1,99 @@
#!/bin/bash
. ./settings.sh
(
echo "dn: associateddomain=${hosted_domain},ou=Domains,${rootdn}"
echo "objectclass: top"
echo "objectclass: domainrelatedobject"
echo "objectclass: inetdomain"
echo "inetdomainstatus: active"
echo "associateddomain: ${hosted_domain}"
echo "inetdomainbasedn: ${hosted_domain_rootdn}"
echo ""
echo "dn: cn=$(echo ${hosted_domain_rootdn} | sed -e 's/=/\\3D/g' -e 's/,/\\2D/g'),cn=mapping tree,cn=config"
echo "objectClass: top"
echo "objectClass: extensibleObject"
echo "objectClass: nsMappingTree"
echo "nsslapd-state: backend"
echo "cn: ${hosted_domain_rootdn}"
echo "nsslapd-backend: $(echo ${hosted_domain} | sed -e 's/\./_/g')"
echo ""
echo "dn: cn=$(echo ${hosted_domain} | sed -e 's/\./_/g'),cn=ldbm database,cn=plugins,cn=config"
echo "objectClass: top"
echo "objectClass: extensibleobject"
echo "objectClass: nsbackendinstance"
echo "cn: $(echo ${hosted_domain} | sed -e 's/\./_/g')"
echo "nsslapd-suffix: ${hosted_domain_rootdn}"
echo "nsslapd-cachesize: -1"
echo "nsslapd-cachememsize: 10485760"
echo "nsslapd-readonly: off"
echo "nsslapd-require-index: off"
echo "nsslapd-directory: /var/lib/dirsrv/slapd-$(hostname -s)/db/$(echo ${hosted_domain} | sed -e 's/\./_/g')"
echo "nsslapd-dncachememsize: 10485760"
echo ""
) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}"
(
echo "dn: ${hosted_domain_rootdn}"
echo "aci: (targetattr=\"carLicense || description || displayName || facsimileTelephoneNumber || homePhone || homePostalAddress || initials || jpegPhoto || labeledURI || mobile || pager || photo || postOfficeBox || postalAddress || postalCode || preferredDeliveryMethod || preferredLanguage || registeredAddress || roomNumber || secretary || seeAlso || st || street || telephoneNumber || telexNumber || title || userCertificate || userPassword || userSMIMECertificate || x500UniqueIdentifier\")(version 3.0; acl \"Enable self write for common attributes\"; allow (write) userdn=\"ldap:///self\";)"
echo "aci: (targetattr =\"*\")(version 3.0;acl \"Directory Administrators Group\";allow (all) (groupdn=\"ldap:///cn=Directory Administrators,${hosted_domain_rootdn}\" or roledn=\"ldap:///cn=kolab-admin,${hosted_domain_rootdn}\");)"
echo "aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrators Group\"; allow (all) groupdn=\"ldap:///cn=Configuration Administrators,ou=Groups,ou=TopologyManagement,o=NetscapeRoot\";)"
echo "aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrator\"; allow (all) userdn=\"ldap:///uid=admin,ou=Administrators,ou=TopologyManagement,o=NetscapeRoot\";)"
echo "aci: (targetattr = \"*\")(version 3.0; acl \"SIE Group\"; allow (all) groupdn = \"ldap:///cn=slapd-$(hostname -s),cn=389 Directory Server,cn=Server Group,cn=$(hostname -f),ou=${domain},o=NetscapeRoot\";)"
echo "aci: (targetattr = \"*\") (version 3.0;acl \"Search Access\";allow (read,compare,search)(userdn = \"ldap:///${hosted_domain_rootdn}??sub?(objectclass=*)\");)"
echo "aci: (targetattr = \"*\") (version 3.0;acl \"Service Search Access\";allow (read,compare,search)(userdn = \"ldap:///uid=kolab-service,ou=Special Users,${rootdn}\");)"
echo "objectClass: top"
echo "objectClass: domain"
echo "dc: $(echo ${hosted_domain} | cut -d'.' -f 1)"
echo ""
) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}"
(
for role in "2fa-user" "activesync-user" "imap-user"; do
- echo "cn=${role},${hosted_domain_rootdn}"
+ echo "dn: cn=${role},${hosted_domain_rootdn}"
echo "cn: ${role}"
echo "description: ${role} role"
echo "objectclass: top"
echo "objectclass: ldapsubentry"
echo "objectclass: nsmanagedroledefinition"
echo "objectclass: nsroledefinition"
echo "objectclass: nssimpleroledefinition"
echo ""
done
echo "dn: ou=Groups,${hosted_domain_rootdn}"
echo "ou: Groups"
echo "objectClass: top"
echo "objectClass: organizationalunit"
echo ""
echo "dn: ou=People,${hosted_domain_rootdn}"
echo "aci: (targetattr = \"*\") (version 3.0;acl \"Hosted Kolab Services\";allow (all)(userdn = \"ldap:///uid=hosted-kolab-service,ou=Special Users,${rootdn}\");)"
echo "ou: People"
echo "objectClass: top"
echo "objectClass: organizationalunit"
echo ""
echo "dn: ou=Special Users,${hosted_domain_rootdn}"
echo "ou: Special Users"
echo "objectClass: top"
echo "objectClass: organizationalunit"
echo ""
echo "dn: ou=Resources,${hosted_domain_rootdn}"
echo "ou: Resources"
echo "objectClass: top"
echo "objectClass: organizationalunit"
echo ""
echo "dn: ou=Shared Folders,${hosted_domain_rootdn}"
echo "ou: Shared Folders"
echo "objectClass: top"
echo "objectClass: organizationalunit"
echo ""
) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}"
diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php
index ac8f2175..05b56f5f 100644
--- a/src/app/Auth/SecondFactor.php
+++ b/src/app/Auth/SecondFactor.php
@@ -1,335 +1,335 @@
<?php
namespace App\Auth;
use App\Sku;
use App\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Kolab2FA\Storage\Base;
/**
* A class to maintain 2-factor authentication
*/
class SecondFactor extends Base
{
protected $user;
protected $cache = [];
protected $config = [
'keymap' => [],
];
/**
* Class constructor
*
* @param \App\User $user User object
*/
public function __construct($user)
{
$this->user = $user;
parent::__construct();
}
/**
* Validate 2-factor authentication code
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse|null
*/
public function requestHandler($request)
{
// get list of configured authentication factors
$factors = $this->factors();
// do nothing if no factors configured
if (empty($factors)) {
return null;
}
if (empty($request->secondfactor) || !is_string($request->secondfactor)) {
$errors = ['secondfactor' => \trans('validation.2fareq')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// try to verify each configured factor
foreach ($factors as $factor) {
// verify the submitted code
// if (strpos($factor, 'dummy:') === 0 && (\app('env') != 'production') {
// if ($request->secondfactor === 'dummy') {
// return null;
// }
// } else
if ($this->verify($factor, $request->secondfactor)) {
return null;
}
}
$errors = ['secondfactor' => \trans('validation.2fainvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
/**
* Remove all configured 2FA methods for the current user
*
* @return bool True on success, False otherwise
*/
public function removeFactors(): bool
{
$this->cache = [];
$prefs = [];
$prefs[$this->key2property('blob')] = null;
$prefs[$this->key2property('factors')] = null;
return $this->savePrefs($prefs);
}
/**
* Returns a list of 2nd factor methods configured for the user
*/
public function factors(): array
{
// First check if the user has the 2FA SKU
$sku_2fa = Sku::where('title', '2fa')->first();
if ($sku_2fa) {
$has_2fa = $this->user->entitlements()->where('sku_id', $sku_2fa->id)->first();
if ($has_2fa) {
$factors = (array) $this->enumerate();
$factors = array_unique($factors);
return $factors;
}
}
return [];
}
/**
* Helper method to verify the given method/code tuple
*
* @param string $factor Factor identifier (<method>:<id>)
* @param string $code Authentication code
*
* @return bool True on successful validation
*/
protected function verify($factor, $code): bool
{
if ($driver = $this->getDriver($factor)) {
return $driver->verify($code, time());
}
return false;
}
/**
* Load driver class for the given authentication factor
*
* @param string $factor Factor identifier (<method>:<id>)
*
* @return \Kolab2FA\Driver\Base
*/
protected function getDriver(string $factor)
{
list($method) = explode(':', $factor, 2);
$config = \config('2fa.' . $method, []);
$driver = \Kolab2FA\Driver\Base::factory($factor, $config);
// configure driver
$driver->storage = $this;
$driver->username = $this->user->email;
return $driver;
}
/**
* Helper for seeding a Roundcube account with 2FA setup
* for testing.
*
* @param string $email Email address
*/
public static function seed(string $email): void
{
$config = [
'kolab_2fa_blob' => [
'totp:8132a46b1f741f88de25f47e' => [
'label' => 'Mobile app (TOTP)',
'created' => 1584573552,
'secret' => 'UAF477LDHZNWVLNA',
'active' => true,
],
// 'dummy:dummy' => [
// 'active' => true,
// ],
],
'kolab_2fa_factors' => [
'totp:8132a46b1f741f88de25f47e',
// 'dummy:dummy',
]
];
self::dbh()->table('users')->updateOrInsert(
['username' => $email, 'mail_host' => '127.0.0.1'],
['preferences' => serialize($config)]
);
}
/**
* Helper for generating current TOTP code for a test user
*
* @param string $email Email address
*
* @return string Generated code
*/
public static function code(string $email): string
{
$sf = new self(User::where('email', $email)->first());
$driver = $sf->getDriver('totp:8132a46b1f741f88de25f47e');
return (string) $driver->get_code();
}
//******************************************************
// Methods required by Kolab2FA Storage Base
//******************************************************
/**
* Initialize the storage driver with the given config options
*/
public function init(array $config)
{
$this->config = array_merge($this->config, $config);
}
/**
* List methods activated for this user
*/
public function enumerate()
{
if ($factors = $this->getFactors()) {
return array_keys(array_filter($factors, function ($prop) {
return !empty($prop['active']);
}));
}
return [];
}
/**
* Read data for the given key
*/
public function read($key)
{
\Log::debug(__METHOD__ . ' ' . $key);
if (!isset($this->cache[$key])) {
$factors = $this->getFactors();
- $this->cache[$key] = $factors[$key];
+ $this->cache[$key] = isset($factors[$key]) ? $factors[$key] : null;
}
return $this->cache[$key];
}
/**
* Save data for the given key
*/
public function write($key, $value)
{
\Log::debug(__METHOD__ . ' ' . @json_encode($value));
// TODO: Not implemented
return false;
}
/**
* Remove the data stored for the given key
*/
public function remove($key)
{
return $this->write($key, null);
}
/**
*
*/
protected function getFactors(): array
{
$prefs = $this->getPrefs();
$key = $this->key2property('blob');
return isset($prefs[$key]) ? (array) $prefs[$key] : [];
}
/**
*
*/
protected function key2property($key)
{
// map key to configured property name
if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) {
return $this->config['keymap'][$key];
}
// default
return 'kolab_2fa_' . $key;
}
/**
* Gets user preferences from Roundcube users table
*/
protected function getPrefs()
{
$user = $this->dbh()->table('users')
->select('preferences')
->where('username', strtolower($this->user->email))
->first();
return $user ? (array) unserialize($user->preferences) : null;
}
/**
* Saves user preferences in Roundcube users table.
* This will merge into old preferences
*/
protected function savePrefs($prefs)
{
$old_prefs = $this->getPrefs();
if (!is_array($old_prefs)) {
return false;
}
$prefs = array_merge($old_prefs, $prefs);
$this->dbh()->table('users')
->where('username', strtolower($this->user->email))
->update(['preferences' => serialize($prefs)]);
return true;
}
/**
* Init connection to the Roundcube database
*/
public static function dbh()
{
$dsn = \config('2fa.dsn');
if (empty($dsn)) {
\Log::warning("2-FACTOR database not configured");
return DB::connection(\config('database.default'));
}
\Config::set('database.connections.2fa', ['url' => $dsn]);
return DB::connection('2fa');
}
}
diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php
index d33562cf..9fbeb62d 100644
--- a/src/app/Backends/LDAP.php
+++ b/src/app/Backends/LDAP.php
@@ -1,511 +1,506 @@
<?php
namespace App\Backends;
use App\Domain;
use App\User;
class LDAP
{
/**
* Create a domain in LDAP.
*
* @param \App\Domain $domain The domain to create.
*
* @return void
*/
public static function createDomain(Domain $domain)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$hostedRootDN = \config('ldap.hosted.root_dn');
$mgmtRootDN = \config('ldap.admin.root_dn');
$domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}";
$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']}";
if (!$ldap->get_entry($dn)) {
$ldap->add_entry($dn, $entry);
}
// 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)) {
$ldap->add_entry($domainBaseDN, $entry);
}
foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) {
if (!$ldap->get_entry("ou={$item},{$domainBaseDN}")) {
$ldap->add_entry(
"ou={$item},{$domainBaseDN}",
[
'ou' => $item,
'description' => $item,
'objectclass' => [
'top',
'organizationalunit'
]
]
);
}
}
foreach (['kolab-admin', 'billing-user'] as $item) {
if (!$ldap->get_entry("cn={$item},{$domainBaseDN}")) {
$ldap->add_entry(
"cn={$item},{$domainBaseDN}",
[
'cn' => $item,
'description' => "{$item} role",
'objectclass' => [
'top',
'ldapsubentry',
'nsmanagedroledefinition',
'nsroledefinition',
'nssimpleroledefinition'
]
]
);
}
}
$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.
*
* @return bool|void
*/
public static function createUser(User $user)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
list($_local, $_domain) = explode('@', $user->email, 2);
$domain = $ldap->find_domain($_domain);
if (!$domain) {
return false;
}
$entry = [
'objectclass' => [
'top',
'inetorgperson',
'inetuser',
'kolabinetorgperson',
'mailrecipient',
'person'
],
'mail' => $user->email,
'uid' => $user->email,
'nsroledn' => []
];
self::setUserAttributes($user, $entry);
$base_dn = $ldap->domain_root_dn($_domain);
$dn = "uid={$user->email},ou=People,{$base_dn}";
if (!$ldap->get_entry($dn)) {
$ldap->add_entry($dn, $entry);
}
$ldap->close();
}
/**
* Update a domain in LDAP.
*
* @param \App\Domain $domain The domain to update.
*
* @return void
*/
public static function updateDomain($domain)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$ldapDomain = $ldap->find_domain($domain->namespace);
$oldEntry = $ldap->get_entry($ldapDomain['dn']);
$newEntry = $oldEntry;
self::setDomainAttributes($domain, $newEntry);
$ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry);
$ldap->close();
}
public static function deleteDomain($domain)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$hostedRootDN = \config('ldap.hosted.root_dn');
$mgmtRootDN = \config('ldap.admin.root_dn');
$domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}";
if ($ldap->get_entry($domainBaseDN)) {
$ldap->delete_entry_recursive($domainBaseDN);
}
if ($ldap_domain = $ldap->find_domain($domain->namespace)) {
if ($ldap->get_entry($ldap_domain['dn'])) {
$ldap->delete_entry($ldap_domain['dn']);
}
}
$ldap->close();
}
public static function deleteUser($user)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
list($_local, $_domain) = explode('@', $user->email, 2);
$domain = $ldap->find_domain($_domain);
if (!$domain) {
$ldap->close();
return false;
}
$base_dn = $ldap->domain_root_dn($_domain);
$dn = "uid={$user->email},ou=People,{$base_dn}";
if (!$ldap->get_entry($dn)) {
$ldap->close();
return false;
}
$ldap->delete_entry($dn);
$ldap->close();
}
/**
* Update a user in LDAP.
*
* @param \App\User $user The user account to update.
*
* @return bool|void
*/
public static function updateUser(User $user)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
list($_local, $_domain) = explode('@', $user->email, 2);
$domain = $ldap->find_domain($_domain);
if (!$domain) {
$ldap->close();
return false;
}
$base_dn = $ldap->domain_root_dn($_domain);
$dn = "uid={$user->email},ou=People,{$base_dn}";
$oldEntry = $ldap->get_entry($dn);
if (!$oldEntry) {
$ldap->close();
return false;
}
if (!array_key_exists('nsroledn', $oldEntry)) {
- $oldEntry['nsroledn'] = (array)$ldap->get_entry_attributes($dn, ['nsroledn']);
+ $roles = $ldap->get_entry_attributes($dn, ['nsroledn']);
+ if (!empty($roles)) {
+ $oldEntry['nsroledn'] = (array)$roles['nsroledn'];
+ }
}
$newEntry = $oldEntry;
self::setUserAttributes($user, $newEntry);
$ldap->modify_entry($dn, $oldEntry, $newEntry);
$ldap->close();
}
/**
* Initialize connection to LDAP
*/
private static function initLDAP(array $config, string $privilege = 'admin')
{
$ldap = new \Net_LDAP3($config);
$ldap->connect();
$ldap->bind(\config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw"));
// TODO: error handling
return $ldap;
}
/**
* Set domain attributes
*/
private static function setDomainAttributes(Domain $domain, array &$entry)
{
$entry['inetdomainstatus'] = $domain->status;
}
/**
* Set common user attributes
*/
private static function setUserAttributes(User $user, array &$entry)
{
$firstName = $user->getSetting('first_name');
$lastName = $user->getSetting('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['mailquota'] = 0;
- if (!array_key_exists('nsroledn', $entry)) {
- $entry['nsroledn'] = [];
- } else if (!is_array($entry['nsroledn'])) {
- $entry['nsroledn'] = (array)$entry['nsroledn'];
- }
-
$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;
- }
- $roles[] = $entitlement->sku->title;
+ default:
+ $roles[] = $entitlement->sku->title;
+ break;
+ }
}
$hostedRootDN = \config('ldap.hosted.root_dn');
+ if (empty($roles)) {
+ if (array_key_exists('nsroledn', $entry)) {
+ unset($entry['nsroledn']);
+ }
+
+ return;
+ }
+
+ $entry['nsroledn'] = [];
+
if (in_array("2fa", $roles)) {
$entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}";
- } else {
- $key = array_search("cn=2fa-user,{$hostedRootDN}", $entry['nsroledn']);
- if ($key !== false) {
- unset($entry['nsroledn'][$key]);
- }
}
if (in_array("activesync", $roles)) {
$entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}";
- } else {
- $key = array_search("cn=activesync-user,{$hostedRootDN}", $entry['nsroledn']);
- if ($key !== false) {
- unset($entry['nsroledn'][$key]);
- }
}
if (!in_array("groupware", $roles)) {
$entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}";
- } else {
- $key = array_search("cn=imap-user,{$hostedRootDN}", $entry['nsroledn']);
- if ($key !== false) {
- unset($entry['nsroledn'][$key]);
- }
}
-
- $entry['nsroledn'] = array_unique($entry['nsroledn']);
}
/**
* 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;
}
/**
* 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);
}
}
diff --git a/src/app/Domain.php b/src/app/Domain.php
index dd78b850..60af5784 100644
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -1,378 +1,387 @@
<?php
namespace App;
use App\Wallet;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* The eloquent definition of a Domain.
*
* @property string $namespace
*/
class Domain extends Model
{
use SoftDeletes;
// 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;
public $incrementing = false;
protected $keyType = 'bigint';
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)
{
$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(),
'entitleable_id' => $this->id,
'entitleable_type' => Domain::class
]
);
}
}
return $this;
}
public function entitlement()
{
return $this->morphOne('App\Entitlement', 'entitleable');
}
/**
* Return list of public+active domain names
*/
public static function getPublicDomains(): array
{
$where = sprintf('(type & %s)', Domain::TYPE_PUBLIC);
return self::whereRaw($where)->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;
}
/**
* Domain status mutator
*
* @throws \Exception
*/
public function setStatusAttribute($status)
{
$new_status = 0;
$allowed_values = [
self::STATUS_NEW,
self::STATUS_ACTIVE,
self::STATUS_CONFIRMED,
self::STATUS_SUSPENDED,
self::STATUS_DELETED,
self::STATUS_LDAP_READY,
self::STATUS_VERIFIED,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid domain status: {$status}");
}
$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;
}
/**
* Suspend this domain.
*
* @return void
*/
public function suspend(): void
{
if ($this->isSuspended()) {
return;
}
$this->status |= Domain::STATUS_SUSPENDED;
$this->save();
}
/**
* Unsuspend this domain.
*
* @return void
*/
public function unsuspend(): void
{
if (!$this->isSuspended()) {
return;
}
$this->status ^= Domain::STATUS_SUSPENDED;
$this->save();
}
/**
* 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;
}
- $record = \dns_get_record($this->namespace, DNS_ANY);
+ $records = \dns_get_record($this->namespace, DNS_ANY);
- if ($record === false) {
+ if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
- if (!empty($record)) {
+ // It may happen that result contains other domains depending on the host
+ // DNS setup
+ $hosts = array_map(
+ function ($record) {
+ return $record['host'];
+ },
+ $records
+ );
+
+ if (in_array($this->namespace, $hosts)) {
$this->status |= Domain::STATUS_VERIFIED;
$this->save();
return true;
}
return false;
}
/**
* Returns the wallet by which the domain is controlled
*
* @return \App\Wallet A wallet object
*/
public function wallet(): Wallet
{
return $this->entitlement()->first()->wallet;
}
}
diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php
new file mode 100644
index 00000000..3fee38e4
--- /dev/null
+++ b/src/app/Http/Controllers/API/AuthController.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace App\Http\Controllers\API;
+
+use App\Http\Controllers\Controller;
+use App\User;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Validator;
+
+class AuthController extends Controller
+{
+ /**
+ * Get the authenticated User
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function info()
+ {
+ $user = $this->guard()->user();
+ $response = V4\UsersController::userResponse($user);
+
+ return response()->json($response);
+ }
+
+ /**
+ * Helper method for other controllers with user auto-logon
+ * functionality
+ *
+ * @param \App\User $user User model object
+ */
+ public static function logonResponse(User $user)
+ {
+ $token = auth()->login($user);
+
+ return response()->json([
+ 'status' => 'success',
+ 'access_token' => $token,
+ 'token_type' => 'bearer',
+ 'expires_in' => Auth::guard()->factory()->getTTL() * 60,
+ ]);
+ }
+
+ /**
+ * Get a JWT token via given credentials.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function login(Request $request)
+ {
+ // TODO: Redirect to dashboard if authenticated.
+ $v = Validator::make(
+ $request->all(),
+ [
+ 'email' => 'required|min:2',
+ 'password' => 'required|min:4',
+ ]
+ );
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ $credentials = $request->only('email', 'password');
+
+ if ($token = $this->guard()->attempt($credentials)) {
+ $sf = new \App\Auth\SecondFactor($this->guard()->user());
+
+ if ($response = $sf->requestHandler($request)) {
+ return $response;
+ }
+
+ return $this->respondWithToken($token);
+ }
+
+ return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401);
+ }
+
+ /**
+ * Log the user out (Invalidate the token)
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function logout()
+ {
+ $this->guard()->logout();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => __('auth.logoutsuccess')
+ ]);
+ }
+
+ /**
+ * Refresh a token.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function refresh()
+ {
+ return $this->respondWithToken($this->guard()->refresh());
+ }
+
+ /**
+ * Get the token array structure.
+ *
+ * @param string $token Respond with this token.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ protected function respondWithToken($token)
+ {
+ return response()->json(
+ [
+ 'access_token' => $token,
+ 'token_type' => 'bearer',
+ 'expires_in' => $this->guard()->factory()->getTTL() * 60
+ ]
+ );
+ }
+
+ /**
+ * Get the guard to be used during authentication.
+ *
+ * @return \Illuminate\Contracts\Auth\Guard
+ */
+ public function guard()
+ {
+ return Auth::guard();
+ }
+}
diff --git a/src/app/Http/Controllers/API/PasswordResetController.php b/src/app/Http/Controllers/API/PasswordResetController.php
index 7d1d0e6e..ba80fc3f 100644
--- a/src/app/Http/Controllers/API/PasswordResetController.php
+++ b/src/app/Http/Controllers/API/PasswordResetController.php
@@ -1,143 +1,143 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Jobs\PasswordResetEmail;
use App\User;
use App\VerificationCode;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
/**
* Password reset API
*/
class PasswordResetController extends Controller
{
/** @var \App\VerificationCode A verification code object */
protected $code;
/**
* Sends password reset code to the user's external email
*
* Verifies user email, sends verification email message.
*
* @param \Illuminate\Http\Request $request HTTP request
*
* @return \Illuminate\Http\JsonResponse JSON response
*/
public function init(Request $request)
{
// Check required fields
$v = Validator::make($request->all(), ['email' => 'required|email']);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
// Find a user by email
$user = User::findByEmail($request->email);
if (!$user) {
$errors = ['email' => __('validation.usernotexists')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if (!$user->getSetting('external_email')) {
$errors = ['email' => __('validation.noextemail')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// Generate the verification code
$code = new VerificationCode(['mode' => 'password-reset']);
$user->verificationcodes()->save($code);
// Send email/sms message
PasswordResetEmail::dispatch($code);
return response()->json(['status' => 'success', 'code' => $code->code]);
}
/**
* Validation of the verification code.
*
* @param \Illuminate\Http\Request $request HTTP request
*
* @return \Illuminate\Http\JsonResponse JSON response
*/
public function verify(Request $request)
{
// Validate the request args
$v = Validator::make(
$request->all(),
[
'code' => 'required',
'short_code' => 'required',
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
// Validate the verification code
$code = VerificationCode::find($request->code);
if (
empty($code)
|| $code->isExpired()
|| $code->mode !== 'password-reset'
|| Str::upper($request->short_code) !== Str::upper($code->short_code)
) {
$errors = ['short_code' => "The code is invalid or expired."];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// For last-step remember the code object, so we can delete it
// with single SQL query (->delete()) instead of two (::destroy())
$this->code = $code;
// Return user name and email/phone from the codes database on success
return response()->json(['status' => 'success']);
}
/**
* Password change
*
* @param \Illuminate\Http\Request $request HTTP request
*
* @return \Illuminate\Http\JsonResponse JSON response
*/
public function reset(Request $request)
{
// Validate the request args
$v = Validator::make(
$request->all(),
[
'password' => 'required|min:4|confirmed',
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$v = $this->verify($request);
if ($v->status() !== 200) {
return $v;
}
$user = $this->code->user;
// Change the user password
$user->setPasswordAttribute($request->password);
$user->save();
// Remove the verification code
$this->code->delete();
- return UsersController::logonResponse($user);
+ return AuthController::logonResponse($user);
}
}
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
index 89dae2b0..6ce02d29 100644
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -1,347 +1,347 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Jobs\SignupVerificationEmail;
use App\Jobs\SignupVerificationSMS;
use App\Domain;
use App\Plan;
use App\Rules\ExternalEmail;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\SignupCode;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
/**
* Signup process API
*/
class SignupController extends Controller
{
/** @var \App\SignupCode A verification code object */
protected $code;
/** @var \App\Plan Signup plan object */
protected $plan;
/**
* Returns plans definitions for signup.
*
* @param \Illuminate\Http\Request $request HTTP request
*
* @return \Illuminate\Http\JsonResponse JSON response
*/
public function plans(Request $request)
{
$plans = [];
Plan::all()->map(function ($plan) use (&$plans) {
$plans[] = [
'title' => $plan->title,
'name' => $plan->name,
'button' => __('app.planbutton', ['plan' => $plan->name]),
'description' => $plan->description,
];
});
return response()->json(['status' => 'success', 'plans' => $plans]);
}
/**
* Starts signup process.
*
* Verifies user name and email/phone, sends verification email/sms message.
* Returns the verification code.
*
* @param \Illuminate\Http\Request $request HTTP request
*
* @return \Illuminate\Http\JsonResponse JSON response
*/
public function init(Request $request)
{
// Check required fields
$v = Validator::make(
$request->all(),
[
'email' => 'required',
'name' => 'required|max:512',
'plan' => 'nullable|alpha_num|max:128',
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
// Validate user email (or phone)
if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) {
return response()->json(['status' => 'error', 'errors' => ['email' => $error]], 422);
}
// Generate the verification code
$code = SignupCode::create([
'data' => [
'email' => $request->email,
'name' => $request->name,
'plan' => $request->plan,
]
]);
// Send email/sms message
if ($is_phone) {
SignupVerificationSMS::dispatch($code);
} else {
SignupVerificationEmail::dispatch($code);
}
return response()->json(['status' => 'success', 'code' => $code->code]);
}
/**
* Validation of the verification code.
*
* @param \Illuminate\Http\Request $request HTTP request
*
* @return \Illuminate\Http\JsonResponse JSON response
*/
public function verify(Request $request)
{
// Validate the request args
$v = Validator::make(
$request->all(),
[
'code' => 'required',
'short_code' => 'required',
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
// Validate the verification code
$code = SignupCode::find($request->code);
if (
empty($code)
|| $code->isExpired()
|| Str::upper($request->short_code) !== Str::upper($code->short_code)
) {
$errors = ['short_code' => "The code is invalid or expired."];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// For signup last-step mode remember the code object, so we can delete it
// with single SQL query (->delete()) instead of two (::destroy())
$this->code = $code;
$has_domain = $this->getPlan()->hasDomain();
// Return user name and email/phone from the codes database,
// domains list for selection and "plan type" flag
return response()->json([
'status' => 'success',
'email' => $code->data['email'],
'name' => $code->data['name'],
'is_domain' => $has_domain,
'domains' => $has_domain ? [] : Domain::getPublicDomains(),
]);
}
/**
* Finishes the signup process by creating the user account.
*
* @param \Illuminate\Http\Request $request HTTP request
*
* @return \Illuminate\Http\JsonResponse JSON response
*/
public function signup(Request $request)
{
// Validate input
$v = Validator::make(
$request->all(),
[
'login' => 'required|min:2',
'password' => 'required|min:4|confirmed',
'domain' => 'required',
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
// Validate verification codes (again)
$v = $this->verify($request);
if ($v->status() !== 200) {
return $v;
}
// Get the plan
$plan = $this->getPlan();
$is_domain = $plan->hasDomain();
$login = $request->login;
$domain = $request->domain;
// Validate login
if ($errors = self::validateLogin($login, $domain, $is_domain)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// Get user name/email from the verification code database
$code_data = $v->getData();
$user_name = $code_data->name;
$user_email = $code_data->email;
// We allow only ASCII, so we can safely lower-case the email address
$login = Str::lower($login);
$domain = Str::lower($domain);
DB::beginTransaction();
// Create user record
$user = User::create([
'name' => $user_name,
'email' => $login . '@' . $domain,
'password' => $request->password,
]);
// Create domain record
// FIXME: Should we do this in UserObserver::created()?
if ($is_domain) {
$domain = Domain::create([
'namespace' => $domain,
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]);
}
$user->assignPlan($plan, $domain);
// Save the external email and plan in user settings
$user->setSetting('external_email', $user_email);
// Remove the verification code
$this->code->delete();
DB::commit();
- return UsersController::logonResponse($user);
+ return AuthController::logonResponse($user);
}
/**
* Returns plan for the signup process
*
* @returns \App\Plan Plan object selected for current signup process
*/
protected function getPlan()
{
if (!$this->plan) {
// Get the plan if specified and exists...
if ($this->code && $this->code->data['plan']) {
$plan = Plan::where('title', $this->code->data['plan'])->first();
}
// ...otherwise use the default plan
if (empty($plan)) {
// TODO: Get default plan title from config
$plan = Plan::where('title', 'individual')->first();
}
$this->plan = $plan;
}
return $this->plan;
}
/**
* Checks if the input string is a valid email address or a phone number
*
* @param string $input Email address or phone number
* @param bool $is_phone Will have been set to True if the string is valid phone number
*
* @return string Error message on validation error
*/
protected static function validatePhoneOrEmail($input, &$is_phone = false): ?string
{
$is_phone = false;
$v = Validator::make(
['email' => $input],
['email' => ['required', 'string', new ExternalEmail()]]
);
if ($v->fails()) {
return $v->errors()->toArray()['email'][0];
}
// TODO: Phone number support
/*
$input = str_replace(array('-', ' '), '', $input);
if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) {
return \trans('validation.noemailorphone');
}
$is_phone = true;
*/
return null;
}
/**
* Login (kolab identity) validation
*
* @param string $login Login (local part of an email address)
* @param string $domain Domain name
* @param bool $external Enables additional checks for domain part
*
* @return array Error messages on validation error
*/
protected static function validateLogin($login, $domain, $external = false): ?array
{
// Validate login part alone
$v = Validator::make(
['login' => $login],
['login' => ['required', 'string', new UserEmailLocal($external)]]
);
if ($v->fails()) {
return ['login' => $v->errors()->toArray()['login'][0]];
}
$domains = $external ? null : Domain::getPublicDomains();
// Validate the domain
$v = Validator::make(
['domain' => $domain],
['domain' => ['required', 'string', new UserEmailDomain($domains)]]
);
if ($v->fails()) {
return ['domain' => $v->errors()->toArray()['domain'][0]];
}
$domain = Str::lower($domain);
// Check if domain is already registered with us
if ($external) {
if (Domain::where('namespace', $domain)->first()) {
return ['domain' => \trans('validation.domainexists')];
}
}
// Check if user with specified login already exists
$email = $login . '@' . $domain;
if (User::findByEmail($email)) {
return ['login' => \trans('validation.loginexists')];
}
return null;
}
}
diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php
new file mode 100644
index 00000000..01069e1c
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Admin;
+
+class DomainsController extends \App\Http\Controllers\API\V4\DomainsController
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php b/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php
new file mode 100644
index 00000000..1e7b3952
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Admin;
+
+class EntitlementsController extends \App\Http\Controllers\API\V4\EntitlementsController
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/Admin/PackagesController.php b/src/app/Http/Controllers/API/V4/Admin/PackagesController.php
new file mode 100644
index 00000000..ba72eacc
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Admin/PackagesController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Admin;
+
+class PackagesController extends \App\Http\Controllers\API\V4\PackagesController
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/Admin/SkusController.php b/src/app/Http/Controllers/API/V4/Admin/SkusController.php
new file mode 100644
index 00000000..35ad6a24
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Admin/SkusController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Admin;
+
+class SkusController extends \App\Http\Controllers\API\V4\SkusController
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
new file mode 100644
index 00000000..84e26a62
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Admin;
+
+class UsersController extends \App\Http\Controllers\API\V4\UsersController
+{
+ public function index()
+ {
+ $result = \App\User::orderBy('email')->get()->map(function ($user) {
+ $data = $user->toArray();
+ $data = array_merge($data, self::userStatuses($user));
+ return $data;
+ });
+
+ return response()->json($result);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
new file mode 100644
index 00000000..ae13008c
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Admin;
+
+class WalletsController extends \App\Http\Controllers\API\V4\WalletsController
+{
+}
diff --git a/src/app/Http/Controllers/API/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php
similarity index 99%
rename from src/app/Http/Controllers/API/DomainsController.php
rename to src/app/Http/Controllers/API/V4/DomainsController.php
index 7ffa894e..06b8bf21 100644
--- a/src/app/Http/Controllers/API/DomainsController.php
+++ b/src/app/Http/Controllers/API/V4/DomainsController.php
@@ -1,280 +1,280 @@
<?php
-namespace App\Http\Controllers\API;
+namespace App\Http\Controllers\API\V4;
use App\Domain;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class DomainsController extends Controller
{
/**
* Return a list of domains owned by the current user
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = Auth::guard()->user();
$list = [];
foreach ($user->domains() as $domain) {
if (!$domain->isPublic()) {
$data = $domain->toArray();
$data = array_merge($data, self::domainStatuses($domain));
$list[] = $data;
}
}
return response()->json($list);
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\JsonResponse
*/
public function create()
{
//
}
/**
* Confirm ownership of the specified domain (via DNS check).
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function confirm($id)
{
$domain = Domain::findOrFail($id);
// Only owner (or admin) has access to the domain
if (!Auth::guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
if (!$domain->confirm()) {
// TODO: This should include an error message to display to the user
return response()->json(['status' => 'error']);
}
return response()->json([
'status' => 'success',
'statusInfo' => self::statusInfo($domain),
'message' => __('app.domain-verify-success'),
]);
}
/**
* Remove the specified resource from storage.
*
* @param int $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
{
//
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
*
* @return \Illuminate\Http\Response
*/
public function edit($id)
{
//
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
//
}
/**
* Get the information about the specified domain.
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function show($id)
{
$domain = Domain::findOrFail($id);
// Only owner (or admin) has access to the domain
if (!Auth::guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
$response = $domain->toArray();
// Add hash information to the response
$response['hash_text'] = $domain->hash(Domain::HASH_TEXT);
$response['hash_cname'] = $domain->hash(Domain::HASH_CNAME);
$response['hash_code'] = $domain->hash(Domain::HASH_CODE);
// Add DNS/MX configuration for the domain
$response['dns'] = self::getDNSConfig($domain);
$response['config'] = self::getMXConfig($domain->namespace);
// Status info
$response['statusInfo'] = self::statusInfo($domain);
$response = array_merge($response, self::domainStatuses($domain));
return response()->json($response);
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
*
* @return \Illuminate\Http\Response
*/
public function update(Request $request, $id)
{
//
}
/**
* Provide DNS MX information to configure specified domain for
*/
protected static function getMXConfig(string $namespace): array
{
$entries = [];
// copy MX entries from an existing domain
if ($master = \config('dns.copyfrom')) {
// TODO: cache this lookup
foreach ((array) dns_get_record($master, DNS_MX) as $entry) {
$entries[] = sprintf(
"@\t%s\t%s\tMX\t%d %s.",
\config('dns.ttl', $entry['ttl']),
$entry['class'],
$entry['pri'],
$entry['target']
);
}
} elseif ($static = \config('dns.static')) {
$entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace));
}
// display SPF settings
if ($spf = \config('dns.spf')) {
$entries[] = ';';
foreach (['TXT', 'SPF'] as $type) {
$entries[] = sprintf(
"@\t%s\tIN\t%s\t\"%s\"",
\config('dns.ttl'),
$type,
$spf
);
}
}
return $entries;
}
/**
* Provide sample DNS config for domain confirmation
*/
protected static function getDNSConfig(Domain $domain): array
{
$serial = date('Ymd01');
$hash_txt = $domain->hash(Domain::HASH_TEXT);
$hash_cname = $domain->hash(Domain::HASH_CNAME);
$hash = $domain->hash(Domain::HASH_CODE);
return [
"@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (",
" {$serial} 10800 3600 604800 86400 )",
";",
"@ IN A <some-ip>",
"www IN A <some-ip>",
";",
"{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.",
"@ 3600 TXT \"{$hash_txt}\"",
];
}
/**
* Prepare domain statuses for the UI
*
* @param \App\Domain $domain Domain object
*
* @return array Statuses array
*/
protected static function domainStatuses(Domain $domain): array
{
return [
'isLdapReady' => $domain->isLdapReady(),
'isConfirmed' => $domain->isConfirmed(),
'isVerified' => $domain->isVerified(),
'isSuspended' => $domain->isSuspended(),
'isActive' => $domain->isActive(),
'isDeleted' => $domain->isDeleted() || $domain->trashed(),
];
}
/**
* Domain status (extended) information.
*
* @param \App\Domain $domain Domain object
*
* @return array Status information
*/
public static function statusInfo(Domain $domain): array
{
$process = [];
// If that is not a public domain, add domain specific steps
$steps = [
'domain-new' => true,
'domain-ldap-ready' => $domain->isLdapReady(),
'domain-verified' => $domain->isVerified(),
'domain-confirmed' => $domain->isConfirmed(),
];
$count = count($steps);
// Create a process check list
foreach ($steps as $step_name => $state) {
$step = [
'label' => $step_name,
'title' => \trans("app.process-{$step_name}"),
'state' => $state,
];
if ($step_name == 'domain-confirmed' && !$state) {
$step['link'] = "/domain/{$domain->id}";
}
$process[] = $step;
if ($state) {
$count--;
}
}
return [
'process' => $process,
'isReady' => $count === 0,
];
}
}
diff --git a/src/app/Http/Controllers/API/EntitlementsController.php b/src/app/Http/Controllers/API/V4/EntitlementsController.php
similarity index 98%
rename from src/app/Http/Controllers/API/EntitlementsController.php
rename to src/app/Http/Controllers/API/V4/EntitlementsController.php
index 062229dc..8fc4ffa2 100644
--- a/src/app/Http/Controllers/API/EntitlementsController.php
+++ b/src/app/Http/Controllers/API/V4/EntitlementsController.php
@@ -1,97 +1,97 @@
<?php
-namespace App\Http\Controllers\API;
+namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class EntitlementsController extends Controller
{
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\JsonResponse
*/
public function create()
{
// TODO
return $this->errorResponse(404);
}
/**
* Remove the specified resource from storage.
*
* @param int $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
{
// TODO
return $this->errorResponse(404);
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function edit($id)
{
// TODO
return $this->errorResponse(404);
}
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
// TODO
return $this->errorResponse(404);
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\JsonResponse
*/
public function store(Request $request)
{
// TODO
return $this->errorResponse(404);
}
/**
* Display the specified resource.
*
* @param int $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
// TODO
return $this->errorResponse(404);
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function update(Request $request, $id)
{
// TODO
return $this->errorResponse(404);
}
}
diff --git a/src/app/Http/Controllers/API/PackagesController.php b/src/app/Http/Controllers/API/V4/PackagesController.php
similarity index 98%
rename from src/app/Http/Controllers/API/PackagesController.php
rename to src/app/Http/Controllers/API/V4/PackagesController.php
index e1506e91..25dae9a1 100644
--- a/src/app/Http/Controllers/API/PackagesController.php
+++ b/src/app/Http/Controllers/API/V4/PackagesController.php
@@ -1,112 +1,112 @@
<?php
-namespace App\Http\Controllers\API;
+namespace App\Http\Controllers\API\V4;
use App\Package;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class PackagesController extends Controller
{
/**
* Show the form for creating a new package.
*
* @return \Illuminate\Http\JsonResponse
*/
public function create()
{
// TODO
return $this->errorResponse(404);
}
/**
* Remove the specified package from storage.
*
* @param int $id Package identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
{
// TODO
return $this->errorResponse(404);
}
/**
* Show the form for editing the specified package.
*
* @param int $id Package identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function edit($id)
{
// TODO
return $this->errorResponse(404);
}
/**
* Display a listing of packages.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
// TODO: Packages should have an 'active' flag too, I guess
$response = [];
$packages = Package::select()->orderBy('title')->get();
foreach ($packages as $package) {
$response[] = [
'id' => $package->id,
'title' => $package->title,
'name' => $package->name,
'description' => $package->description,
'cost' => $package->cost(),
'isDomain' => $package->isDomain(),
];
}
return response()->json($response);
}
/**
* Store a newly created package in storage.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\JsonResponse
*/
public function store(Request $request)
{
// TODO
return $this->errorResponse(404);
}
/**
* Display the specified package.
*
* @param int $id Package identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
// TODO
return $this->errorResponse(404);
}
/**
* Update the specified package in storage.
*
* @param \Illuminate\Http\Request $request Request object
* @param int $id Package identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function update(Request $request, $id)
{
// TODO
return $this->errorResponse(404);
}
}
diff --git a/src/app/Http/Controllers/API/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
similarity index 99%
rename from src/app/Http/Controllers/API/PaymentsController.php
rename to src/app/Http/Controllers/API/V4/PaymentsController.php
index f17ba69e..546c33fa 100644
--- a/src/app/Http/Controllers/API/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -1,221 +1,221 @@
<?php
-namespace App\Http\Controllers\API;
+namespace App\Http\Controllers\API\V4;
use App\Payment;
use App\Wallet;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class PaymentsController extends Controller
{
/**
* Create a new payment.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = Auth::guard()->user();
// TODO: Wallet selection
$wallet = $current_user->wallets()->first();
// Check required fields
$v = Validator::make(
$request->all(),
[
'amount' => 'required|int|min:1',
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
// Register the user in Mollie, if not yet done
// FIXME: Maybe Mollie ID should be bound to a wallet, but then
// The same customer could technicly have multiple
// Mollie IDs, then we'd need to use some "virtual" email
// address (e.g. <wallet-id>@<user-domain>) instead of the user email address
$customer_id = $current_user->getSetting('mollie_id');
$seq_type = 'oneoff';
if (empty($customer_id)) {
$customer = mollie()->customers()->create([
'name' => $current_user->name,
'email' => $current_user->email,
]);
$seq_type = 'first';
$customer_id = $customer->id;
$current_user->setSetting('mollie_id', $customer_id);
}
$payment_request = [
'amount' => [
'currency' => 'CHF',
// a number with two decimals is required
'value' => sprintf('%.2f', $request->amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => $seq_type, // 'first' / 'oneoff' / 'recurring'
'description' => 'Kolab Now Payment', // required
'redirectUrl' => \url('/wallet'), // required for non-recurring payments
'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
];
// Create the payment in Mollie
$payment = mollie()->payments()->create($payment_request);
// Store the payment reference in database
self::storePayment($payment, $wallet->id, $request->amount);
return response()->json([
'status' => 'success',
'redirectUrl' => $payment->getCheckoutUrl(),
]);
}
/**
* Update payment status (and balance).
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function webhook(Request $request)
{
$db_payment = Payment::find($request->id);
// Mollie recommends to return "200 OK" even if the payment does not exist
if (empty($db_payment)) {
return response('Success', 200);
}
// Get the payment details from Mollie
$payment = mollie()->payments()->get($request->id);
if (empty($payment)) {
return response('Success', 200);
}
if ($payment->isPaid()) {
if (!$payment->hasRefunds() && !$payment->hasChargebacks()) {
// The payment is paid and isn't refunded or charged back.
// Update the balance, if it wasn't already
if ($db_payment->status != 'paid') {
$db_payment->wallet->credit($db_payment->amount);
}
} elseif ($payment->hasRefunds()) {
// The payment has been (partially) refunded.
// The status of the payment is still "paid"
// TODO: Update balance
} elseif ($payment->hasChargebacks()) {
// The payment has been (partially) charged back.
// The status of the payment is still "paid"
// TODO: Update balance
}
}
// This is a sanity check, just in case the payment provider api
// sent us open -> paid -> open -> paid. So, we lock the payment after it's paid.
if ($db_payment->status != 'paid') {
$db_payment->status = $payment->status;
$db_payment->save();
}
return response('Success', 200);
}
/**
* Charge a wallet with a "recurring" payment.
*
* @param \App\Wallet $wallet The wallet to charge
* @param int $amount The amount of money in cents
*
* @return bool
*/
public static function directCharge(Wallet $wallet, $amount): bool
{
$customer_id = $wallet->owner->getSetting('mollie_id');
if (empty($customer_id)) {
return false;
}
// Check if there's at least one valid mandate
$mandates = mollie()->mandates()->listFor($customer_id)->filter(function ($mandate) {
return $mandate->isValid();
});
if (empty($mandates)) {
return false;
}
$payment_request = [
'amount' => [
'currency' => 'CHF',
// a number with two decimals is required
'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => 'recurring',
'description' => 'Kolab Now Recurring Payment',
'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'),
];
// Create the payment in Mollie
$payment = mollie()->payments()->create($payment_request);
// Store the payment reference in database
self::storePayment($payment, $wallet->id, $amount);
return true;
}
/**
* Create self URL
*
* @param string $route Route/Path
*
* @return string Full URL
*/
protected static function serviceUrl(string $route): string
{
$url = \url($route);
// When testing the host might be e.g. 127.0.0.1:8000.
// This will not be accepted by Mollie. Let's use our fqdn instead.
// This does not have to be working URL, we do not require Mollie
// to come back (yet).
if (preg_match('|^https?://[0-9][^/]+|', $url, $matches)) {
$url = str_replace($matches[0], \config('app.public_url'), $url);
}
return $url;
}
/**
* Create a payment record in DB
*
* @param object $payment Mollie payment
* @param string $wallet_id Wallet ID
* @param int $amount Amount of money in cents
*/
protected static function storePayment($payment, $wallet_id, $amount): void
{
$db_payment = new Payment();
$db_payment->id = $payment->id;
$db_payment->description = $payment->description;
$db_payment->status = $payment->status;
$db_payment->amount = $amount;
$db_payment->wallet_id = $wallet_id;
$db_payment->save();
}
}
diff --git a/src/app/Http/Controllers/API/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php
similarity index 99%
rename from src/app/Http/Controllers/API/SkusController.php
rename to src/app/Http/Controllers/API/V4/SkusController.php
index 33366eea..869b19e2 100644
--- a/src/app/Http/Controllers/API/SkusController.php
+++ b/src/app/Http/Controllers/API/V4/SkusController.php
@@ -1,181 +1,181 @@
<?php
-namespace App\Http\Controllers\API;
+namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Sku;
use Illuminate\Http\Request;
class SkusController extends Controller
{
/**
* Show the form for creating a new sku.
*
* @return \Illuminate\Http\JsonResponse
*/
public function create()
{
// TODO
return $this->errorResponse(404);
}
/**
* Remove the specified sku from storage.
*
* @param int $id SKU identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
{
// TODO
return $this->errorResponse(404);
}
/**
* Show the form for editing the specified sku.
*
* @param int $id SKU identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function edit($id)
{
// TODO
return $this->errorResponse(404);
}
/**
* Display a listing of the sku.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$response = [];
$skus = Sku::select()->get();
// Note: we do not limit the result to active SKUs only.
// It's because we might need users assigned to old SKUs,
// we need to display these old SKUs on the entitlements list
foreach ($skus as $sku) {
if ($data = $this->skuElement($sku)) {
$response[] = $data;
}
}
usort($response, function ($a, $b) {
return ($b['prio'] <=> $a['prio']);
});
return response()->json($response);
}
/**
* Store a newly created sku in storage.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\JsonResponse
*/
public function store(Request $request)
{
// TODO
return $this->errorResponse(404);
}
/**
* Display the specified sku.
*
* @param int $id SKU identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
// TODO
return $this->errorResponse(404);
}
/**
* Update the specified sku in storage.
*
* @param \Illuminate\Http\Request $request Request object
* @param int $id SKU identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function update(Request $request, $id)
{
// TODO
return $this->errorResponse(404);
}
/**
* Convert SKU information to metadata used by UI to
* display the form control
*
* @param \App\Sku $sku SKU object
*
* @return array|null Metadata
*/
protected function skuElement($sku): ?array
{
$type = $sku->handler_class::entitleableClass();
// ignore incomplete handlers
if (!$type) {
return null;
}
$type = explode('\\', $type);
$type = strtolower(end($type));
$handler = explode('\\', $sku->handler_class);
$handler = strtolower(end($handler));
$data = $sku->toArray();
$data['type'] = $type;
$data['handler'] = $handler;
$data['readonly'] = false;
$data['enabled'] = false;
$data['prio'] = $sku->handler_class::priority();
// Use localized value, toArray() does not get them right
$data['name'] = $sku->name;
$data['description'] = $sku->description;
unset($data['handler_class']);
switch ($handler) {
case 'activesync':
$data['required'] = ['groupware'];
break;
case 'auth2f':
$data['forbidden'] = ['activesync'];
break;
case 'storage':
// Quota range input
$data['readonly'] = true; // only the checkbox will be disabled, not range
$data['enabled'] = true;
$data['range'] = [
'min' => $data['units_free'],
'max' => $sku->handler_class::MAX_ITEMS,
'unit' => $sku->handler_class::ITEM_UNIT,
];
break;
case 'mailbox':
// Mailbox is always enabled and cannot be unset
$data['readonly'] = true;
$data['enabled'] = true;
break;
}
return $data;
}
}
diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
similarity index 73%
rename from src/app/Http/Controllers/API/UsersController.php
rename to src/app/Http/Controllers/API/V4/UsersController.php
index 7fa0906d..05bc2866 100644
--- a/src/app/Http/Controllers/API/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -1,667 +1,488 @@
<?php
-namespace App\Http\Controllers\API;
+namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Domain;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\Sku;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UsersController extends Controller
{
- /**
- * Create a new API\UsersController instance.
- *
- * Ensures that the correct authentication middleware is applied except for /login
- *
- * @return void
- */
- public function __construct()
- {
- $this->middleware('auth:api', ['except' => ['login']]);
- }
-
- /**
- * Helper method for other controllers with user auto-logon
- * functionality
- *
- * @param \App\User $user User model object
- */
- public static function logonResponse(User $user)
- {
- $token = auth()->login($user);
-
- return response()->json([
- 'status' => 'success',
- 'access_token' => $token,
- 'token_type' => 'bearer',
- 'expires_in' => Auth::guard()->factory()->getTTL() * 60,
- ]);
- }
-
/**
* Delete a user.
*
* @param int $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function destroy($id)
{
$user = User::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' => __('app.user-delete-success'),
]);
}
/**
* Listing of users.
*
* The user-entitlements billed to the current user wallet(s)
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
+ \Log::debug("Regular API");
$user = $this->guard()->user();
$result = $user->users()->orderBy('email')->get()->map(function ($user) {
$data = $user->toArray();
$data = array_merge($data, self::userStatuses($user));
return $data;
});
return response()->json($result);
}
- /**
- * Get the authenticated User
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function info()
- {
- $user = $this->guard()->user();
- $response = $this->userResponse($user);
-
- return response()->json($response);
- }
-
- /**
- * Get a JWT token via given credentials.
- *
- * @param \Illuminate\Http\Request $request The API request.
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function login(Request $request)
- {
- $v = Validator::make(
- $request->all(),
- [
- 'email' => 'required|min:2',
- 'password' => 'required|min:4',
- ]
- );
-
- if ($v->fails()) {
- return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
- }
-
- $credentials = $request->only('email', 'password');
-
- if ($token = $this->guard()->attempt($credentials)) {
- $sf = new \App\Auth\SecondFactor($this->guard()->user());
-
- if ($response = $sf->requestHandler($request)) {
- return $response;
- }
-
- return $this->respondWithToken($token);
- }
-
- return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401);
- }
-
- /**
- * Log the user out (Invalidate the token)
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function logout()
- {
- $this->guard()->logout();
-
- return response()->json([
- 'status' => 'success',
- 'message' => __('auth.logoutsuccess')
- ]);
- }
-
- /**
- * Refresh a token.
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function refresh()
- {
- return $this->respondWithToken($this->guard()->refresh());
- }
-
- /**
- * Get the token array structure.
- *
- * @param string $token Respond with this token.
- *
- * @return \Illuminate\Http\JsonResponse
- */
- protected function respondWithToken($token)
- {
- return response()->json(
- [
- 'access_token' => $token,
- 'token_type' => 'bearer',
- 'expires_in' => $this->guard()->factory()->getTTL() * 60
- ]
- );
- }
-
/**
* 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 (empty($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;
$response['skus'][$sku->id] = [
// 'cost' => $ent->cost,
'count' => isset($response['skus'][$sku->id]) ? $response['skus'][$sku->id]['count'] + 1 : 1,
];
}
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 = [];
$steps = [
'user-new' => true,
'user-ldap-ready' => $user->isLdapReady(),
'user-imap-ready' => $user->isImapReady(),
];
// Create a process check list
foreach ($steps as $step_name => $state) {
$step = [
'label' => $step_name,
'title' => \trans("app.process-{$step_name}"),
'state' => $state,
];
$process[] = $step;
}
list ($local, $domain) = explode('@', $user->email);
$domain = Domain::where('namespace', $domain)->first();
// If that is not a public domain, add domain specific steps
if ($domain && !$domain->isPublic()) {
$domain_status = DomainsController::statusInfo($domain);
$process = array_merge($process, $domain_status['process']);
}
$all = count($process);
$checked = count(array_filter($process, function ($v) {
return $v['state'];
}));
return [
'process' => $process,
'isReady' => $all === $checked,
];
}
/**
* 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);
}
if ($error_response = $this->validateUserRequest($request, null, $settings)) {
return $error_response;
}
if (empty($request->package) || !($package = \App\Package::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);
}
$user_name = !empty($settings['first_name']) ? $settings['first_name'] : '';
if (!empty($settings['last_name'])) {
$user_name .= ' ' . $settings['last_name'];
}
DB::beginTransaction();
// Create user record
$user = User::create([
'name' => $user_name,
'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' => __('app.user-create-success'),
]);
}
/**
* Update user data.
*
* @param \Illuminate\Http\Request $request The API request.
* @params string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$user = User::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();
return response()->json([
'status' => 'success',
'message' => __('app.user-update-success'),
]);
}
/**
* Get the guard to be used during authentication.
*
* @return \Illuminate\Contracts\Auth\Guard
*/
public function guard()
{
return Auth::guard();
}
/**
* Update user entitlements.
*
* @param \App\User $user The user
* @param array|null $skus Set of SKUs for the user
*/
protected function updateEntitlements(User $user, $skus)
{
if (!is_array($skus)) {
return;
}
// Existing SKUs
// FIXME: Is there really no query builder method to get result indexed
// by some column or primary key?
$all_skus = Sku::all()->mapWithKeys(function ($sku) {
return [$sku->id => $sku];
});
// Existing user entitlements
// Note: We sort them by cost, so e.g. for storage we get these free first
$entitlements = $user->entitlements()->orderBy('cost')->get();
// Go through existing entitlements and remove those no longer needed
foreach ($entitlements as $ent) {
$sku_id = $ent->sku_id;
if (array_key_exists($sku_id, $skus)) {
// An existing entitlement exists on the requested list
$skus[$sku_id] -= 1;
if ($skus[$sku_id] < 0) {
$ent->delete();
}
} elseif ($all_skus[$sku_id]->handler_class != \App\Handlers\Mailbox::class) {
// An existing entitlement does not exists on the requested list
// Never delete 'mailbox' SKU
$ent->delete();
}
}
// Add missing entitlements
foreach ($skus as $sku_id => $count) {
if ($count > 0 && $all_skus->has($sku_id)) {
$user->assignSku($all_skus[$sku_id], $count);
}
}
}
/**
* Create a response data array for specified user.
*
* @param \App\User $user User object
*
* @return array Response data
*/
- protected function userResponse(User $user): array
+ public static function userResponse(User $user): array
{
$response = $user->toArray();
// Settings
// TODO: It might be reasonable to limit the list of settings here to these
// that are safe and are used in the UI
$response['settings'] = [];
foreach ($user->settings 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);
$response = array_merge($response, self::userStatuses($user));
// Add discount info to wallet object output
$map_func = function ($wallet) {
$result = $wallet->toArray();
if ($wallet->discount) {
$result['discount'] = $wallet->discount->discount;
$result['discount_description'] = $wallet->discount->description;
}
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 userStatuses(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 The 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:512',
'last_name' => '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, false)) {
+ } elseif ($error = \App\Utils::validateEmail($email, $controller, false)) {
$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::validateEmail($alias, $controller, true))
+ && ($error = \App\Utils::validateEmail($alias, $controller, true))
) {
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']);
}
-
- /**
- * Email address (login or alias) validation
- *
- * @param string $email Email address
- * @param \App\User $user The account owner
- * @param bool $is_alias The email is an alias
- *
- * @return string Error message on validation error
- */
- protected static function validateEmail(string $email, User $user, bool $is_alias = false): ?string
- {
- $attribute = $is_alias ? 'alias' : 'email';
-
- if (strpos($email, '@') === false) {
- return \trans('validation.entryinvalid', ['attribute' => $attribute]);
- }
-
- list($login, $domain) = explode('@', $email);
-
- // Check if domain exists
- $domain = Domain::where('namespace', Str::lower($domain))->first();
-
- if (empty($domain)) {
- return \trans('validation.domaininvalid');
- }
-
- // Validate login part alone
- $v = Validator::make(
- [$attribute => $login],
- [$attribute => ['required', new UserEmailLocal(!$domain->isPublic())]]
- );
-
- if ($v->fails()) {
- return $v->errors()->toArray()[$attribute][0];
- }
-
- // Check if it is one of domains available to the user
- // TODO: We should have a helper that returns "flat" array with domain names
- // I guess we could use pluck() somehow
- $domains = array_map(
- function ($domain) {
- return $domain->namespace;
- },
- $user->domains()
- );
-
- if (!in_array($domain->namespace, $domains)) {
- return \trans('validation.entryexists', ['attribute' => 'domain']);
- }
-
- // Check if user with specified address already exists
- if (User::findByEmail($email)) {
- return \trans('validation.entryexists', ['attribute' => $attribute]);
- }
-
- return null;
- }
}
diff --git a/src/app/Http/Controllers/API/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php
similarity index 95%
rename from src/app/Http/Controllers/API/WalletsController.php
rename to src/app/Http/Controllers/API/V4/WalletsController.php
index 94247981..c275ba4d 100644
--- a/src/app/Http/Controllers/API/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/WalletsController.php
@@ -1,97 +1,93 @@
<?php
-/**
- * API\WalletsController
- */
-
-namespace App\Http\Controllers\API;
+namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
/**
* API\WalletsController
*/
class WalletsController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*
* @param int $id
*
* @return \Illuminate\Http\Response
*/
public function show($id)
{
//
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
*
* @return \Illuminate\Http\Response
*/
public function edit($id)
{
//
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
*
* @return \Illuminate\Http\Response
*/
public function update(Request $request, $id)
{
//
}
/**
* Remove the specified resource from storage.
*
* @param int $id
*
* @return \Illuminate\Http\Response
*/
public function destroy($id)
{
//
}
}
diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php
index bedc807d..1ccbd82b 100644
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -1,81 +1,84 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
\App\Http\Middleware\RequestLogger::class,
\App\Http\Middleware\TrustProxies::class,
\App\Http\Middleware\CheckForMaintenanceMode::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
- \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
+ \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
'throttle:60,1',
'bindings',
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
+ 'admin' => \App\Http\Middleware\AuthenticateAdmin::class,
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
/**
* The priority-sorted list of middleware.
*
* This forces non-global middleware to always be in the given order.
*
* @var array
*/
protected $middlewarePriority = [
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
+ \App\Http\Middleware\AuthenticateAdmin::class,
\App\Http\Middleware\Authenticate::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\Illuminate\Auth\Middleware\Authorize::class,
+ \App\Http\Middleware\AuthenticateAdmin::class,
];
}
diff --git a/src/app/Http/Middleware/AuthenticateAdmin.php b/src/app/Http/Middleware/AuthenticateAdmin.php
new file mode 100644
index 00000000..dfbff382
--- /dev/null
+++ b/src/app/Http/Middleware/AuthenticateAdmin.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+
+class AuthenticateAdmin
+{
+ /**
+ * Handle an incoming request.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @param \Closure $next
+ * @return mixed
+ */
+ public function handle($request, Closure $next)
+ {
+ $user = auth()->user();
+
+ if (!$user) {
+ abort(403, "Unauthorized");
+ }
+
+ if ($user->role !== "admin") {
+ abort(403, "Unauthorized");
+ }
+
+ return $next($request);
+ }
+}
diff --git a/src/app/Jobs/UserVerify.php b/src/app/Jobs/UserVerify.php
index 55717580..cb131732 100644
--- a/src/app/Jobs/UserVerify.php
+++ b/src/app/Jobs/UserVerify.php
@@ -1,55 +1,75 @@
<?php
namespace App\Jobs;
use App\Backends\IMAP;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class UserVerify implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
protected $user;
public $tries = 5;
/** @var bool Delete the job if its models no longer exist. */
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @param User $user The user to create.
*
* @return void
*/
public function __construct(User $user)
{
$this->user = $user;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
+ // Verify a mailbox sku is among the user entitlements.
+ $skuMailbox = \App\Sku::where('title', 'mailbox')->first();
+
+ if (!$skuMailbox) {
+ return;
+ }
+
+ $mailbox = \App\Entitlement::where(
+ [
+ 'sku_id' => $skuMailbox->id,
+ 'entitleable_id' => $this->user->id,
+ 'entitleable_type' => User::class
+ ]
+ )->first();
+
+ if (!$mailbox) {
+ return;
+ }
+
+ // The user has a mailbox
if (!$this->user->isImapReady()) {
if (IMAP::verifyAccount($this->user->email)) {
$this->user->status |= User::STATUS_IMAP_READY;
$this->user->status |= User::STATUS_ACTIVE;
$this->user->save();
}
}
}
}
diff --git a/src/app/Jobs/WalletPayment.php b/src/app/Jobs/WalletPayment.php
index c16b50f9..d1304b12 100644
--- a/src/app/Jobs/WalletPayment.php
+++ b/src/app/Jobs/WalletPayment.php
@@ -1,51 +1,51 @@
<?php
namespace App\Jobs;
use App\Wallet;
-use App\Http\Controllers\API\PaymentsController;
+use App\Http\Controllers\API\V4\PaymentsController;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class WalletPayment implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
protected $wallet;
public $tries = 5;
/** @var bool Delete the job if its models no longer exist. */
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @param \App\Wallet $wallet The wallet to charge.
*
* @return void
*/
public function __construct(Wallet $wallet)
{
$this->wallet = $wallet;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
if (!$this->wallet->balance < 0) {
PaymentsController::directCharge($this->wallet, $this->wallet->balance * -1);
}
}
}
diff --git a/src/app/User.php b/src/app/User.php
index 62fdef93..4fe5a6d5 100644
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -1,605 +1,618 @@
<?php
namespace App;
use App\Entitlement;
use App\UserAlias;
use App\Traits\UserAliasesTrait;
use App\Traits\UserSettingsTrait;
use App\Wallet;
use Illuminate\Notifications\Notifiable;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Iatstuti\Database\Support\NullableFields;
use Tymon\JWTAuth\Contracts\JWTSubject;
/**
* The eloquent definition of a User.
*
* @property string $email
* @property int $id
* @property string $name
* @property string $password
* @property int $status
*/
class User extends Authenticatable implements JWTSubject
{
use Notifiable;
use NullableFields;
use UserAliasesTrait;
use UserSettingsTrait;
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;
// change the default primary key type
public $incrementing = false;
protected $keyType = 'bigint';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'email',
'password',
'password_ldap',
'status'
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'password',
'password_ldap',
'remember_token',
+ 'role'
];
protected $nullable = [
'name',
'password',
'password_ldap'
];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'email_verified_at' => 'datetime',
];
/**
* 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(),
'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, 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();
// TODO: Sanity check, this probably should be in preReq() on handlers
// or in EntitlementObserver
if ($sku->handler_class::entitleableClass() != User::class) {
throw new \Exception("Cannot assign non-user SKU ({$sku->title}) to a user");
}
while ($count > 0) {
\App\Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $sku->id,
'cost' => $sku->units_free >= $exists ? $sku->cost : 0,
'entitleable_id' => $this->id,
'entitleable_type' => User::class
]);
$exists++;
$count--;
}
return $this;
}
/**
* Check if current user can delete another object.
*
* @param \App\User|\App\Domain $object A user|domain object
*
* @return bool True if he can, False otherwise
*/
public function canDelete($object): bool
{
if (!method_exists($object, 'wallet')) {
return false;
}
+ if ($this->role == "admin") {
+ return true;
+ }
+
$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 $this->wallets->contains($wallet) || $this->accounts->contains($wallet);
}
/**
* Check if current user can read data of another object.
*
* @param \App\User|\App\Domain $object A user|domain object
*
* @return bool True if he can, False otherwise
*/
public function canRead($object): bool
{
if (!method_exists($object, 'wallet')) {
return false;
}
+ if ($this->role == "admin") {
+ return true;
+ }
+
if ($object instanceof User && $this->id == $object->id) {
return true;
}
$wallet = $object->wallet();
return $this->wallets->contains($wallet) || $this->accounts->contains($wallet);
}
/**
* Check if current user can update data of another object.
*
* @param \App\User|\App\Domain $object A user|domain object
*
* @return bool True if he can, False otherwise
*/
public function canUpdate($object): bool
{
if (!method_exists($object, 'wallet')) {
return false;
}
+ if ($this->role == "admin") {
+ return true;
+ }
+
if ($object instanceof User && $this->id == $object->id) {
return true;
}
return $this->canDelete($object);
}
/**
* List the domains to which this user is entitled.
*
* @return Domain[]
*/
public function domains()
{
$dbdomains = Domain::whereRaw(
sprintf(
'(type & %s) AND (status & %s)',
Domain::TYPE_PUBLIC,
Domain::STATUS_ACTIVE
)
)->get();
$domains = [];
foreach ($dbdomains as $dbdomain) {
$domains[] = $dbdomain;
}
foreach ($this->wallets as $wallet) {
$entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get();
foreach ($entitlements as $entitlement) {
$domain = $entitlement->entitleable;
\Log::info("Found domain for {$this->email}: {$domain->namespace} (owned)");
$domains[] = $domain;
}
}
foreach ($this->accounts as $wallet) {
$entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get();
foreach ($entitlements as $entitlement) {
$domain = $entitlement->entitleable;
\Log::info("Found domain {$this->email}: {$domain->namespace} (charged)");
$domains[] = $domain;
}
}
return $domains;
}
public function entitlement()
{
return $this->morphOne('App\Entitlement', 'entitleable');
}
/**
* Entitlements for this user.
*
* Note that these are entitlements that apply to the user account, and not entitlements that
* this user owns.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function entitlements()
{
return $this->hasMany('App\Entitlement', 'entitleable_id', 'id');
}
public function addEntitlement($entitlement)
{
if (!$this->entitlements->contains($entitlement)) {
return $this->entitlements()->save($entitlement);
}
}
/**
* Helper to find user by email address, whether it is
* main email address, alias or external email
*
* @param string $email Email address
*
* @return \App\User User model object if found
*/
public static function findByEmail(string $email): ?User
{
if (strpos($email, '@') === false) {
return null;
}
$email = \strtolower($email);
$user = self::where('email', $email)->first();
if ($user) {
return $user;
}
$alias = UserAlias::where('alias', $email)->first();
if ($alias) {
return $alias->user;
}
// TODO: External email
return null;
}
public function getJWTIdentifier()
{
return $this->getKey();
}
public function getJWTCustomClaims()
{
return [];
}
/**
* 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;
}
/**
* Any (additional) properties of this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function settings()
{
return $this->hasMany('App\UserSetting', 'user_id');
}
/**
* 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.
*
* Users assigned to wallets the current user controls or owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function users()
{
$wallets = array_merge(
$this->wallets()->pluck('id')->all(),
$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', 'App\User');
}
/**
* Verification codes for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function verificationcodes()
{
return $this->hasMany('App\VerificationCode', 'user_id', 'id');
}
/**
* Returns the wallet by which the user is controlled
*
* @return \App\Wallet A wallet object
*/
public function wallet(): Wallet
{
$entitlement = $this->entitlement()->first();
// TODO: No entitlement should not happen, but in tests we have
// such cases, so we fallback to the user's wallet in this case
return $entitlement ? $entitlement->wallet : $this->wallets()->first();
}
/**
* 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)
{
if (!empty($password)) {
$this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]);
$this->attributes['password_ldap'] = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $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;
}
}
diff --git a/src/app/Utils.php b/src/app/Utils.php
index a46b8fb6..a789f146 100644
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -1,97 +1,163 @@
<?php
namespace App;
+use App\Rules\UserEmailLocal;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
/**
* Small utility functions for App.
*/
class Utils
{
/**
* Provide all unique combinations of elements in $input, with order and duplicates irrelevant.
*
* @param array $input The input array of elements.
*
* @return array[]
*/
public static function powerSet(array $input): array
{
$output = [];
for ($x = 0; $x < count($input); $x++) {
self::combine($input, $x + 1, 0, [], 0, $output);
}
return $output;
}
/**
* Returns a UUID in the form of an integer.
*
* @return integer
*/
public static function uuidInt(): int
{
$hex = Uuid::uuid4();
$bin = pack('h*', str_replace('-', '', $hex));
$ids = unpack('L', $bin);
$id = array_shift($ids);
return $id;
}
/**
* Returns a UUID in the form of a string.
*
* @return string
*/
public static function uuidStr(): string
{
return Uuid::uuid4()->toString();
}
private static function combine($input, $r, $index, $data, $i, &$output): void
{
$n = count($input);
// Current cobination is ready
if ($index == $r) {
$output[] = array_slice($data, 0, $r);
return;
}
// When no more elements are there to put in data[]
if ($i >= $n) {
return;
}
// current is included, put next at next location
$data[$index] = $input[$i];
self::combine($input, $r, $index + 1, $data, $i + 1, $output);
// current is excluded, replace it with next (Note that i+1
// is passed, but index is not changed)
self::combine($input, $r, $index, $data, $i + 1, $output);
}
/**
* Create a configuration/environment data to be passed to
* the UI
*
* @todo For a lack of better place this is put here for now
*
* @return array Configuration data
*/
public static function uiEnv(): array
{
$opts = ['app.name', 'app.url', 'app.domain'];
$env = \app('config')->getMany($opts);
$countries = include resource_path('countries.php');
$env['countries'] = $countries ?: [];
+ $env['jsapp'] = strpos(request()->getHttpHost(), 'admin.') === 0 ? 'admin.js' : 'user.js';
+
return $env;
}
+
+ /**
+ * Email address (login or alias) validation
+ *
+ * @param string $email Email address
+ * @param \App\User $user The account owner
+ * @param bool $is_alias The email is an alias
+ *
+ * @return string Error message on validation error
+ */
+ public static function validateEmail(
+ string $email,
+ \App\User $user,
+ bool $is_alias = false
+ ): ?string {
+ $attribute = $is_alias ? 'alias' : 'email';
+
+ if (strpos($email, '@') === false) {
+ return \trans('validation.entryinvalid', ['attribute' => $attribute]);
+ }
+
+ list($login, $domain) = explode('@', $email);
+
+ // Check if domain exists
+ $domain = Domain::where('namespace', Str::lower($domain))->first();
+
+ if (empty($domain)) {
+ return \trans('validation.domaininvalid');
+ }
+
+ // Validate login part alone
+ $v = Validator::make(
+ [$attribute => $login],
+ [$attribute => ['required', new UserEmailLocal(!$domain->isPublic())]]
+ );
+
+ if ($v->fails()) {
+ return $v->errors()->toArray()[$attribute][0];
+ }
+
+ // Check if it is one of domains available to the user
+ // TODO: We should have a helper that returns "flat" array with domain names
+ // I guess we could use pluck() somehow
+ $domains = array_map(
+ function ($domain) {
+ return $domain->namespace;
+ },
+ $user->domains()
+ );
+
+ if (!in_array($domain->namespace, $domains)) {
+ return \trans('validation.entryexists', ['attribute' => 'domain']);
+ }
+
+ // Check if user with specified address already exists
+ if (User::findByEmail($email)) {
+ return \trans('validation.entryexists', ['attribute' => $attribute]);
+ }
+
+ return null;
+ }
}
diff --git a/src/database/migrations/2020_03_27_134609_user_table_add_role_column.php b/src/database/migrations/2020_03_27_134609_user_table_add_role_column.php
new file mode 100644
index 00000000..b60da550
--- /dev/null
+++ b/src/database/migrations/2020_03_27_134609_user_table_add_role_column.php
@@ -0,0 +1,39 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class UserTableAddRoleColumn extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'users',
+ function (Blueprint $table) {
+ $table->string('role')->nullable();
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'users',
+ function (Blueprint $table) {
+ $table->dropColumn('role');
+ }
+ );
+ }
+}
diff --git a/src/database/seeds/DomainSeeder.php b/src/database/seeds/DomainSeeder.php
index 4edc94c7..d21e4890 100644
--- a/src/database/seeds/DomainSeeder.php
+++ b/src/database/seeds/DomainSeeder.php
@@ -1,55 +1,65 @@
<?php
use App\Domain;
use Illuminate\Database\Seeder;
class DomainSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
$domains = [
"kolabnow.com",
"mykolab.com",
"attorneymail.ch",
"barmail.ch",
"collaborative.li",
"diplomail.ch",
"freedommail.ch",
"groupoffice.ch",
"journalistmail.ch",
"legalprivilege.ch",
"libertymail.co"
];
foreach ($domains as $domain) {
Domain::create(
[
'namespace' => $domain,
'status' => Domain::STATUS_CONFIRMED + Domain::STATUS_ACTIVE,
'type' => Domain::TYPE_PUBLIC
]
);
}
+ if (!in_array(\config('app.domain'), $domains)) {
+ Domain::create(
+ [
+ 'namespace' => \config('app.domain'),
+ 'status' => DOMAIN::STATUS_CONFIRMED + Domain::STATUS_ACTIVE,
+ 'type' => Domain::TYPE_PUBLIC
+ ]
+ );
+ }
+
$domains = [
'example.com',
'example.net',
'example.org'
];
foreach ($domains as $domain) {
Domain::create(
[
'namespace' => $domain,
'status' => Domain::STATUS_CONFIRMED + Domain::STATUS_ACTIVE,
'type' => Domain::TYPE_EXTERNAL
]
);
}
}
}
diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php
index 1bfae731..c96b0d93 100644
--- a/src/database/seeds/UserSeeder.php
+++ b/src/database/seeds/UserSeeder.php
@@ -1,136 +1,149 @@
<?php
use App\Auth\SecondFactor;
use App\Domain;
use App\Entitlement;
use App\User;
use App\Sku;
use Carbon\Carbon;
use Illuminate\Database\Seeder;
use App\Wallet;
// phpcs:ignore
class UserSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
$domain = Domain::create(
[
'namespace' => 'kolab.org',
'status' => Domain::STATUS_NEW
+ Domain::STATUS_ACTIVE
+ Domain::STATUS_CONFIRMED
+ Domain::STATUS_VERIFIED,
'type' => Domain::TYPE_EXTERNAL
]
);
$john = User::create(
[
'name' => 'John Doe',
'email' => 'john@kolab.org',
'password' => 'simple123',
'email_verified_at' => now()
]
);
$john->setSettings(
[
'first_name' => 'John',
'last_name' => 'Doe',
'currency' => 'USD',
'country' => 'US',
'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005",
'external_email' => 'john.doe.external@gmail.com',
'phone' => '+1 509-248-1111',
]
);
$john->setAliases(['john.doe@kolab.org']);
$wallet = $john->wallets->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_lite = \App\Package::where('title', 'lite')->first();
$domain->assignPackage($package_domain, $john);
$john->assignPackage($package_kolab);
$jack = User::create(
[
'name' => 'Jack Daniels',
'email' => 'jack@kolab.org',
'password' => 'simple123',
'email_verified_at' => now()
]
);
$jack->setSettings(
[
'first_name' => 'Jack',
'last_name' => 'Daniels',
'currency' => 'USD',
'country' => 'US'
]
);
$jack->setAliases(['jack.daniels@kolab.org']);
$john->assignPackage($package_kolab, $jack);
foreach ($john->entitlements as $entitlement) {
$entitlement->created_at = Carbon::now()->subMonthsWithoutOverflow(1);
$entitlement->updated_at = Carbon::now()->subMonthsWithoutOverflow(1);
$entitlement->save();
}
$ned = User::create(
[
'name' => 'Edward Flanders',
'email' => 'ned@kolab.org',
'password' => 'simple123',
'email_verified_at' => now()
]
);
$ned->setSettings(
[
'first_name' => 'Edward',
'last_name' => 'Flanders',
'currency' => 'USD',
'country' => 'US'
]
);
$john->assignPackage($package_kolab, $ned);
$ned->assignSku(\App\Sku::where('title', 'activesync')->first(), 1);
// Ned is a controller on Jack's wallet
$john->wallets()->first()->addController($ned);
// Ned is also our 2FA test user
$sku2fa = Sku::firstOrCreate(['title' => '2fa']);
$ned->assignSku($sku2fa);
SecondFactor::seed('ned@kolab.org');
$joe = User::create(
[
'name' => 'Joe Sixpack',
'email' => 'joe@kolab.org',
'password' => 'simple123',
'email_verified_at' => now()
]
);
$john->assignPackage($package_lite, $joe);
factory(User::class, 10)->create();
+
+ $jeroen = User::create(
+ [
+ 'name' => 'Jeroen van Meeuwen',
+ 'email' => 'jeroen@jeroen.jeroen',
+ 'password' => 'jeroen',
+ 'email_verified_at' => now()
+ ]
+ );
+
+ $jeroen->role = "admin";
+
+ $jeroen->save();
}
}
diff --git a/src/public/mix-manifest.json b/src/public/mix-manifest.json
index 2d601171..df1588cc 100644
--- a/src/public/mix-manifest.json
+++ b/src/public/mix-manifest.json
@@ -1,4 +1,5 @@
{
- "/js/app.js": "/js/app.js",
+ "/js/admin.js": "/js/admin.js",
+ "/js/user.js": "/js/user.js",
"/css/app.css": "/css/app.css"
}
diff --git a/src/resources/js/admin.js b/src/resources/js/admin.js
new file mode 100644
index 00000000..caf680dc
--- /dev/null
+++ b/src/resources/js/admin.js
@@ -0,0 +1,9 @@
+/**
+ * Application code for the admin UI
+ */
+
+import router from './routes-admin'
+
+window.router = router
+
+require('./app')
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index 77c8205a..f2a0a106 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,287 +1,286 @@
/**
* 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/Menu'
-import router from './routes'
import store from './store'
import FontAwesomeIcon from './fontawesome'
import VueToastr from '@deveodk/vue-toastr'
window.Vue = require('vue')
Vue.component('svg-icon', FontAwesomeIcon)
Vue.use(VueToastr, {
defaultPosition: 'toast-bottom-right',
defaultTimeout: 5000
})
const vTooltip = (el, binding) => {
const t = []
if (binding.modifiers.focus) t.push('focus')
if (binding.modifiers.hover) t.push('hover')
if (binding.modifiers.click) t.push('click')
if (!t.length) t.push('hover')
$(el).tooltip({
title: binding.value,
placement: binding.arg || 'top',
trigger: t.join(' '),
html: !!binding.modifiers.html,
});
}
Vue.directive('tooltip', {
bind: vTooltip,
update: vTooltip,
unbind (el) {
$(el).tooltip('dispose')
}
})
// Add a response interceptor for general/validation error handler
// This have to be before Vue and Router setup. Otherwise we would
// not be able to handle axios responses initiated from inside
// components created/mounted handlers (e.g. signup code verification link)
window.axios.interceptors.response.use(
response => {
// Do nothing
return response
},
error => {
let error_msg
let status = error.response ? error.response.status : 200
if (error.response && status == 422) {
error_msg = "Form validation error"
$.each(error.response.data.errors || {}, (idx, msg) => {
$('form').each((i, form) => {
const input_name = ($(form).data('validation-prefix') || '') + idx
const input = $('#' + input_name)
if (input.length) {
// Create an error message\
// API responses can use a string, array or object
let msg_text = ''
if ($.type(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
input.children(':not(:first-child)').each((index, element) => {
if (msg[index]) {
$(element).find('input').addClass('is-invalid')
}
})
input.addClass('is-invalid').next('.invalid-feedback').remove()
input.after(feedback)
}
else {
// Standard form element
input.addClass('is-invalid')
input.parent().find('.invalid-feedback').remove()
input.parent().append(feedback)
}
return false
}
});
})
$('form .is-invalid:not(.listinput-widget)').first().focus()
}
else if (error.response && error.response.data) {
error_msg = error.response.data.message
}
else {
error_msg = error.request ? error.request.statusText : error.message
}
app.$toastr('error', error_msg || "Server Error", 'Error')
// Pass the error as-is
return Promise.reject(error)
}
)
const app = new Vue({
el: '#app',
components: {
'app-component': AppComponent,
'menu-component': MenuComponent
},
store,
- router,
+ router: window.router,
data() {
return {
isLoading: true
}
},
methods: {
// Clear (bootstrap) form validation state
clearFormValidation(form) {
$(form).find('.is-invalid').removeClass('is-invalid')
$(form).find('.invalid-feedback').remove()
},
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(token, dashboard) {
store.commit('logoutUser') // destroy old state data
store.commit('loginUser')
localStorage.setItem('token', token)
axios.defaults.headers.common.Authorization = 'Bearer ' + token
if (dashboard !== false) {
- router.push(store.state.afterLogin || { name: 'dashboard' })
+ this.$router.push(store.state.afterLogin || { name: 'dashboard' })
}
store.state.afterLogin = null
},
// Set user state to "not logged in"
logoutUser() {
store.commit('logoutUser')
localStorage.setItem('token', '')
delete axios.defaults.headers.common.Authorization
- router.push({ name: 'login' })
+ this.$router.push({ name: 'login' })
},
// Display "loading" overlay (to be used by route components)
startLoading() {
this.isLoading = true
// Lock the UI with the 'loading...' element
$('#app').append($('<div class="app-loader"><div class="spinner-border" role="status"><span class="sr-only">Loading</span></div></div>'))
},
// Hide "loading" overlay
stopLoading() {
$('#app > .app-loader').fadeOut()
this.isLoading = false
},
errorPage(code, msg) {
// 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".
const map = {
400: "Bad request",
401: "Unauthorized",
403: "Access denied",
404: "Not found",
405: "Method not allowed",
500: "Internal server error"
}
if (!msg) msg = map[code] || "Unknown Error"
const error_page = `<div id="error-page"><div class="code">${code}</div><div class="message">${msg}</div></div>`
$('#app').children(':not(nav)').remove()
$('#app').append(error_page)
},
errorHandler(error) {
this.stopLoading()
if (!error.response) {
// TODO: probably network connection error
} else if (error.response.status === 401) {
this.logoutUser()
} else {
this.errorPage(error.response.status, error.response.statusText)
}
},
price(price) {
return (price/100).toLocaleString('de-DE', { style: 'currency', currency: 'CHF' })
},
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 'Deleted'
}
if (domain.isSuspended) {
return 'Suspended'
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
return 'Not Ready'
}
return 'Active'
},
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 'Deleted'
}
if (user.isSuspended) {
return 'Suspended'
}
if (!user.isImapReady || !user.isLdapReady) {
return 'Not Ready'
}
return 'Active'
}
}
})
diff --git a/src/resources/js/routes-admin.js b/src/resources/js/routes-admin.js
new file mode 100644
index 00000000..53a51793
--- /dev/null
+++ b/src/resources/js/routes-admin.js
@@ -0,0 +1,67 @@
+import Vue from 'vue'
+import VueRouter from 'vue-router'
+
+Vue.use(VueRouter)
+
+import DashboardComponent from '../vue/Admin/Dashboard'
+import Error404Component from '../vue/404'
+import LoginComponent from '../vue/Login'
+import LogoutComponent from '../vue/Logout'
+import PasswordResetComponent from '../vue/PasswordReset'
+
+import store from './store'
+
+const routes = [
+ {
+ path: '/',
+ redirect: { name: 'dashboard' }
+ },
+ {
+ path: '/dashboard',
+ name: 'dashboard',
+ component: DashboardComponent,
+ meta: { requiresAuth: true }
+ },
+ {
+ path: '/login',
+ name: 'login',
+ component: LoginComponent
+ },
+ {
+ path: '/logout',
+ name: 'logout',
+ component: LogoutComponent
+ },
+ {
+ path: '/password-reset/:code?',
+ name: 'password-reset',
+ component: PasswordResetComponent
+ },
+ {
+ name: '404',
+ path: '*',
+ component: Error404Component
+ }
+]
+
+const router = new VueRouter({
+ mode: 'history',
+ routes
+})
+
+router.beforeEach((to, from, next) => {
+ // check if the route requires authentication and user is not logged in
+ if (to.matched.some(route => route.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
+ }
+
+ next()
+})
+
+export default router
diff --git a/src/resources/js/routes.js b/src/resources/js/routes-user.js
similarity index 100%
rename from src/resources/js/routes.js
rename to src/resources/js/routes-user.js
diff --git a/src/resources/js/user.js b/src/resources/js/user.js
new file mode 100644
index 00000000..d99e318d
--- /dev/null
+++ b/src/resources/js/user.js
@@ -0,0 +1,9 @@
+/**
+ * Application code for the user UI
+ */
+
+import router from './routes-user'
+
+window.router = router
+
+require('./app')
diff --git a/src/resources/views/layouts/app.blade.php b/src/resources/views/layouts/app.blade.php
index fd6754d5..97463117 100644
--- a/src/resources/views/layouts/app.blade.php
+++ b/src/resources/views/layouts/app.blade.php
@@ -1,22 +1,22 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name') }} -- @yield('title')</title>
{{-- TODO: PWA disabled for now: @laravelPWA --}}
<link href="{{ asset('css/app.css') }}" rel="stylesheet">
</head>
<body>
<div class="outer-container">
@yield('content')
</div>
<script>window.config = {!! json_encode($env) !!}</script>
- <script src="{{ asset('js/app.js') }}" defer></script>
+ <script src="{{ asset('js/' . $env['jsapp']) }}" defer></script>
</body>
</html>
diff --git a/src/resources/vue/Admin/Dashboard.vue b/src/resources/vue/Admin/Dashboard.vue
new file mode 100644
index 00000000..f3c53cba
--- /dev/null
+++ b/src/resources/vue/Admin/Dashboard.vue
@@ -0,0 +1,32 @@
+<template>
+ <div v-if="!$root.isLoading" class="container" dusk="dashboard-component">
+ <div id="dashboard-nav"></div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ isReady: true
+ }
+ },
+ mounted() {
+ const authInfo = this.$store.state.isLoggedIn ? this.$store.state.authInfo : null
+
+ if (authInfo) {
+
+ } else {
+ this.$root.startLoading()
+ axios.get('/api/auth/info')
+ .then(response => {
+ this.$store.state.authInfo = response.data
+ this.$root.stopLoading()
+ })
+ .catch(this.$root.errorHandler)
+ }
+ },
+ methods: {
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
index ab75bd80..eef59d1d 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,57 +1,98 @@
<?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!
|
*/
Route::group(
[
'middleware' => 'api',
'prefix' => 'auth'
],
function ($router) {
- Route::get('info', 'API\UsersController@info');
- Route::post('login', 'API\UsersController@login');
- Route::post('logout', 'API\UsersController@logout');
- Route::post('refresh', 'API\UsersController@refresh');
+ Route::post('login', 'API\AuthController@login');
+ Route::group(
+ ['middleware' => 'auth:api'],
+ function ($router) {
+ Route::get('info', 'API\AuthController@info');
+ Route::post('logout', 'API\AuthController@logout');
+ Route::post('refresh', 'API\AuthController@refresh');
+ }
+ );
+ }
+);
+
+Route::group(
+ [
+ 'domain' => \config('app.domain'),
+ 'middleware' => 'api',
+ 'prefix' => '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::get('signup/plans', 'API\SignupController@plans');
Route::post('signup/init', 'API\SignupController@init');
Route::post('signup/verify', 'API\SignupController@verify');
Route::post('signup', 'API\SignupController@signup');
}
);
Route::group(
[
+ 'domain' => \config('app.domain'),
'middleware' => 'auth:api',
'prefix' => 'v4'
],
function () {
- Route::apiResource('domains', API\DomainsController::class);
- Route::get('domains/{id}/confirm', 'API\DomainsController@confirm');
+ Route::apiResource('domains', API\V4\DomainsController::class);
+ Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm');
- Route::apiResource('entitlements', API\EntitlementsController::class);
- Route::apiResource('packages', API\PackagesController::class);
- Route::apiResource('skus', API\SkusController::class);
- Route::apiResource('users', API\UsersController::class);
- Route::apiResource('wallets', API\WalletsController::class);
+ Route::apiResource('entitlements', API\V4\EntitlementsController::class);
+ Route::apiResource('packages', API\V4\PackagesController::class);
+ Route::apiResource('skus', API\V4\SkusController::class);
+ Route::apiResource('users', API\V4\UsersController::class);
+ Route::apiResource('wallets', API\V4\WalletsController::class);
- Route::post('payments', 'API\PaymentsController@store');
+ Route::post('payments', 'API\V4\PaymentsController@store');
}
);
-Route::post('webhooks/payment/mollie', 'API\PaymentsController@webhook');
+Route::group(
+ [
+ 'domain' => \config('app.domain'),
+ ],
+ function () {
+ Route::post('webhooks/payment/mollie', 'API\V4\PaymentsController@webhook');
+ }
+);
+
+Route::group(
+ [
+ 'domain' => 'admin.' . \config('app.domain'),
+ 'middleware' => ['auth:api', 'admin'],
+ 'prefix' => 'v4',
+ ],
+ function () {
+ Route::apiResource('domains', API\V4\Admin\DomainsController::class);
+ Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm');
+
+ Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class);
+ Route::apiResource('packages', API\V4\Admin\PackagesController::class);
+ Route::apiResource('skus', API\V4\Admin\SkusController::class);
+ Route::apiResource('users', API\V4\Admin\UsersController::class);
+ Route::apiResource('wallets', API\V4\Admin\WalletsController::class);
+ }
+);
diff --git a/src/tests/Browser/Admin/LogonTest.php b/src/tests/Browser/Admin/LogonTest.php
new file mode 100644
index 00000000..0c8b67ae
--- /dev/null
+++ b/src/tests/Browser/Admin/LogonTest.php
@@ -0,0 +1,161 @@
+<?php
+
+namespace Tests\Browser\Admin;
+
+use Tests\Browser;
+use Tests\Browser\Components\Menu;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+use Illuminate\Foundation\Testing\DatabaseMigrations;
+
+class LogonTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ // This will set baseURL for all tests in this file
+ // If we wanted to visit both user and admin in one test
+ // we can also just call visit() with full url
+ Browser::$baseUrl = str_replace('//', '//admin.', \config('app.url'));
+ }
+
+ /**
+ * Test menu on logon page
+ */
+ public function testLogonMenu(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home());
+ $browser->within(new Menu(), function ($browser) {
+ $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']);
+ });
+ });
+ }
+
+ /**
+ * Test redirect to /login if user is unauthenticated
+ */
+ public function testLogonRedirect(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/dashboard');
+
+ // Checks if we're really on the login page
+ $browser->waitForLocation('/login')
+ ->on(new Home());
+ });
+ }
+
+ /**
+ * Logon with wrong password/user test
+ */
+ public function testLogonWrongCredentials(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('jeroen@jeroen.jeroen', 'wrong');
+
+ // Error message
+ $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) {
+ $browser->assertToastTitle('Error')
+ ->assertToastMessage('Invalid username or password.')
+ ->closeToast();
+ });
+
+ // Checks if we're still on the logon page
+ $browser->on(new Home());
+ });
+ }
+
+ /**
+ * Successful logon test
+ */
+ public function testLogonSuccessful(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true);
+
+ // Checks if we're really on Dashboard page
+ $browser->on(new Dashboard())
+ ->within(new Menu(), function ($browser) {
+ $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']);
+ })
+ ->assertUser('jeroen@jeroen.jeroen');
+
+ // Test that visiting '/' with logged in user does not open logon form
+ // but "redirects" to the dashboard
+ $browser->visit('/')->on(new Dashboard());
+ });
+ }
+
+ /**
+ * Logout test
+ *
+ * @depends testLogonSuccessful
+ */
+ public function testLogout(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->on(new Dashboard());
+
+ // Click the Logout button
+ $browser->within(new Menu(), function ($browser) {
+ $browser->click('.link-logout');
+ });
+
+ // We expect the logon page
+ $browser->waitForLocation('/login')
+ ->on(new Home());
+
+ // with default menu
+ $browser->within(new Menu(), function ($browser) {
+ $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']);
+ });
+
+ // Success toast message
+ $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
+ $browser->assertToastTitle('')
+ ->assertToastMessage('Successfully logged out')
+ ->closeToast();
+ });
+ });
+ }
+
+ /**
+ * Logout by URL test
+ */
+ public function testLogoutByURL(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true);
+
+ // Checks if we're really on Dashboard page
+ $browser->on(new Dashboard());
+
+ // Use /logout url, and expect the logon page
+ $browser->visit('/logout')
+ ->waitForLocation('/login')
+ ->on(new Home());
+
+ // with default menu
+ $browser->within(new Menu(), function ($browser) {
+ $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']);
+ });
+
+ // Success toast message
+ $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
+ $browser->assertToastTitle('')
+ ->assertToastMessage('Successfully logged out')
+ ->closeToast();
+ });
+ });
+ }
+}
diff --git a/src/tests/Browser/Pages/Dashboard.php b/src/tests/Browser/Pages/Dashboard.php
index 4fd18e93..0f933fed 100644
--- a/src/tests/Browser/Pages/Dashboard.php
+++ b/src/tests/Browser/Pages/Dashboard.php
@@ -1,57 +1,57 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Page;
class Dashboard extends Page
{
/**
* Get the URL for the page.
*
* @return string
*/
public function url()
{
return '/dashboard';
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function assert($browser)
{
$browser->assertPathIs('/dashboard')
->waitUntilMissing('@app .app-loader')
- ->assertVisible('@links');
+ ->assertPresent('@links');
}
/**
* Assert logged-in user
*
* @param \Laravel\Dusk\Browser $browser The browser object
* @param string $user User email
*/
public function assertUser($browser, $user)
{
$browser->assertVue('$store.state.authInfo.email', $user, '@dashboard-component');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements()
{
return [
'@app' => '#app',
'@links' => '#dashboard-nav',
'@status' => '#status-box',
];
}
}
diff --git a/src/tests/Browser/StatusTest.php b/src/tests/Browser/StatusTest.php
index a2c51e33..33ef1d94 100644
--- a/src/tests/Browser/StatusTest.php
+++ b/src/tests/Browser/StatusTest.php
@@ -1,167 +1,167 @@
<?php
namespace Tests\Browser;
use App\Domain;
use App\User;
use Tests\Browser;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\DomainInfo;
use Tests\Browser\Pages\DomainList;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\UserInfo;
use Tests\Browser\Pages\UserList;
use Tests\TestCaseDusk;
use Illuminate\Support\Facades\DB;
class StatusTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
DB::statement("UPDATE domains SET status = (status | " . Domain::STATUS_CONFIRMED . ")"
. " WHERE namespace = 'kolab.org'");
DB::statement("UPDATE users SET status = (status | " . User::STATUS_IMAP_READY . ")"
. " WHERE email = 'john@kolab.org'");
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
DB::statement("UPDATE domains SET status = (status | " . Domain::STATUS_CONFIRMED . ")"
. " WHERE namespace = 'kolab.org'");
DB::statement("UPDATE users SET status = (status | " . User::STATUS_IMAP_READY . ")"
. " WHERE email = 'john@kolab.org'");
parent::tearDown();
}
/**
* Test account status in the Dashboard
*/
public function testDashboard(): void
{
// Unconfirmed domain
$domain = Domain::where('namespace', 'kolab.org')->first();
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
$this->browse(function ($browser) use ($domain) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->whenAvailable('@status', function ($browser) {
$browser->assertSeeIn('.card-title', 'Account status:')
->assertSeeIn('.card-title span.text-danger', 'Not ready')
->with('ul.status-list', function ($browser) {
$browser->assertElementsCount('li', 7)
->assertVisible('li:nth-child(1) svg.fa-check-square')
->assertSeeIn('li:nth-child(1) span', 'User registered')
->assertVisible('li:nth-child(2) svg.fa-check-square')
->assertSeeIn('li:nth-child(2) span', 'User created')
->assertVisible('li:nth-child(3) svg.fa-check-square')
->assertSeeIn('li:nth-child(3) span', 'User mailbox created')
->assertVisible('li:nth-child(4) svg.fa-check-square')
->assertSeeIn('li:nth-child(4) span', 'Custom domain registered')
->assertVisible('li:nth-child(5) svg.fa-check-square')
->assertSeeIn('li:nth-child(5) span', 'Custom domain created')
->assertVisible('li:nth-child(6) svg.fa-check-square')
->assertSeeIn('li:nth-child(6) span', 'Custom domain verified')
->assertVisible('li:nth-child(7) svg.fa-square')
->assertSeeIn('li:nth-child(7) a', 'Custom domain ownership verified');
});
});
// Confirm the domain and wait until the whole status box disappears
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// At the moment, this may take about 10 seconds
$browser->waitUntilMissing('@status', 15);
});
}
/**
* Test domain status on domains list and domain info page
*
* @depends testDashboard
*/
public function testDomainStatus(): void
{
$domain = Domain::where('namespace', 'kolab.org')->first();
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
$this->browse(function ($browser) use ($domain) {
$browser->on(new Dashboard())
->click('@links a.link-domains')
->on(new DomainList())
// Assert domain status icon
->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-danger')
->assertText('@table tbody tr:first-child td:first-child svg title', 'Not Ready')
->click('@table tbody tr:first-child td:first-child a')
->on(new DomainInfo())
->whenAvailable('@status', function ($browser) {
$browser->assertSeeIn('.card-title', 'Domain status:')
->assertSeeIn('.card-title span.text-danger', 'Not ready')
->with('ul.status-list', function ($browser) {
$browser->assertElementsCount('li', 4)
->assertVisible('li:nth-child(1) svg.fa-check-square')
->assertSeeIn('li:nth-child(1) span', 'Custom domain registered')
->assertVisible('li:nth-child(2) svg.fa-check-square')
->assertSeeIn('li:nth-child(2) span', 'Custom domain created')
->assertVisible('li:nth-child(3) svg.fa-check-square')
->assertSeeIn('li:nth-child(3) span', 'Custom domain verified')
->assertVisible('li:nth-child(4) svg.fa-square')
->assertSeeIn('li:nth-child(4) span', 'Custom domain ownership verified');
});
});
// Confirm the domain and wait until the whole status box disappears
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// At the moment, this may take about 10 seconds
$browser->waitUntilMissing('@status', 15);
});
}
/**
* Test user status on users list
*
* @depends testDashboard
*/
public function testUserStatus(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->status ^= User::STATUS_IMAP_READY;
$john->save();
$this->browse(function ($browser) {
$browser->visit(new Dashboard())
->click('@links a.link-users')
->on(new UserList())
// Assert user status icons
->assertVisible('@table tbody tr:first-child td:first-child svg.fa-user.text-success')
->assertText('@table tbody tr:first-child td:first-child svg title', 'Active')
- ->assertVisible('@table tbody tr:nth-child(2) td:first-child svg.fa-user.text-danger')
- ->assertText('@table tbody tr:nth-child(2) td:first-child svg title', 'Not Ready')
- ->click('@table tbody tr:nth-child(2) td:first-child a')
+ ->assertVisible('@table tbody tr:nth-child(3) td:first-child svg.fa-user.text-danger')
+ ->assertText('@table tbody tr:nth-child(3) td:first-child svg title', 'Not Ready')
+ ->click('@table tbody tr:nth-child(3) td:first-child a')
->on(new UserInfo())
->with('@form', function (Browser $browser) {
// Assert stet in the user edit form
$browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) #status', 'Not Ready');
});
// TODO: The status should also be live-updated here
// Maybe when we have proper websocket communication
});
}
}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
index fd4e439b..a3e74c15 100644
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -1,585 +1,588 @@
<?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\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UsersTest extends TestCaseDusk
{
private $profile = [
'first_name' => 'John',
'last_name' => 'Doe',
];
/**
* {@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();
Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
}
/**
* {@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();
Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testInfoUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$user = User::where('email', 'john@kolab.org')->first();
$browser->visit('/user/' . $user->id)->on(new Home());
});
}
/**
* Test users list page (unauthenticated)
*/
public function testListUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/users')->on(new Home());
});
}
/**
* Test users list page
*/
public function testList(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-users', 'User accounts')
->click('@links .link-users')
->on(new UserList())
->whenAvailable('@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', 'john@kolab.org')
- ->assertSeeIn('tbody tr:nth-child(3) a', 'ned@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')
->assertVisible('tbody tr:nth-child(1) button.button-delete')
->assertVisible('tbody tr:nth-child(2) button.button-delete')
- ->assertVisible('tbody tr:nth-child(3) button.button-delete');
+ ->assertVisible('tbody tr:nth-child(3) button.button-delete')
+ ->assertVisible('tbody tr:nth-child(4) button.button-delete');
});
});
}
/**
* Test user account editing page (not profile page)
*
* @depends testList
*/
public function testInfo(): void
{
$this->browse(function (Browser $browser) {
$browser->on(new UserList())
- ->click('@table tr:nth-child(2) a')
+ ->click('@table tr:nth-child(3) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@form', 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', 'Email')
->assertValue('div.row:nth-child(4) input[type=text]', 'john@kolab.org')
->assertDisabled('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(['john.doe@kolab.org'])
->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('button[type=submit]', 'Submit');
// Clear some fields and submit
$browser->type('#first_name', '')
->type('#last_name', '')
->click('button[type=submit]');
})
->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
$browser->assertToastTitle('')
->assertToastMessage('User data updated successfully')
->closeToast();
});
// Test error handling (password)
$browser->with('@form', function (Browser $browser) {
$browser->type('#password', 'aaaaaa')
->type('#password_confirmation', '')
->click('button[type=submit]')
->waitFor('#password + .invalid-feedback')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.')
->assertFocused('#password');
})
->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) {
$browser->assertToastTitle('Error')
->assertToastMessage('Form validation error')
->closeToast();
});
// TODO: Test password change
// Test form error handling (aliases)
$browser->with('@form', function (Browser $browser) {
// TODO: For some reason, clearing the input value
// with ->type('#password', '') does not work, maybe some dusk/vue intricacy
// For now we just use the default password
$browser->type('#password', 'simple123')
->type('#password_confirmation', 'simple123')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]');
})
->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) {
$browser->assertToastTitle('Error')
->assertToastMessage('Form validation error')
->closeToast();
})
->with('@form', function (Browser $browser) {
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(2, 'The specified alias is invalid.', false);
});
});
// Test adding aliases
$browser->with('@form', function (Browser $browser) {
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(2)
->addListEntry('john.test@kolab.org');
})
->click('button[type=submit]');
})
->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
$browser->assertToastTitle('')
->assertToastMessage('User data updated successfully')
->closeToast();
});
$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('@form', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(8) label', 'Subscriptions')
->assertVisible('@skus.row:nth-child(8)')
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 5)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox')
->assertSeeIn('tbody tr:nth-child(1) td.price', '4,44 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(2)->setQuotaValue(3);
})
->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', '5,55 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.'
)
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(4) td.name', '2-Factor Authentication')
->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',
'Two factor authentication for webmail and administration panel'
)
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(5) td.name', 'Activesync')
->assertSeeIn('tbody tr:nth-child(5) td.price', '1,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',
'Mobile synchronization'
)
->click('tbody tr:nth-child(5) td.selection input');
})
->assertMissing('@skus table + .hint')
->click('button[type=submit]');
})
->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
$browser->assertToastTitle('')
->assertToastMessage('User data updated successfully')
->closeToast();
});
$expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage'];
$this->assertUserEntitlements($john, $expected);
// Test subscriptions interaction
$browser->with('@form', 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 '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 adding page
*
* @depends testList
*/
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('@form', 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', 'Email')
->assertValue('div.row:nth-child(3) input[type=text]', '')
->assertEnabled('div.row:nth-child(3) input[type=text]')
->assertSeeIn('div.row:nth-child(4) label', 'Email aliases')
->assertVisible('div.row:nth-child(4) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue([])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(5) label', 'Password')
->assertValue('div.row:nth-child(5) input[type=password]', '')
->assertSeeIn('div.row:nth-child(6) label', 'Confirm password')
->assertValue('div.row:nth-child(6) input[type=password]', '')
->assertSeeIn('div.row:nth-child(7) 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,99 CHF/month')
->assertSeeIn('tbody tr:nth-child(2) .price', '4,44 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]');
})
->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) {
$browser->assertToastTitle('Error')
->assertToastMessage('Form validation error')
->closeToast();
})
->with('@form', function (Browser $browser) {
$browser->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('@form', 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]');
})
->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) {
$browser->assertToastTitle('Error')
->assertToastMessage('Form validation error')
->closeToast();
})
->with('@form', function (Browser $browser) {
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(1, 'The specified alias is invalid.', false);
});
});
// Successful account creation
$browser->with('@form', function (Browser $browser) {
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(1)
->addListEntry('julia.roberts2@kolab.org');
})
->click('button[type=submit]');
})
->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
$browser->assertToastTitle('')
->assertToastMessage('User created successfully')
->closeToast();
})
// check redirection to users list
->waitForLocation('/users')
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
- $browser->assertElementsCount('tbody tr', 4)
- ->assertSeeIn('tbody tr:nth-child(3) a', 'julia.roberts@kolab.org');
+ $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->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage']);
});
}
/**
* 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) {
$browser->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
- $browser->assertElementsCount('tbody tr', 4)
- ->assertSeeIn('tbody tr:nth-child(3) a', 'julia.roberts@kolab.org')
- ->click('tbody tr:nth-child(3) button.button-delete');
+ $browser->assertElementsCount('tbody tr', 5)
+ ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org')
+ ->click('tbody tr:nth-child(4) 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');
})
->whenAvailable('@table', function (Browser $browser) {
- $browser->click('tbody tr:nth-child(3) button.button-delete');
+ $browser->click('tbody tr:nth-child(4) button.button-delete');
})
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->click('@button-action');
})
->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
$browser->assertToastTitle('')
->assertToastMessage('User deleted successfully')
->closeToast();
})
->with('@table', function (Browser $browser) {
- $browser->assertElementsCount('tbody tr', 3)
+ $browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
- ->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org')
- ->assertSeeIn('tbody tr:nth-child(3) a', 'ned@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 clicking Delete on the controller record redirects to /profile/delete
$browser
->with('@table', function (Browser $browser) {
- $browser->click('tbody tr:nth-child(2) button.button-delete');
+ $browser->click('tbody tr:nth-child(3) button.button-delete');
})
->waitForLocation('/profile/delete');
});
// Test that non-controller user cannot see/delete himself on the users list
// Note: Access to /profile/delete page is tested in UserProfileTest.php
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 0);
});
});
// Test that controller user (Ned) can see/delete 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', 3)
- ->assertElementsCount('tbody button.button-delete', 3);
+ $browser->assertElementsCount('tbody tr', 4)
+ ->assertElementsCount('tbody button.button-delete', 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())
->click('@table tr:nth-child(2) a')
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->assertElementsCount('tbody tr', 5)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '3,99 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,56 CHF/month¹')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,99 CHF/month¹')
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹')
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,90 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('@form', function (Browser $browser) {
$browser->whenAvailable('@packages', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) .price', '8,99 CHF/month¹') // Groupware
->assertSeeIn('tbody tr:nth-child(2) .price', '3,99 CHF/month¹'); // Lite
})
->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
}
}
diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php
new file mode 100644
index 00000000..5ed5e7e0
--- /dev/null
+++ b/src/tests/Feature/Controller/Admin/UsersTest.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Tests\Feature\Controller\Admin;
+
+use App\Domain;
+use App\User;
+use Tests\TestCase;
+
+class UsersTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ // This will set base URL for all tests in this file
+ // If we wanted to access both user and admin in one test
+ // we can also just call post/get/whatever with full url
+ \config(['app.url' => str_replace('//', '//admin.', \config('app.url'))]);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test (/api/v4/index)
+ */
+ public function testIndex(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+
+ $response = $this->actingAs($user)->get("api/v4/users");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/users");
+ $response->assertStatus(200);
+
+ // TODO: Test the response
+ $this->markTestIncomplete();
+ }
+}
diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php
new file mode 100644
index 00000000..99f6f1cb
--- /dev/null
+++ b/src/tests/Feature/Controller/AuthTest.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Domain;
+use App\User;
+use Tests\TestCase;
+
+class AuthTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('UsersControllerTest1@userscontroller.com');
+ $this->deleteTestDomain('userscontroller.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('UsersControllerTest1@userscontroller.com');
+ $this->deleteTestDomain('userscontroller.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test fetching current user info (/api/auth/info)
+ */
+ public function testInfo(): void
+ {
+ $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+ $domain = $this->getTestDomain('userscontroller.com', [
+ 'status' => Domain::STATUS_NEW,
+ 'type' => Domain::TYPE_PUBLIC,
+ ]);
+
+ $response = $this->actingAs($user)->get("api/auth/info");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($user->id, $json['id']);
+ $this->assertEquals($user->email, $json['email']);
+ $this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']);
+ $this->assertTrue(is_array($json['statusInfo']));
+ $this->assertTrue(is_array($json['settings']));
+ $this->assertTrue(is_array($json['aliases']));
+
+ // Note: Details of the content are tested in testUserResponse()
+ }
+
+ /**
+ * Test /api/auth/login
+ */
+ public function testLogin(): string
+ {
+ // Request with no data
+ $response = $this->post("api/auth/login", []);
+ $response->assertStatus(422);
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json['errors']);
+ $this->assertArrayHasKey('email', $json['errors']);
+ $this->assertArrayHasKey('password', $json['errors']);
+
+ // Request with invalid password
+ $post = ['email' => 'john@kolab.org', 'password' => 'wrong'];
+ $response = $this->post("api/auth/login", $post);
+ $response->assertStatus(401);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame('Invalid username or password.', $json['message']);
+
+ // Valid user+password
+ $post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
+ $response = $this->post("api/auth/login", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+ $this->assertTrue(!empty($json['access_token']));
+ $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']);
+ $this->assertEquals('bearer', $json['token_type']);
+
+ // TODO: We have browser tests for 2FA but we should probably also test it here
+
+ return $json['access_token'];
+ }
+
+ /**
+ * Test /api/auth/logout
+ *
+ * @depends testLogin
+ */
+ public function testLogout($token): void
+ {
+ // Request with no token, testing that it requires auth
+ $response = $this->post("api/auth/logout");
+ $response->assertStatus(401);
+
+ // Test the same using JSON mode
+ $response = $this->json('POST', "api/auth/logout", []);
+ $response->assertStatus(401);
+
+ // Request with valid token
+ $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals('success', $json['status']);
+ $this->assertEquals('Successfully logged out.', $json['message']);
+
+ // Check if it really destroyed the token?
+ $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info");
+ $response->assertStatus(401);
+ }
+
+ public function testRefresh(): void
+ {
+ // TODO
+ $this->markTestIncomplete();
+ }
+}
diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php
index 03d8fa1f..b1245308 100644
--- a/src/tests/Feature/Controller/SkusTest.php
+++ b/src/tests/Feature/Controller/SkusTest.php
@@ -1,70 +1,70 @@
<?php
namespace Tests\Feature\Controller;
-use App\Http\Controllers\API\SkusController;
+use App\Http\Controllers\API\V4\SkusController;
use App\Sku;
use Tests\TestCase;
class SkusTest extends TestCase
{
/**
* 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::where('title', 'mailbox')->first();
$response = $this->actingAs($user)->get("api/v4/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(9, $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 for SkusController::skuElement()
*/
public function testSkuElement(): void
{
$sku = Sku::where('title', 'storage')->first();
$result = $this->invokeMethod(new SkusController(), 'skuElement', [$sku]);
$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']);
$this->assertSame('user', $result['type']);
$this->assertSame('storage', $result['handler']);
$this->assertSame($sku->units_free, $result['range']['min']);
$this->assertSame($sku->handler_class::MAX_ITEMS, $result['range']['max']);
$this->assertSame($sku->handler_class::ITEM_UNIT, $result['range']['unit']);
$this->assertTrue($result['readonly']);
$this->assertTrue($result['enabled']);
// Test all SKU types
$this->markTestIncomplete();
}
}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
index 5bd77054..b1bd615f 100644
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -1,873 +1,773 @@
<?php
namespace Tests\Feature\Controller;
use App\Discount;
use App\Domain;
-use App\Http\Controllers\API\UsersController;
+use App\Http\Controllers\API\V4\UsersController;
use App\Package;
use App\Sku;
use App\User;
use App\Wallet;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
class UsersTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UsersControllerTest2@userscontroller.com');
$this->deleteTestUser('UsersControllerTest3@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
$this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestDomain('userscontroller.com');
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UsersControllerTest2@userscontroller.com');
$this->deleteTestUser('UsersControllerTest3@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
$this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestDomain('userscontroller.com');
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
parent::tearDown();
}
- /**
- * Test fetching current user info (/api/auth/info)
- */
- public function testInfo(): void
- {
- $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
- $domain = $this->getTestDomain('userscontroller.com', [
- 'status' => Domain::STATUS_NEW,
- 'type' => Domain::TYPE_PUBLIC,
- ]);
-
- $response = $this->actingAs($user)->get("api/auth/info");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertEquals($user->id, $json['id']);
- $this->assertEquals($user->email, $json['email']);
- $this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']);
- $this->assertTrue(is_array($json['statusInfo']));
- $this->assertTrue(is_array($json['settings']));
- $this->assertTrue(is_array($json['aliases']));
-
- // Note: Details of the content are tested in testUserResponse()
- }
-
/**
* Test user deleting (DELETE /api/v4/users/<id>)
*/
public function testDestroy(): void
{
// First create some users/accounts to delete
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$john = $this->getTestUser('john@kolab.org');
$user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user1->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $user1);
$user1->assignPackage($package_kolab, $user2);
$user1->assignPackage($package_kolab, $user3);
// Test unauth access
$response = $this->delete("api/v4/users/{$user2->id}");
$response->assertStatus(401);
// Test access to other user/account
$response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}");
$response->assertStatus(403);
$response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(403);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test that non-controller cannot remove himself
$response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(403);
// Test removing a non-controller user
$response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('User deleted successfully.', $json['message']);
// Test removing self (an account with users)
$response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('User deleted successfully.', $json['message']);
}
/**
* Test user deleting (DELETE /api/v4/users/<id>)
*/
public function testDestroyByController(): void
{
// Create an account with additional controller - $user2
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user1->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $user1);
$user1->assignPackage($package_kolab, $user2);
$user1->assignPackage($package_kolab, $user3);
$user1->wallets()->first()->addController($user2);
// TODO/FIXME:
// For now controller can delete himself, as well as
// the whole account he has control to, including the owner
// Probably he should not be able to do either of those
// However, this is not 0-regression scenario as we
// do not fully support additional controllers.
//$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}");
//$response->assertStatus(403);
$response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(200);
$response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(200);
// Note: More detailed assertions in testDestroy() above
$this->assertTrue($user1->fresh()->trashed());
$this->assertTrue($user2->fresh()->trashed());
$this->assertTrue($user3->fresh()->trashed());
}
/**
* Test user listing (GET /api/v4/users)
*/
public function testIndex(): void
{
// Test unauth access
$response = $this->get("api/v4/users");
$response->assertStatus(401);
$jack = $this->getTestUser('jack@kolab.org');
$joe = $this->getTestUser('joe@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($jack)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(0, $json);
$response = $this->actingAs($john)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame($jack->email, $json[0]['email']);
$this->assertSame($joe->email, $json[1]['email']);
$this->assertSame($john->email, $json[2]['email']);
$this->assertSame($ned->email, $json[3]['email']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isDeleted', $json[0]);
$this->assertArrayHasKey('isSuspended', $json[0]);
$this->assertArrayHasKey('isActive', $json[0]);
$this->assertArrayHasKey('isLdapReady', $json[0]);
$this->assertArrayHasKey('isImapReady', $json[0]);
$response = $this->actingAs($ned)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame($jack->email, $json[0]['email']);
$this->assertSame($joe->email, $json[1]['email']);
$this->assertSame($john->email, $json[2]['email']);
$this->assertSame($ned->email, $json[3]['email']);
}
- /**
- * Test /api/auth/login
- */
- public function testLogin(): string
- {
- // Request with no data
- $response = $this->post("api/auth/login", []);
- $response->assertStatus(422);
- $json = $response->json();
-
- $this->assertSame('error', $json['status']);
- $this->assertCount(2, $json['errors']);
- $this->assertArrayHasKey('email', $json['errors']);
- $this->assertArrayHasKey('password', $json['errors']);
-
- // Request with invalid password
- $post = ['email' => 'john@kolab.org', 'password' => 'wrong'];
- $response = $this->post("api/auth/login", $post);
- $response->assertStatus(401);
-
- $json = $response->json();
-
- $this->assertSame('error', $json['status']);
- $this->assertSame('Invalid username or password.', $json['message']);
-
- // Valid user+password
- $post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
- $response = $this->post("api/auth/login", $post);
- $json = $response->json();
-
- $response->assertStatus(200);
- $this->assertTrue(!empty($json['access_token']));
- $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']);
- $this->assertEquals('bearer', $json['token_type']);
-
- // TODO: We have browser tests for 2FA but we should probably also test it here
-
- return $json['access_token'];
- }
-
- /**
- * Test /api/auth/logout
- *
- * @depends testLogin
- */
- public function testLogout($token): void
- {
- // Request with no token, testing that it requires auth
- $response = $this->post("api/auth/logout");
- $response->assertStatus(401);
-
- // Test the same using JSON mode
- $response = $this->json('POST', "api/auth/logout", []);
- $response->assertStatus(401);
-
- // Request with valid token
- $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertEquals('success', $json['status']);
- $this->assertEquals('Successfully logged out.', $json['message']);
-
- // Check if it really destroyed the token?
- $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info");
- $response->assertStatus(401);
- }
-
- public function testRefresh(): void
- {
- // TODO
- $this->markTestIncomplete();
- }
-
public function testStatusInfo(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user->status = User::STATUS_NEW;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertFalse($result['isReady']);
$this->assertCount(3, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(false, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(false, $result['process'][2]['state']);
$user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertTrue($result['isReady']);
$this->assertCount(3, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$domain->status |= Domain::STATUS_VERIFIED;
$domain->type = Domain::TYPE_EXTERNAL;
$domain->save();
$result = UsersController::statusInfo($user);
$this->assertFalse($result['isReady']);
$this->assertCount(7, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$this->assertSame('domain-new', $result['process'][3]['label']);
$this->assertSame(true, $result['process'][3]['state']);
$this->assertSame('domain-ldap-ready', $result['process'][4]['label']);
$this->assertSame(false, $result['process'][4]['state']);
$this->assertSame('domain-verified', $result['process'][5]['label']);
$this->assertSame(true, $result['process'][5]['state']);
$this->assertSame('domain-confirmed', $result['process'][6]['label']);
$this->assertSame(false, $result['process'][6]['state']);
}
/**
* Test user data response used in show and info actions
*/
public function testUserResponse(): void
{
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
$this->assertEquals($user->id, $result['id']);
$this->assertEquals($user->email, $result['email']);
$this->assertEquals($user->status, $result['status']);
$this->assertTrue(is_array($result['statusInfo']));
$this->assertTrue(is_array($result['aliases']));
$this->assertCount(1, $result['aliases']);
$this->assertSame('john.doe@kolab.org', $result['aliases'][0]);
$this->assertTrue(is_array($result['settings']));
$this->assertSame('US', $result['settings']['country']);
$this->assertSame('USD', $result['settings']['currency']);
$this->assertTrue(is_array($result['accounts']));
$this->assertTrue(is_array($result['wallets']));
$this->assertCount(0, $result['accounts']);
$this->assertCount(1, $result['wallets']);
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertArrayNotHasKey('discount', $result['wallet']);
$ned = $this->getTestUser('ned@kolab.org');
$ned_wallet = $ned->wallets()->first();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]);
$this->assertEquals($ned->id, $result['id']);
$this->assertEquals($ned->email, $result['email']);
$this->assertTrue(is_array($result['accounts']));
$this->assertTrue(is_array($result['wallets']));
$this->assertCount(1, $result['accounts']);
$this->assertCount(1, $result['wallets']);
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertSame($wallet->id, $result['accounts'][0]['id']);
$this->assertSame($ned_wallet->id, $result['wallets'][0]['id']);
// Test discount in a response
$discount = Discount::where('code', 'TEST')->first();
$wallet->discount()->associate($discount);
$wallet->save();
$user->refresh();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
$this->assertEquals($user->id, $result['id']);
$this->assertSame($discount->id, $result['wallet']['discount_id']);
$this->assertSame($discount->discount, $result['wallet']['discount']);
$this->assertSame($discount->description, $result['wallet']['discount_description']);
$this->assertSame($discount->id, $result['wallets'][0]['discount_id']);
$this->assertSame($discount->discount, $result['wallets'][0]['discount']);
$this->assertSame($discount->description, $result['wallets'][0]['discount_description']);
}
/**
* Test fetching user data/profile (GET /api/v4/users/<user-id>)
*/
public function testShow(): void
{
$userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com');
// Test getting profile of self
$response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}");
$json = $response->json();
$response->assertStatus(200);
$this->assertEquals($userA->id, $json['id']);
$this->assertEquals($userA->email, $json['email']);
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
$this->assertTrue(is_array($json['aliases']));
$this->assertSame([], $json['skus']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isDeleted', $json);
$this->assertArrayHasKey('isSuspended', $json);
$this->assertArrayHasKey('isActive', $json);
$this->assertArrayHasKey('isLdapReady', $json);
$this->assertArrayHasKey('isImapReady', $json);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
// Test unauthorized access to a profile of other user
$response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}");
$response->assertStatus(403);
// Test authorized access to a profile of other user
// Ned: Additional account controller
$response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}");
$response->assertStatus(200);
$response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}");
$response->assertStatus(200);
// John: Account owner
$response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}");
$response->assertStatus(200);
$response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}");
$response->assertStatus(200);
$json = $response->json();
$storage_sku = Sku::where('title', 'storage')->first();
$groupware_sku = Sku::where('title', 'groupware')->first();
$mailbox_sku = Sku::where('title', 'mailbox')->first();
$secondfactor_sku = Sku::where('title', '2fa')->first();
$this->assertCount(5, $json['skus']);
+
$this->assertSame(2, $json['skus'][$storage_sku->id]['count']);
$this->assertSame(1, $json['skus'][$groupware_sku->id]['count']);
$this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']);
$this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']);
}
/**
* Test user creation (POST /api/v4/users)
*/
public function testStore(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
// Test empty request
$response = $this->actingAs($john)->post("/api/v4/users", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The email field is required.", $json['errors']['email']);
$this->assertSame("The password field is required.", $json['errors']['password'][0]);
$this->assertCount(2, $json);
// Test access by user not being a wallet controller
$post = ['first_name' => 'Test'];
$response = $this->actingAs($jack)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(403);
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test some invalid data
$post = ['password' => '12345678', 'email' => 'invalid'];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
$this->assertSame('The specified email is invalid.', $json['errors']['email']);
// Test existing user email
$post = [
'password' => 'simple',
'password_confirmation' => 'simple',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'jack.daniels@kolab.org',
];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The specified email is not available.', $json['errors']['email']);
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$post = [
'password' => 'simple',
'password_confirmation' => 'simple',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'john2.doe2@kolab.org',
'aliases' => ['useralias1@kolab.org', 'useralias2@kolab.org'],
];
// Missing package
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Package is required.", $json['errors']['package']);
$this->assertCount(2, $json);
// Invalid package
$post['package'] = $package_domain->id;
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Invalid package selected.", $json['errors']['package']);
$this->assertCount(2, $json);
// Test full and valid data
$post['package'] = $package_kolab->id;
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = User::where('email', 'john2.doe2@kolab.org')->first();
$this->assertInstanceOf(User::class, $user);
$this->assertSame('John2', $user->getSetting('first_name'));
$this->assertSame('Doe2', $user->getSetting('last_name'));
$aliases = $user->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@kolab.org', $aliases[0]->alias);
$this->assertSame('useralias2@kolab.org', $aliases[1]->alias);
// Assert the new user entitlements
$this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage']);
// Assert the wallet to which the new user should be assigned to
$wallet = $user->wallet();
$this->assertSame($john->wallets()->first()->id, $wallet->id);
// Test acting as account controller (not owner)
/*
// FIXME: How do we know to which wallet the new user should be assigned to?
$this->deleteTestUser('john2.doe2@kolab.org');
$response = $this->actingAs($ned)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
*/
$this->markTestIncomplete();
}
/**
* Test user update (PUT /api/v4/users/<user-id>)
*/
public function testUpdate(): void
{
$userA = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$domain = $this->getTestDomain(
'userscontroller.com',
['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]
);
// Test unauthorized update of other user profile
$response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []);
$response->assertStatus(403);
// Test authorized update of account owner by account controller
$response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []);
$response->assertStatus(200);
// Test updating of self (empty request)
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertCount(2, $json);
// Test some invalid data
$post = ['password' => '12345678', 'currency' => 'invalid'];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
$this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]);
// Test full profile update including password
$post = [
'password' => 'simple',
'password_confirmation' => 'simple',
'first_name' => 'John2',
'last_name' => 'Doe2',
'phone' => '+123 123 123',
'external_email' => 'external@gmail.com',
'billing_address' => 'billing',
'country' => 'CH',
'currency' => 'CHF',
'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertTrue($userA->password != $userA->fresh()->password);
unset($post['password'], $post['password_confirmation'], $post['aliases']);
foreach ($post as $key => $value) {
$this->assertSame($value, $userA->getSetting($key));
}
$aliases = $userA->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias);
$this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias);
// Test unsetting values
$post = [
'first_name' => '',
'last_name' => '',
'phone' => '',
'external_email' => '',
'billing_address' => '',
'country' => '',
'currency' => '',
'aliases' => ['useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertCount(2, $json);
unset($post['aliases']);
foreach ($post as $key => $value) {
$this->assertNull($userA->getSetting($key));
}
$aliases = $userA->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias);
// Test error on setting an alias to other user's domain
// and missing password confirmation
$post = [
'password' => 'simple123',
'aliases' => ['useralias2@' . \config('app.domain'), 'useralias1@kolab.org']
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertCount(1, $json['errors']['aliases']);
$this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]);
$this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
// Test authorized update of other user
$response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}", []);
$response->assertStatus(200);
// TODO: Test error on aliases with invalid/non-existing/other-user's domain
// Create entitlements and additional user for following tests
$owner = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$package_domain = Package::where('title', 'domain-hosting')->first();
$package_kolab = Package::where('title', 'kolab')->first();
$package_lite = Package::where('title', 'lite')->first();
$sku_mailbox = Sku::where('title', 'mailbox')->first();
$sku_storage = Sku::where('title', 'storage')->first();
$sku_groupware = Sku::where('title', 'groupware')->first();
$domain = $this->getTestDomain(
'userscontroller.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$domain->assignPackage($package_domain, $owner);
$owner->assignPackage($package_kolab);
$owner->assignPackage($package_lite, $user);
// Non-controller cannot update his own entitlements
$post = ['skus' => []];
$response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(422);
// Test updating entitlements
$post = [
'skus' => [
$sku_mailbox->id => 1,
$sku_storage->id => 3,
$sku_groupware->id => 1,
],
];
$response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(200);
$storage_cost = $user->entitlements()
->where('sku_id', $sku_storage->id)
->orderBy('cost')
->pluck('cost')->all();
$this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage']);
$this->assertSame([0, 0, 25], $storage_cost);
}
/**
* Test UsersController::updateEntitlements()
*/
public function testUpdateEntitlements(): void
{
// TODO: Test more cases of entitlements update
$this->markTestIncomplete();
}
/**
* List of alias validation cases for testValidateEmail()
*
* @return array Arguments for testValidateEmail()
*/
public function dataValidateEmail(): array
{
$this->refreshApplication();
$public_domains = Domain::getPublicDomains();
$domain = reset($public_domains);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
return [
// Invalid format
["$domain", $john, true, 'The specified alias is invalid.'],
[".@$domain", $john, true, 'The specified alias is invalid.'],
["test123456@localhost", $john, true, 'The specified domain is invalid.'],
["test123456@unknown-domain.org", $john, true, 'The specified domain is invalid.'],
["$domain", $john, false, 'The specified email is invalid.'],
[".@$domain", $john, false, 'The specified email is invalid.'],
// forbidden local part on public domains
["admin@$domain", $john, true, 'The specified alias is not available.'],
["administrator@$domain", $john, true, 'The specified alias is not available.'],
// forbidden (other user's domain)
["testtest@kolab.org", $user, true, 'The specified domain is not available.'],
// existing alias of other user
["jack.daniels@kolab.org", $john, true, 'The specified alias is not available.'],
// existing user
["jack@kolab.org", $john, true, 'The specified alias is not available.'],
// valid (user domain)
["admin@kolab.org", $john, true, null],
// valid (public domain)
["test.test@$domain", $john, true, null],
];
}
/**
* User email/alias validation.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*
* @dataProvider dataValidateEmail
*/
public function testValidateEmail($alias, $user, $is_alias, $expected_result): void
{
- $result = $this->invokeMethod(new UsersController(), 'validateEmail', [$alias, $user, $is_alias]);
+ $result = $this->invokeMethod(new \App\Utils(), 'validateEmail', [$alias, $user, $is_alias]);
$this->assertSame($expected_result, $result);
}
}
diff --git a/src/webpack.mix.js b/src/webpack.mix.js
index 8a923cbb..257371a0 100644
--- a/src/webpack.mix.js
+++ b/src/webpack.mix.js
@@ -1,15 +1,16 @@
const mix = require('laravel-mix');
/*
|--------------------------------------------------------------------------
| Mix Asset Management
|--------------------------------------------------------------------------
|
| Mix provides a clean, fluent API for defining some Webpack build steps
| for your Laravel application. By default, we are compiling the Sass
| file for the application as well as bundling up all the JS files.
|
*/
-mix.js('resources/js/app.js', 'public/js')
+mix.js('resources/js/user.js', 'public/js')
+ .js('resources/js/admin.js', 'public/js')
.sass('resources/sass/app.scss', 'public/css');
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Thu, Dec 18, 10:36 AM (31 m, 1 s)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
418721
Default Alt Text
(255 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment