Page MenuHomePhorge

No OneTemporary

Size
255 KB
Referenced Files
None
Subscribers
None
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

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)

Event Timeline