Page MenuHomePhorge

No OneTemporary

Size
580 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/bin/quickstart.sh b/bin/quickstart.sh
index 4aa5c848..84ce7c0a 100755
--- a/bin/quickstart.sh
+++ b/bin/quickstart.sh
@@ -1,94 +1,96 @@
#!/bin/bash
set -e
function die() {
echo "$1"
exit 1
}
rpm -qv composer >/dev/null 2>&1 || \
test ! -z "$(which composer 2>/dev/null)" || \
die "Is composer installed?"
rpm -qv docker-compose >/dev/null 2>&1 || \
test ! -z "$(which docker-compose 2>/dev/null)" || \
die "Is docker-compose installed?"
rpm -qv npm >/dev/null 2>&1 || \
test ! -z "$(which npm 2>/dev/null)" || \
die "Is npm installed?"
rpm -qv php >/dev/null 2>&1 || \
test ! -z "$(which php 2>/dev/null)" || \
die "Is php installed?"
rpm -qv php-ldap >/dev/null 2>&1 || \
test ! -z "$(php --ini | grep ldap)" || \
die "Is php-ldap installed?"
rpm -qv php-mysqlnd >/dev/null 2>&1 || \
test ! -z "$(php --ini | grep mysql)" || \
die "Is php-mysqlnd installed?"
test ! -z "$(php --modules | grep swoole)" || \
die "Is swoole installed?"
base_dir=$(dirname $(dirname $0))
docker pull docker.io/kolab/centos7:latest
docker-compose down --remove-orphans
docker-compose build
pushd ${base_dir}/src/
if [ ! -f ".env" ]; then
cp .env.example .env
fi
if [ -f ".env.local" ]; then
# Ensure there's a line ending
echo "" >> .env
cat .env.local >> .env
fi
popd
bin/regen-certs
docker-compose up -d coturn kolab mariadb openvidu kurento-media-server proxy redis
pushd ${base_dir}/src/
rm -rf vendor/ composer.lock
php -dmemory_limit=-1 /bin/composer install
npm install
find bootstrap/cache/ -type f ! -name ".gitignore" -delete
./artisan key:generate
./artisan jwt:secret -f
./artisan clear-compiled
./artisan cache:clear
./artisan horizon:install
if [ ! -z "$(rpm -qv chromium 2>/dev/null)" ]; then
chver=$(rpmquery --queryformat="%{VERSION}" chromium | awk -F'.' '{print $1}')
./artisan dusk:chrome-driver ${chver}
fi
if [ ! -f 'resources/countries.php' ]; then
./artisan data:countries
fi
npm run dev
popd
docker-compose up -d worker
pushd ${base_dir}/src/
rm -rf database/database.sqlite
./artisan db:ping --wait
php -dmemory_limit=512M ./artisan migrate:refresh --seed
+./artisan data:import
+./artisan swoole:http stop >/dev/null 2>&1 || :
./artisan swoole:http start
popd
diff --git a/extras/kolab_policy_greylist.py b/extras/kolab_policy_greylist.py
new file mode 100755
index 00000000..c26186ee
--- /dev/null
+++ b/extras/kolab_policy_greylist.py
@@ -0,0 +1,79 @@
+#!/usr/bin/python3
+"""
+An example implementation of a policy service.
+"""
+
+import json
+import time
+import sys
+
+import requests
+
+
+def read_request_input():
+ """
+ Read a single policy request from sys.stdin, and return a dictionary
+ containing the request.
+ """
+ start_time = time.time()
+
+ policy_request = {}
+ end_of_request = False
+
+ while not end_of_request:
+ if (time.time() - start_time) >= 10:
+ sys.exit(0)
+
+ request_line = sys.stdin.readline()
+
+ if request_line.strip() == '':
+ if 'request' in policy_request:
+ end_of_request = True
+ else:
+ request_line = request_line.strip()
+ request_key = request_line.split('=')[0]
+ request_value = '='.join(request_line.split('=')[1:])
+
+ policy_request[request_key] = request_value
+
+ return policy_request
+
+
+if __name__ == "__main__":
+ URL = 'https://services.kolabnow.com/api/webhooks/policy/greylist'
+
+ # Start the work
+ while True:
+ REQUEST = read_request_input()
+
+ try:
+ RESPONSE = requests.post(
+ URL,
+ data=REQUEST,
+ verify=True
+ )
+ # pylint: disable=broad-except
+ except Exception:
+ print("action=DEFER_IF_PERMIT Temporary error, try again later.")
+ sys.exit(1)
+
+ try:
+ R = json.loads(RESPONSE.text)
+ # pylint: disable=broad-except
+ except Exception:
+ sys.exit(1)
+
+ if 'prepend' in R:
+ for prepend in R['prepend']:
+ print("action=PREPEND {0}".format(prepend))
+
+ if RESPONSE.ok:
+ print("action={0}\n".format(R['response']))
+
+ sys.stdout.flush()
+ else:
+ print("action={0} {1}\n".format(R['response'], R['reason']))
+
+ sys.stdout.flush()
+
+ sys.exit(0)
diff --git a/extras/kolab_policy_ratelimit.py b/extras/kolab_policy_ratelimit.py
new file mode 100755
index 00000000..b459b257
--- /dev/null
+++ b/extras/kolab_policy_ratelimit.py
@@ -0,0 +1,79 @@
+#!/usr/bin/python3
+"""
+This policy applies rate limitations
+"""
+
+import json
+import time
+import sys
+
+import requests
+
+
+def read_request_input():
+ """
+ Read a single policy request from sys.stdin, and return a dictionary
+ containing the request.
+ """
+ start_time = time.time()
+
+ policy_request = {}
+ end_of_request = False
+
+ while not end_of_request:
+ if (time.time() - start_time) >= 10:
+ sys.exit(0)
+
+ request_line = sys.stdin.readline()
+
+ if request_line.strip() == '':
+ if 'request' in policy_request:
+ end_of_request = True
+ else:
+ request_line = request_line.strip()
+ request_key = request_line.split('=')[0]
+ request_value = '='.join(request_line.split('=')[1:])
+
+ policy_request[request_key] = request_value
+
+ return policy_request
+
+
+if __name__ == "__main__":
+ URL = 'https://services.kolabnow.com/api/webhooks/policy/ratelimit'
+
+ # Start the work
+ while True:
+ REQUEST = read_request_input()
+
+ try:
+ RESPONSE = requests.post(
+ URL,
+ data=REQUEST,
+ verify=True
+ )
+ # pylint: disable=broad-except
+ except Exception:
+ print("action=DEFER_IF_PERMIT Temporary error, try again later.")
+ sys.exit(1)
+
+ try:
+ R = json.loads(RESPONSE.text)
+ # pylint: disable=broad-except
+ except Exception:
+ sys.exit(1)
+
+ if 'prepend' in R:
+ for prepend in R['prepend']:
+ print("action=PREPEND {0}".format(prepend))
+
+ if RESPONSE.ok:
+ print("action={0}\n".format(R['response']))
+
+ sys.stdout.flush()
+ else:
+ print("action={0} {1}\n".format(R['response'], R['reason']))
+
+ sys.stdout.flush()
+
+ sys.exit(0)
diff --git a/extras/kolab_policy_spf.py b/extras/kolab_policy_spf.py
new file mode 100755
index 00000000..d98baac8
--- /dev/null
+++ b/extras/kolab_policy_spf.py
@@ -0,0 +1,80 @@
+#!/usr/bin/python3
+"""
+This is the implementation of a (postfix) MTA policy service to enforce the
+Sender Policy Framework.
+"""
+
+import json
+import time
+import sys
+
+import requests
+
+
+def read_request_input():
+ """
+ Read a single policy request from sys.stdin, and return a dictionary
+ containing the request.
+ """
+ start_time = time.time()
+
+ policy_request = {}
+ end_of_request = False
+
+ while not end_of_request:
+ if (time.time() - start_time) >= 10:
+ sys.exit(0)
+
+ request_line = sys.stdin.readline()
+
+ if request_line.strip() == '':
+ if 'request' in policy_request:
+ end_of_request = True
+ else:
+ request_line = request_line.strip()
+ request_key = request_line.split('=')[0]
+ request_value = '='.join(request_line.split('=')[1:])
+
+ policy_request[request_key] = request_value
+
+ return policy_request
+
+
+if __name__ == "__main__":
+ URL = 'https://services.kolabnow.com/api/webhooks/policy/spf'
+
+ # Start the work
+ while True:
+ REQUEST = read_request_input()
+
+ try:
+ RESPONSE = requests.post(
+ URL,
+ data=REQUEST,
+ verify=True
+ )
+ # pylint: disable=broad-except
+ except Exception:
+ print("action=DEFER_IF_PERMIT Temporary error, try again later.")
+ sys.exit(1)
+
+ try:
+ R = json.loads(RESPONSE.text)
+ # pylint: disable=broad-except
+ except Exception:
+ sys.exit(1)
+
+ if 'prepend' in R:
+ for prepend in R['prepend']:
+ print("action=PREPEND {0}".format(prepend))
+
+ if RESPONSE.ok:
+ print("action={0}\n".format(R['response']))
+
+ sys.stdout.flush()
+ else:
+ print("action={0} {1}\n".format(R['response'], R['reason']))
+
+ sys.stdout.flush()
+
+ sys.exit(0)
diff --git a/src/.gitignore b/src/.gitignore
index ad4636b4..c89f4357 100644
--- a/src/.gitignore
+++ b/src/.gitignore
@@ -1,24 +1,26 @@
*.swp
database/database.sqlite
node_modules/
package-lock.json
public/css/*.css
public/hot
public/js/*.js
public/storage/
storage/*.key
+storage/*.log
+storage/*-????-??-??*
storage/export/
tests/report/
vendor
.env
.env.backup
.env.local
.env.testing
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
composer.lock
resources/countries.php
resources/build/js/
diff --git a/src/app/Console/Commands/User/GreylistCommand.php b/src/app/Console/Commands/User/GreylistCommand.php
new file mode 100644
index 00000000..76710b44
--- /dev/null
+++ b/src/app/Console/Commands/User/GreylistCommand.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Console\Commands\User;
+
+use App\Console\Command;
+
+class GreylistCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'user:greylist {user}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'List currently greylisted delivery attempts for the user.';
+
+ /**
+ * Create a new command instance.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ parent::__construct();
+ }
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ // pretend that all users are local;
+ $recipientAddress = $this->argument('user');
+ $recipientHash = hash('sha256', $recipientAddress);
+
+ $lastConnect = \App\Policy\Greylist\Connect::where('recipient_hash', $recipientHash)
+ ->orderBy('updated_at', 'desc')
+ ->first();
+
+ if ($lastConnect) {
+ $timestamp = $lastConnect->updated_at->copy();
+ $this->info("Going from timestamp (last connect) {$timestamp}");
+ } else {
+ $timestamp = \Carbon\Carbon::now();
+ $this->info("Going from timestamp (now) {$timestamp}");
+ }
+
+
+ \App\Policy\Greylist\Connect::where('recipient_hash', $recipientHash)
+ ->where('greylisting', true)
+ ->whereDate('updated_at', '>=', $timestamp->copy()->subDays(7))
+ ->orderBy('created_at')->each(
+ function ($connect) {
+ $this->info(
+ sprintf(
+ "From %s@%s since %s",
+ $connect->sender_local,
+ $connect->sender_domain,
+ $connect->created_at
+ )
+ );
+ }
+ );
+ }
+}
diff --git a/src/app/Domain.php b/src/app/Domain.php
index 5b3ce1c2..b69759f7 100644
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -1,501 +1,515 @@
<?php
namespace App;
use App\Wallet;
+use App\Traits\DomainConfigTrait;
+use App\Traits\SettingsTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* The eloquent definition of a Domain.
*
* @property string $namespace
* @property int $status
* @property int $tenant_id
* @property int $type
*/
class Domain extends Model
{
+ use DomainConfigTrait;
+ use SettingsTrait;
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)
{
// If this domain is public it can not be assigned to a user.
if ($this->isPublic()) {
return $this;
}
// See if this domain is already owned by another user.
$wallet = $this->wallet();
if ($wallet) {
\Log::error(
"Domain {$this->namespace} is already assigned to {$wallet->owner->email}"
);
return $this;
}
$wallet_id = $user->wallets()->first()->id;
foreach ($package->skus as $sku) {
for ($i = $sku->pivot->qty; $i > 0; $i--) {
\App\Entitlement::create(
[
'wallet_id' => $wallet_id,
'sku_id' => $sku->id,
'cost' => $sku->pivot->cost(),
'fee' => $sku->pivot->fee(),
'entitleable_id' => $this->id,
'entitleable_type' => Domain::class
]
);
}
}
return $this;
}
/**
* The domain entitlement.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphOne
*/
public function entitlement()
{
return $this->morphOne('App\Entitlement', 'entitleable');
}
/**
* Return list of public+active domain names (for current tenant)
*/
public static function getPublicDomains(): array
{
return self::withEnvTenantContext()
->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC))
->get(['namespace'])->pluck('namespace')->toArray();
}
/**
* Returns whether this domain is active.
*
* @return bool
*/
public function isActive(): bool
{
return ($this->status & self::STATUS_ACTIVE) > 0;
}
/**
* Returns whether this domain is confirmed the ownership of.
*
* @return bool
*/
public function isConfirmed(): bool
{
return ($this->status & self::STATUS_CONFIRMED) > 0;
}
/**
* Returns whether this domain is deleted.
*
* @return bool
*/
public function isDeleted(): bool
{
return ($this->status & self::STATUS_DELETED) > 0;
}
/**
* Returns whether this domain is registered with us.
*
* @return bool
*/
public function isExternal(): bool
{
return ($this->type & self::TYPE_EXTERNAL) > 0;
}
/**
* Returns whether this domain is hosted with us.
*
* @return bool
*/
public function isHosted(): bool
{
return ($this->type & self::TYPE_HOSTED) > 0;
}
/**
* Returns whether this domain is new.
*
* @return bool
*/
public function isNew(): bool
{
return ($this->status & self::STATUS_NEW) > 0;
}
/**
* Returns whether this domain is public.
*
* @return bool
*/
public function isPublic(): bool
{
return ($this->type & self::TYPE_PUBLIC) > 0;
}
/**
* Returns whether this domain is registered in LDAP.
*
* @return bool
*/
public function isLdapReady(): bool
{
return ($this->status & self::STATUS_LDAP_READY) > 0;
}
/**
* Returns whether this domain is suspended.
*
* @return bool
*/
public function isSuspended(): bool
{
return ($this->status & self::STATUS_SUSPENDED) > 0;
}
/**
* Returns whether this (external) domain has been verified
* to exist in DNS.
*
* @return bool
*/
public function isVerified(): bool
{
return ($this->status & self::STATUS_VERIFIED) > 0;
}
/**
* Ensure the namespace is appropriately cased.
*/
public function setNamespaceAttribute($namespace)
{
$this->attributes['namespace'] = strtolower($namespace);
}
/**
* Domain status mutator
*
* @throws \Exception
*/
public function setStatusAttribute($status)
{
$new_status = 0;
$allowed_values = [
self::STATUS_NEW,
self::STATUS_ACTIVE,
self::STATUS_SUSPENDED,
self::STATUS_DELETED,
self::STATUS_CONFIRMED,
self::STATUS_VERIFIED,
self::STATUS_LDAP_READY,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid domain status: {$status}");
}
if ($this->isPublic()) {
$this->attributes['status'] = $new_status;
return;
}
if ($new_status & self::STATUS_CONFIRMED) {
// if we have confirmed ownership of or management access to the domain, then we have
// also confirmed the domain exists in DNS.
$new_status |= self::STATUS_VERIFIED;
$new_status |= self::STATUS_ACTIVE;
}
if ($new_status & self::STATUS_DELETED && $new_status & self::STATUS_ACTIVE) {
$new_status ^= self::STATUS_ACTIVE;
}
if ($new_status & self::STATUS_SUSPENDED && $new_status & self::STATUS_ACTIVE) {
$new_status ^= self::STATUS_ACTIVE;
}
// if the domain is now active, it is not new anymore.
if ($new_status & self::STATUS_ACTIVE && $new_status & self::STATUS_NEW) {
$new_status ^= self::STATUS_NEW;
}
$this->attributes['status'] = $new_status;
}
/**
* Ownership verification by checking for a TXT (or CNAME) record
* in the domain's DNS (that matches the verification hash).
*
* @return bool True if verification was successful, false otherwise
* @throws \Exception Throws exception on DNS or DB errors
*/
public function confirm(): bool
{
if ($this->isConfirmed()) {
return true;
}
$hash = $this->hash(self::HASH_TEXT);
$confirmed = false;
// Get DNS records and find a matching TXT entry
$records = \dns_get_record($this->namespace, DNS_TXT);
if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
foreach ($records as $record) {
if ($record['txt'] === $hash) {
$confirmed = true;
break;
}
}
// Get DNS records and find a matching CNAME entry
// Note: some servers resolve every non-existing name
// so we need to define left and right side of the CNAME record
// i.e.: kolab-verify IN CNAME <hash>.domain.tld.
if (!$confirmed) {
$cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace;
$records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME);
if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
foreach ($records as $records) {
if ($records['target'] === $cname) {
$confirmed = true;
break;
}
}
}
if ($confirmed) {
$this->status |= Domain::STATUS_CONFIRMED;
$this->save();
}
return $confirmed;
}
/**
* Generate a verification hash for this domain
*
* @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT
*
* @return string Verification hash
*/
public function hash($mod = null): string
{
$cname = 'kolab-verify';
if ($mod === self::HASH_CNAME) {
return $cname;
}
$hash = \md5('hkccp-verify-' . $this->namespace);
return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash;
}
+ /**
+ * Any (additional) properties of this domain.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function settings()
+ {
+ return $this->hasMany('App\DomainSetting', 'domain_id');
+ }
+
/**
* Suspend this domain.
*
* @return void
*/
public function suspend(): void
{
if ($this->isSuspended()) {
return;
}
$this->status |= Domain::STATUS_SUSPENDED;
$this->save();
}
/**
* The tenant for this domain.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function tenant()
{
return $this->belongsTo('App\Tenant', 'tenant_id', 'id');
}
/**
* Unsuspend this domain.
*
* The domain is unsuspended through either of the following courses of actions;
*
* * The account balance has been topped up, or
* * a suspected spammer has resolved their issues, or
* * the command-line is triggered.
*
* Therefore, we can also confidently set the domain status to 'active' should the ownership of or management
* access to have been confirmed before.
*
* @return void
*/
public function unsuspend(): void
{
if (!$this->isSuspended()) {
return;
}
$this->status ^= Domain::STATUS_SUSPENDED;
if ($this->isConfirmed() && $this->isVerified()) {
$this->status |= Domain::STATUS_ACTIVE;
}
$this->save();
}
/**
* List the users of a domain, so long as the domain is not a public registration domain.
*
* @return array
*/
public function users(): array
{
if ($this->isPublic()) {
return [];
}
$wallet = $this->wallet();
if (!$wallet) {
return [];
}
$mailboxSKU = \App\Sku::withObjectTenantContext($this)->where('title', 'mailbox')->first();
if (!$mailboxSKU) {
\Log::error("No mailbox SKU available.");
return [];
}
$entitlements = $wallet->entitlements()
->where('entitleable_type', \App\User::class)
->where('sku_id', $mailboxSKU->id)->get();
$users = [];
foreach ($entitlements as $entitlement) {
$users[] = $entitlement->entitleable;
}
return $users;
}
/**
* Verify if a domain exists in DNS
*
* @return bool True if registered, False otherwise
* @throws \Exception Throws exception on DNS or DB errors
*/
public function verify(): bool
{
if ($this->isVerified()) {
return true;
}
$records = \dns_get_record($this->namespace, DNS_ANY);
if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
// It may happen that result contains other domains depending on the host DNS setup
// that's why in_array() and not just !empty()
if (in_array($this->namespace, array_column($records, 'host'))) {
$this->status |= Domain::STATUS_VERIFIED;
$this->save();
return true;
}
return false;
}
/**
* Returns the wallet by which the domain is controlled
*
* @return \App\Wallet A wallet object
*/
public function wallet(): ?Wallet
{
// Note: Not all domains have a entitlement/wallet
$entitlement = $this->entitlement()->withTrashed()->orderBy('created_at', 'desc')->first();
return $entitlement ? $entitlement->wallet : null;
}
}
diff --git a/src/app/DomainSetting.php b/src/app/DomainSetting.php
new file mode 100644
index 00000000..0cc55b2b
--- /dev/null
+++ b/src/app/DomainSetting.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of settings for a Domain.
+ *
+ * @property int $id
+ * @property int $domain_id
+ * @property string $key
+ * @property string $value
+ */
+class DomainSetting extends Model
+{
+ protected $fillable = [
+ 'domain_id', 'key', 'value'
+ ];
+
+ /**
+ * The domain to which this setting belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function domain()
+ {
+ return $this->belongsTo(
+ '\App\Domain',
+ 'domain_id', /* local */
+ 'id' /* remote */
+ );
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
index a07dc280..0caefacf 100644
--- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
@@ -1,306 +1,308 @@
<?php
namespace App\Http\Controllers\API\V4\Admin;
use App\Domain;
use App\Group;
use App\Sku;
use App\User;
use App\UserAlias;
use App\UserSetting;
use App\Wallet;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class UsersController extends \App\Http\Controllers\API\V4\UsersController
{
/**
* Delete a user.
*
* @param int $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function destroy($id)
{
return $this->errorResponse(404);
}
/**
* Searching of user accounts.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$search = trim(request()->input('search'));
$owner = trim(request()->input('owner'));
$result = collect([]);
if ($owner) {
$owner = User::find($owner);
if ($owner) {
$result = $owner->users(false)->orderBy('email')->get();
}
} elseif (strpos($search, '@')) {
// Search by email
$result = User::withTrashed()->where('email', $search)
->orderBy('email')
->get();
if ($result->isEmpty()) {
// Search by an alias
$user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id');
// Search by an external email
$ext_user_ids = UserSetting::where('key', 'external_email')
->where('value', $search)
->get()
->pluck('user_id');
$user_ids = $user_ids->merge($ext_user_ids)->unique();
// Search by a distribution list email
if ($group = Group::withTrashed()->where('email', $search)->first()) {
$user_ids = $user_ids->merge([$group->wallet()->user_id])->unique();
}
if (!$user_ids->isEmpty()) {
$result = User::withTrashed()->whereIn('id', $user_ids)
->orderBy('email')
->get();
}
}
} elseif (is_numeric($search)) {
// Search by user ID
$user = User::withTrashed()->where('id', $search)
->first();
if ($user) {
$result->push($user);
}
} elseif (strpos($search, '.') !== false) {
// Search by domain
$domain = Domain::withTrashed()->where('namespace', $search)
->first();
if ($domain) {
if (($wallet = $domain->wallet()) && ($owner = $wallet->owner()->withTrashed()->first())) {
$result->push($owner);
}
}
} elseif (!empty($search)) {
$wallet = Wallet::find($search);
if ($wallet) {
if ($owner = $wallet->owner()->withTrashed()->first()) {
$result->push($owner);
}
}
}
// Process the result
$result = $result->map(
function ($user) {
$data = $user->toArray();
$data = array_merge($data, self::userStatuses($user));
return $data;
}
);
$result = [
'list' => $result,
'count' => count($result),
'message' => \trans('app.search-foundxusers', ['x' => count($result)]),
];
return response()->json($result);
}
/**
* Reset 2-Factor Authentication for the user
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function reset2FA(Request $request, $id)
{
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(403);
}
$sku = Sku::withObjectTenantContext($user)->where('title', '2fa')->first();
// Note: we do select first, so the observer can delete
// 2FA preferences from Roundcube database, so don't
// be tempted to replace first() with delete() below
$entitlement = $user->entitlements()->where('sku_id', $sku->id)->first();
$entitlement->delete();
return response()->json([
'status' => 'success',
'message' => __('app.user-reset-2fa-success'),
]);
}
/**
* Display information on the user account specified by $id.
*
* @param int $id The account to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($user)) {
return $this->errorResponse(403);
}
$response = $this->userResponse($user);
// Simplified Entitlement/SKU information,
// TODO: I agree this format may need to be extended in future
$response['skus'] = [];
foreach ($user->entitlements as $ent) {
$sku = $ent->sku;
if (!isset($response['skus'][$sku->id])) {
$response['skus'][$sku->id] = ['costs' => [], 'count' => 0];
}
$response['skus'][$sku->id]['count']++;
$response['skus'][$sku->id]['costs'][] = $ent->cost;
}
+ $response['config'] = $user->getConfig();
+
return response()->json($response);
}
/**
* Create a new user record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
return $this->errorResponse(404);
}
/**
* Suspend the user
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function suspend(Request $request, $id)
{
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(403);
}
$user->suspend();
return response()->json([
'status' => 'success',
'message' => __('app.user-suspend-success'),
]);
}
/**
* Un-Suspend the user
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function unsuspend(Request $request, $id)
{
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(403);
}
$user->unsuspend();
return response()->json([
'status' => 'success',
'message' => __('app.user-unsuspend-success'),
]);
}
/**
* Update user data.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(403);
}
// For now admins can change only user external email address
$rules = [];
if (array_key_exists('external_email', $request->input())) {
$rules['external_email'] = 'email';
}
// Validate input
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
// Update user settings
$settings = $request->only(array_keys($rules));
if (!empty($settings)) {
$user->setSettings($settings);
}
return response()->json([
'status' => 'success',
'message' => __('app.user-update-success'),
]);
}
}
diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php
index a0464a3e..790e8033 100644
--- a/src/app/Http/Controllers/API/V4/DomainsController.php
+++ b/src/app/Http/Controllers/API/V4/DomainsController.php
@@ -1,389 +1,425 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Domain;
use App\Http\Controllers\Controller;
use App\Backends\LDAP;
use Carbon\Carbon;
use Illuminate\Http\Request;
class DomainsController extends Controller
{
/**
* Return a list of domains owned by the current user
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->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()
{
return $this->errorResponse(404);
}
/**
* Confirm ownership of the specified domain (via DNS check).
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function confirm($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
if (!$domain->confirm()) {
return response()->json([
'status' => 'error',
'message' => \trans('app.domain-verify-error'),
]);
}
return response()->json([
'status' => 'success',
'statusInfo' => self::statusInfo($domain),
'message' => \trans('app.domain-verify-success'),
]);
}
/**
* Remove the specified resource from storage.
*
* @param int $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
{
return $this->errorResponse(404);
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function edit($id)
{
return $this->errorResponse(404);
}
+ /**
+ * Set the domain configuration.
+ *
+ * @param int $id Domain identifier
+ *
+ * @return \Illuminate\Http\JsonResponse|void
+ */
+ public function setConfig($id)
+ {
+ $domain = Domain::find($id);
+
+ if (empty($domain)) {
+ return $this->errorResponse(404);
+ }
+
+ // Only owner (or admin) has access to the domain
+ if (!$this->guard()->user()->canRead($domain)) {
+ return $this->errorResponse(403);
+ }
+
+ $errors = $domain->setConfig(request()->input());
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.domain-setconfig-success'),
+ ]);
+ }
+
+
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\JsonResponse
*/
public function store(Request $request)
{
return $this->errorResponse(404);
}
/**
* Get the information about the specified domain.
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function show($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
$response = $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);
+ $response['mx'] = self::getMXConfig($domain->namespace);
+
+ // Domain configuration, e.g. spf whitelist
+ $response['config'] = $domain->getConfig();
// Status info
$response['statusInfo'] = self::statusInfo($domain);
$response = array_merge($response, self::domainStatuses($domain));
return response()->json($response);
}
/**
* Fetch domain status (and reload setup process)
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function status($id)
{
$domain = Domain::withEnvTenant()->findOrFail($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
$response = self::statusInfo($domain);
if (!empty(request()->input('refresh'))) {
$updated = false;
$last_step = 'none';
foreach ($response['process'] as $idx => $step) {
$last_step = $step['label'];
if (!$step['state']) {
if (!$this->execProcessStep($domain, $step['label'])) {
break;
}
$updated = true;
}
}
if ($updated) {
$response = self::statusInfo($domain);
}
$success = $response['isReady'];
$suffix = $success ? 'success' : 'error-' . $last_step;
$response['status'] = $success ? 'success' : 'error';
$response['message'] = \trans('app.process-' . $suffix);
}
$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\JsonResponse
*/
public function update(Request $request, $id)
{
return $this->errorResponse(404);
}
/**
* Provide DNS MX information to configure specified domain for
*/
protected static function getMXConfig(string $namespace): array
{
$entries = [];
// copy MX entries from an existing domain
if ($master = \config('dns.copyfrom')) {
// TODO: cache this lookup
foreach ((array) dns_get_record($master, DNS_MX) as $entry) {
$entries[] = sprintf(
"@\t%s\t%s\tMX\t%d %s.",
\config('dns.ttl', $entry['ttl']),
$entry['class'],
$entry['pri'],
$entry['target']
);
}
} elseif ($static = \config('dns.static')) {
$entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace));
}
// display SPF settings
if ($spf = \config('dns.spf')) {
$entries[] = ';';
foreach (['TXT', 'SPF'] as $type) {
$entries[] = sprintf(
"@\t%s\tIN\t%s\t\"%s\"",
\config('dns.ttl'),
$type,
$spf
);
}
}
return $entries;
}
/**
* Provide sample DNS config for domain confirmation
*/
protected static function getDNSConfig(Domain $domain): array
{
$serial = date('Ymd01');
$hash_txt = $domain->hash(Domain::HASH_TEXT);
$hash_cname = $domain->hash(Domain::HASH_CNAME);
$hash = $domain->hash(Domain::HASH_CODE);
return [
"@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (",
" {$serial} 10800 3600 604800 86400 )",
";",
"@ IN A <some-ip>",
"www IN A <some-ip>",
";",
"{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.",
"@ 3600 TXT \"{$hash_txt}\"",
];
}
/**
* Prepare domain statuses for the UI
*
* @param \App\Domain $domain Domain object
*
* @return array Statuses array
*/
protected static function 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--;
}
}
$state = $count === 0 ? 'done' : 'running';
// After 180 seconds assume the process is in failed state,
// this should unlock the Refresh button in the UI
if ($count > 0 && $domain->created_at->diffInSeconds(Carbon::now()) > 180) {
$state = 'failed';
}
return [
'process' => $process,
'processState' => $state,
'isReady' => $count === 0,
];
}
/**
* Execute (synchronously) specified step in a domain setup process.
*
* @param \App\Domain $domain Domain object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool True if the execution succeeded, False otherwise
*/
public static function execProcessStep(Domain $domain, string $step): bool
{
try {
switch ($step) {
case 'domain-ldap-ready':
// Domain not in LDAP, create it
if (!$domain->isLdapReady()) {
LDAP::createDomain($domain);
$domain->status |= Domain::STATUS_LDAP_READY;
$domain->save();
}
return $domain->isLdapReady();
case 'domain-verified':
// Domain existence not verified
$domain->verify();
return $domain->isVerified();
case 'domain-confirmed':
// Domain ownership confirmation
$domain->confirm();
return $domain->isConfirmed();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
}
diff --git a/src/app/Http/Controllers/API/V4/PolicyController.php b/src/app/Http/Controllers/API/V4/PolicyController.php
new file mode 100644
index 00000000..a2f77545
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/PolicyController.php
@@ -0,0 +1,230 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use App\Providers\PaymentProvider;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Validator;
+
+class PolicyController extends Controller
+{
+ /**
+ * Take a greylist policy request
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function greylist()
+ {
+ $data = \request()->input();
+
+ list($local, $domainName) = explode('@', $data['recipient']);
+
+ $request = new \App\Policy\Greylist\Request($data);
+
+ $shouldDefer = $request->shouldDefer();
+
+ if ($shouldDefer) {
+ return response()->json(
+ ['response' => 'DEFER_IF_PERMIT', 'reason' => "Greylisted for 5 minutes. Try again later."],
+ 403
+ );
+ }
+
+ $prependGreylist = $request->headerGreylist();
+
+ $result = [
+ 'response' => 'DUNNO',
+ 'prepend' => [$prependGreylist]
+ ];
+
+ return response()->json($result, 200);
+ }
+
+ /*
+ * Apply a sensible rate limitation to a request.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function ratelimit()
+ {
+ /*
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-pass.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+ */
+/*
+ $data = \request()->input();
+
+ // TODO: normalize sender address
+ $sender = strtolower($data['sender']);
+
+ $alias = \App\UserAlias::where('alias', $sender)->first();
+
+ if (!$alias) {
+ $user = \App\User::where('email', $sender)->first();
+
+ if (!$user) {
+ // what's the situation here?
+ }
+ } else {
+ $user = $alias->user;
+ }
+
+ // TODO time-limit
+ $userRates = \App\Policy\Ratelimit::where('user_id', $user->id);
+
+ // TODO message vs. recipient limit
+ if ($userRates->count() > 10) {
+ // TODO
+ }
+
+ // this is the wallet to which the account is billed
+ $wallet = $user->wallet;
+
+ // TODO: consider $wallet->payments;
+
+ $owner = $wallet->user;
+
+ // TODO time-limit
+ $ownerRates = \App\Policy\Ratelimit::where('owner_id', $owner->id);
+
+ // TODO message vs. recipient limit (w/ user counts)
+ if ($ownerRates->count() > 10) {
+ // TODO
+ }
+*/
+ }
+
+ /*
+ * Apply the sender policy framework to a request.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function senderPolicyFramework()
+ {
+ $data = \request()->input();
+
+ list($netID, $netType) = \App\Utils::getNetFromAddress($data['client_address']);
+ list($senderLocal, $senderDomain) = explode('@', $data['sender']);
+
+ // This network can not be recognized.
+ if (!$netID) {
+ return response()->json(
+ [
+ 'response' => 'DEFER_IF_PERMIT',
+ 'reason' => 'Temporary error. Please try again later.'
+ ],
+ 403
+ );
+ }
+
+ // Compose the cache key we want.
+ $cacheKey = "{$netType}_{$netID}_{$senderDomain}";
+
+ $result = \App\Policy\SPF\Cache::get($cacheKey);
+
+ if (!$result) {
+ $environment = new \SPFLib\Check\Environment(
+ $data['client_address'],
+ $data['client_name'],
+ $data['sender']
+ );
+
+ $result = (new \SPFLib\Checker())->check($environment);
+
+ \App\Policy\SPF\Cache::set($cacheKey, serialize($result));
+ } else {
+ $result = unserialize($result);
+ }
+
+ $fail = false;
+ $prependSPF = '';
+
+ switch ($result->getCode()) {
+ case \SPFLib\Check\Result::CODE_ERROR_PERMANENT:
+ $fail = true;
+ $prependSPF = "Received-SPF: Permerror";
+ break;
+
+ case \SPFLib\Check\Result::CODE_ERROR_TEMPORARY:
+ $prependSPF = "Received-SPF: Temperror";
+ break;
+
+ case \SPFLib\Check\Result::CODE_FAIL:
+ $fail = true;
+ $prependSPF = "Received-SPF: Fail";
+ break;
+
+ case \SPFLib\Check\Result::CODE_SOFTFAIL:
+ $prependSPF = "Received-SPF: Softfail";
+ break;
+
+ case \SPFLib\Check\Result::CODE_NEUTRAL:
+ $prependSPF = "Received-SPF: Neutral";
+ break;
+
+ case \SPFLib\Check\Result::CODE_PASS:
+ $prependSPF = "Received-SPF: Pass";
+ break;
+
+ case \SPFLib\Check\Result::CODE_NONE:
+ $prependSPF = "Received-SPF: None";
+ break;
+ }
+
+ $prependSPF .= " identity=mailfrom;";
+ $prependSPF .= " client-ip={$data['client_address']};";
+ $prependSPF .= " helo={$data['client_name']};";
+ $prependSPF .= " envelope-from={$data['sender']};";
+
+ if ($fail) {
+ // TODO: check the recipient's policy, such as using barracuda for anti-spam and anti-virus as a relay for
+ // inbound mail to a local recipient address.
+ $objects = \App\Utils::findObjectsByRecipientAddress($data['recipient']);
+
+ if (!empty($objects)) {
+ // check if any of the recipient objects have whitelisted the helo, first one wins.
+ foreach ($objects as $object) {
+ if (method_exists($object, 'senderPolicyFrameworkWhitelist')) {
+ $result = $object->senderPolicyFrameworkWhitelist($data['client_name']);
+
+ if ($result) {
+ $response = [
+ 'response' => 'DUNNO',
+ 'prepend' => ["Received-SPF: Pass Check skipped at recipient's discretion"],
+ 'reason' => 'HELO name whitelisted'
+ ];
+
+ return response()->json($response, 200);
+ }
+ }
+ }
+ }
+
+ $result = [
+ 'response' => 'REJECT',
+ 'prepend' => [$prependSPF],
+ 'reason' => "Prohibited by Sender Policy Framework"
+ ];
+
+ return response()->json($result, 403);
+ }
+
+ $result = [
+ 'response' => 'DUNNO',
+ 'prepend' => [$prependSPF],
+ 'reason' => "Don't know"
+ ];
+
+ return response()->json($result, 200);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
index c8c0309a..95b64a90 100644
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -1,813 +1,846 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Domain;
use App\Group;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\Sku;
use App\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UsersController extends Controller
{
/** @const array List of user setting keys available for modification in UI */
public const USER_SETTINGS = [
'billing_address',
'country',
'currency',
'external_email',
'first_name',
'last_name',
'organization',
'phone',
];
/**
* On user create it is filled with a user or group object to force-delete
* before the creation of a new user record is possible.
*
* @var \App\User|\App\Group|null
*/
protected $deleteBeforeCreate;
/**
* Delete a user.
*
* @param int $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function destroy($id)
{
$user = User::withEnvTenantContext()->find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
// User can't remove himself until he's the controller
if (!$this->guard()->user()->canDelete($user)) {
return $this->errorResponse(403);
}
$user->delete();
return response()->json([
'status' => 'success',
'message' => __('app.user-delete-success'),
]);
}
/**
* Listing of users.
*
* The user-entitlements billed to the current user wallet(s)
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->guard()->user();
$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);
}
+ /**
+ * Set user config.
+ *
+ * @param int $id The user
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function setConfig($id)
+ {
+ $user = User::find($id);
+
+ if (empty($user)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canRead($user)) {
+ return $this->errorResponse(403);
+ }
+
+ $errors = $user->setConfig(request()->input());
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => __('app.user-setconfig-success'),
+ ]);
+ }
+
/**
* Display information on the user account specified by $id.
*
* @param int $id The account to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$user = User::withEnvTenantContext()->find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($user)) {
return $this->errorResponse(403);
}
$response = $this->userResponse($user);
// Simplified Entitlement/SKU information,
// TODO: I agree this format may need to be extended in future
$response['skus'] = [];
foreach ($user->entitlements as $ent) {
$sku = $ent->sku;
if (!isset($response['skus'][$sku->id])) {
$response['skus'][$sku->id] = ['costs' => [], 'count' => 0];
}
$response['skus'][$sku->id]['count']++;
$response['skus'][$sku->id]['costs'][] = $ent->cost;
}
+ $response['config'] = $user->getConfig();
+
return response()->json($response);
}
/**
* Fetch user status (and reload setup process)
*
* @param int $id User identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function status($id)
{
$user = User::withEnvTenantContext()->find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($user)) {
return $this->errorResponse(403);
}
$response = self::statusInfo($user);
if (!empty(request()->input('refresh'))) {
$updated = false;
$async = false;
$last_step = 'none';
foreach ($response['process'] as $idx => $step) {
$last_step = $step['label'];
if (!$step['state']) {
$exec = $this->execProcessStep($user, $step['label']);
if (!$exec) {
if ($exec === null) {
$async = true;
}
break;
}
$updated = true;
}
}
if ($updated) {
$response = self::statusInfo($user);
}
$success = $response['isReady'];
$suffix = $success ? 'success' : 'error-' . $last_step;
$response['status'] = $success ? 'success' : 'error';
$response['message'] = \trans('app.process-' . $suffix);
if ($async && !$success) {
$response['processState'] = 'waiting';
$response['status'] = 'success';
$response['message'] = \trans('app.process-async');
}
}
$response = array_merge($response, self::userStatuses($user));
return response()->json($response);
}
/**
* User status (extended) information
*
* @param \App\User $user User object
*
* @return array Status information
*/
public static function statusInfo(User $user): array
{
$process = [];
$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'];
}));
$state = $all === $checked ? 'done' : 'running';
// After 180 seconds assume the process is in failed state,
// this should unlock the Refresh button in the UI
if ($all !== $checked && $user->created_at->diffInSeconds(Carbon::now()) > 180) {
$state = 'failed';
}
// Check if the user is a controller of his wallet
$isController = $user->canDelete($user);
$hasCustomDomain = $user->wallet()->entitlements()
->where('entitleable_type', Domain::class)
->count() > 0;
// Get user's entitlements titles
$skus = $user->entitlements()->select('skus.title')
->join('skus', 'skus.id', '=', 'entitlements.sku_id')
->get()
->pluck('title')
->sort()
->unique()
->values()
->all();
return [
'skus' => $skus,
// TODO: This will change when we enable all users to create domains
'enableDomains' => $isController && $hasCustomDomain,
// TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners
'enableDistlists' => $isController && $hasCustomDomain && in_array('distlist', $skus),
'enableUsers' => $isController,
'enableWallets' => $isController,
'process' => $process,
'processState' => $state,
'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);
}
$this->deleteBeforeCreate = null;
if ($error_response = $this->validateUserRequest($request, null, $settings)) {
return $error_response;
}
if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) {
$errors = ['package' => \trans('validation.packagerequired')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if ($package->isDomain()) {
$errors = ['package' => \trans('validation.packageinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// @phpstan-ignore-next-line
if ($this->deleteBeforeCreate) {
$this->deleteBeforeCreate->forceDelete();
}
// Create user record
$user = User::create([
'email' => $request->email,
'password' => $request->password,
]);
$owner->assignPackage($package, $user);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->aliases)) {
$user->setAliases($request->aliases);
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => __('app.user-create-success'),
]);
}
/**
* Update user data.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$user = User::withEnvTenantContext()->find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
// TODO: Decide what attributes a user can change on his own profile
if (!$current_user->canUpdate($user)) {
return $this->errorResponse(403);
}
if ($error_response = $this->validateUserRequest($request, $user, $settings)) {
return $error_response;
}
// Entitlements, only controller can do that
if ($request->skus !== null && !$current_user->canDelete($user)) {
return $this->errorResponse(422, "You have no permission to change entitlements");
}
DB::beginTransaction();
$this->updateEntitlements($user, $request->skus);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->password)) {
$user->password = $request->password;
$user->save();
}
if (isset($request->aliases)) {
$user->setAliases($request->aliases);
}
// TODO: Make sure that UserUpdate job is created in case of entitlements update
// and no password change. So, for example quota change is applied to LDAP
// TODO: Review use of $user->save() in the above context
DB::commit();
$response = [
'status' => 'success',
'message' => __('app.user-update-success'),
];
// For self-update refresh the statusInfo in the UI
if ($user->id == $current_user->id) {
$response['statusInfo'] = self::statusInfo($user);
}
return response()->json($response);
}
/**
* Update user entitlements.
*
* @param \App\User $user The user
* @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty]
*/
protected function updateEntitlements(User $user, $rSkus)
{
if (!is_array($rSkus)) {
return;
}
// list of skus, [id=>obj]
$skus = Sku::withEnvTenantContext()->get()->mapWithKeys(
function ($sku) {
return [$sku->id => $sku];
}
);
// existing entitlement's SKUs
$eSkus = [];
$user->entitlements()->groupBy('sku_id')
->selectRaw('count(*) as total, sku_id')->each(
function ($e) use (&$eSkus) {
$eSkus[$e->sku_id] = $e->total;
}
);
foreach ($skus as $skuID => $sku) {
$e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0;
$r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0;
if ($sku->handler_class == \App\Handlers\Mailbox::class) {
if ($r != 1) {
throw new \Exception("Invalid quantity of mailboxes");
}
}
if ($e > $r) {
// remove those entitled more than existing
$user->removeSku($sku, ($e - $r));
} elseif ($e < $r) {
// add those requested more than entitled
$user->assignSku($sku, ($r - $e));
}
}
}
/**
* Create a response data array for specified user.
*
* @param \App\User $user User object
*
* @return array Response data
*/
public static function userResponse(User $user): array
{
$response = $user->toArray();
// Settings
$response['settings'] = [];
foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) {
$response['settings'][$item->key] = $item->value;
}
// Aliases
$response['aliases'] = [];
foreach ($user->aliases as $item) {
$response['aliases'][] = $item->alias;
}
// Status info
$response['statusInfo'] = self::statusInfo($user);
$response = array_merge($response, self::userStatuses($user));
// Add more info to the wallet object output
$map_func = function ($wallet) use ($user) {
$result = $wallet->toArray();
if ($wallet->discount) {
$result['discount'] = $wallet->discount->discount;
$result['discount_description'] = $wallet->discount->description;
}
if ($wallet->user_id != $user->id) {
$result['user_email'] = $wallet->owner->email;
}
$provider = \App\Providers\PaymentProvider::factory($wallet);
$result['provider'] = $provider->name();
return $result;
};
// Information about wallets and accounts for access checks
$response['wallets'] = $user->wallets->map($map_func)->toArray();
$response['accounts'] = $user->accounts->map($map_func)->toArray();
$response['wallet'] = $map_func($user->wallet());
return $response;
}
/**
* Prepare user statuses for the UI
*
* @param \App\User $user User object
*
* @return array Statuses array
*/
protected static function 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|null The error response on error
*/
protected function validateUserRequest(Request $request, $user, &$settings = [])
{
$rules = [
'external_email' => 'nullable|email',
'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/',
'first_name' => 'string|nullable|max:128',
'last_name' => 'string|nullable|max:128',
'organization' => 'string|nullable|max:512',
'billing_address' => 'string|nullable|max:1024',
'country' => 'string|nullable|alpha|size:2',
'currency' => 'string|nullable|alpha|size:3',
'aliases' => 'array|nullable',
];
if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) {
$rules['password'] = 'required|min:4|max:2048|confirmed';
}
$errors = [];
// Validate input
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = $v->errors()->toArray();
}
$controller = $user ? $user->wallet()->owner : $this->guard()->user();
// For new user validate email address
if (empty($user)) {
$email = $request->email;
if (empty($email)) {
$errors['email'] = \trans('validation.required', ['attribute' => 'email']);
} elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) {
$errors['email'] = $error;
}
}
// Validate aliases input
if (isset($request->aliases)) {
$aliases = [];
$existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : [];
foreach ($request->aliases as $idx => $alias) {
if (is_string($alias) && !empty($alias)) {
// Alias cannot be the same as the email address (new user)
if (!empty($email) && Str::lower($alias) == Str::lower($email)) {
continue;
}
// validate new aliases
if (
!in_array($alias, $existing_aliases)
&& ($error = self::validateAlias($alias, $controller))
) {
if (!isset($errors['aliases'])) {
$errors['aliases'] = [];
}
$errors['aliases'][$idx] = $error;
continue;
}
$aliases[] = $alias;
}
}
$request->aliases = $aliases;
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// Update user settings
$settings = $request->only(array_keys($rules));
unset($settings['password'], $settings['aliases'], $settings['email']);
return null;
}
/**
* Execute (synchronously) specified step in a user setup process.
*
* @param \App\User $user User object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool|null True if the execution succeeded, False if not, Null when
* the job has been sent to the worker (result unknown)
*/
public static function execProcessStep(User $user, string $step): ?bool
{
try {
if (strpos($step, 'domain-') === 0) {
list ($local, $domain) = explode('@', $user->email);
$domain = Domain::where('namespace', $domain)->first();
return DomainsController::execProcessStep($domain, $step);
}
switch ($step) {
case 'user-ldap-ready':
// User not in LDAP, create it
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
$user->refresh();
return $user->isLdapReady();
case 'user-imap-ready':
// User not in IMAP? Verify again
// Do it synchronously if the imap admin credentials are available
// otherwise let the worker do the job
if (!\config('imap.admin_password')) {
\App\Jobs\User\VerifyJob::dispatch($user->id);
return null;
}
$job = new \App\Jobs\User\VerifyJob($user->id);
$job->handle();
$user->refresh();
return $user->isImapReady();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
/**
* Email address validation for use as a user mailbox (login).
*
* @param string $email Email address
* @param \App\User $user The account owner
* @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group
* with the specified email address, if exists
*
* @return ?string Error message on validation error
*/
public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string
{
$deleted = null;
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
// Check if domain exists
$domain = Domain::withEnvTenantContext()->where('namespace', $domain)->first();
if (empty($domain)) {
return \trans('validation.domaininvalid');
}
// Validate login part alone
$v = Validator::make(
['email' => $login],
['email' => ['required', new UserEmailLocal(!$domain->isPublic())]]
);
if ($v->fails()) {
return $v->errors()->toArray()['email'][0];
}
// Check if it is one of domains available to the user
$domains = \collect($user->domains())->pluck('namespace')->all();
if (!in_array($domain->namespace, $domains)) {
return \trans('validation.entryexists', ['attribute' => 'domain']);
}
// Check if a user with specified address already exists
if ($existing_user = User::emailExists($email, true)) {
// If this is a deleted user in the same custom domain
// we'll force delete him before
if (!$domain->isPublic() && $existing_user->trashed()) {
$deleted = $existing_user;
} else {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
}
// Check if an alias with specified address already exists.
if (User::aliasExists($email)) {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
// Check if a group with specified address already exists
if ($existing_group = Group::emailExists($email, true)) {
// If this is a deleted group in the same custom domain
// we'll force delete it before
if (!$domain->isPublic() && $existing_group->trashed()) {
$deleted = $existing_group;
} else {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
}
return null;
}
/**
* Email address validation for use as an alias.
*
* @param string $email Email address
* @param \App\User $user The account owner
*
* @return ?string Error message on validation error
*/
public static function validateAlias(string $email, \App\User $user): ?string
{
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'alias']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'alias']);
}
// Check if domain exists
$domain = Domain::withEnvTenantContext()->where('namespace', $domain)->first();
if (empty($domain)) {
return \trans('validation.domaininvalid');
}
// Validate login part alone
$v = Validator::make(
['alias' => $login],
['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]]
);
if ($v->fails()) {
return $v->errors()->toArray()['alias'][0];
}
// Check if it is one of domains available to the user
$domains = \collect($user->domains())->pluck('namespace')->all();
if (!in_array($domain->namespace, $domains)) {
return \trans('validation.entryexists', ['attribute' => 'domain']);
}
// Check if a user with specified address already exists
if ($existing_user = User::emailExists($email, true)) {
// Allow an alias in a custom domain to an address that was a user before
if ($domain->isPublic() || !$existing_user->trashed()) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
}
// Check if an alias with specified address already exists
if (User::aliasExists($email)) {
// Allow assigning the same alias to a user in the same group account,
// but only for non-public domains
if ($domain->isPublic()) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
}
// Check if a group with specified address already exists
if (Group::emailExists($email)) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
return null;
}
}
diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php
index 9d4a5085..f49d8a29 100644
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -1,103 +1,103 @@
<?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,
\App\Http\Middleware\DevelConfig::class,
\App\Http\Middleware\Locale::class,
// FIXME: CORS handling added here, I didn't find a nice way
// to add this only to the API routes
// \App\Http\Middleware\Cors::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:120,1',
+ //'throttle:120,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,
'reseller' => \App\Http\Middleware\AuthenticateReseller::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\AuthenticateReseller::class,
\App\Http\Middleware\Authenticate::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\Illuminate\Auth\Middleware\Authorize::class,
];
/**
* Handle an incoming HTTP request.
*
* @param \Illuminate\Http\Request $request HTTP Request object
*
* @return \Illuminate\Http\Response
*/
public function handle($request)
{
// Overwrite the http request object
return parent::handle(Request::createFrom($request));
}
}
diff --git a/src/app/IP4Net.php b/src/app/IP4Net.php
index 1e57ba77..c55d8e97 100644
--- a/src/app/IP4Net.php
+++ b/src/app/IP4Net.php
@@ -1,19 +1,40 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class IP4Net extends Model
{
protected $table = "ip4nets";
protected $fillable = [
+ 'rir_name',
'net_number',
'net_mask',
'net_broadcast',
'country',
- 'serial'
+ 'serial',
+ 'created_at',
+ 'updated_at'
];
+
+ public static function getNet($ip, $mask = 32)
+ {
+ $query = "
+ SELECT id FROM ip4nets
+ WHERE INET_ATON(net_number) <= INET_ATON(?)
+ AND INET_ATON(net_broadcast) >= INET_ATON(?)
+ ORDER BY INET_ATON(net_number), net_mask DESC LIMIT 1
+ ";
+
+ $results = DB::select($query, [$ip, $ip]);
+
+ if (sizeof($results) == 0) {
+ return null;
+ }
+
+ return \App\IP4Net::find($results[0]->id);
+ }
}
diff --git a/src/app/Observers/SignupCodeObserver.php b/src/app/Observers/SignupCodeObserver.php
index 7f987cf2..6ae0b6d6 100644
--- a/src/app/Observers/SignupCodeObserver.php
+++ b/src/app/Observers/SignupCodeObserver.php
@@ -1,76 +1,77 @@
<?php
namespace App\Observers;
use App\SignupCode;
use Carbon\Carbon;
use Illuminate\Support\Str;
class SignupCodeObserver
{
/**
* Handle the "creating" event.
*
* Ensure that the code entry is created with a random code/short_code.
*
* @param \App\SignupCode $code The code being created.
*
* @return void
*/
public function creating(SignupCode $code): void
{
$code_length = SignupCode::CODE_LENGTH;
$exp_hours = env('SIGNUP_CODE_EXPIRY', SignupCode::CODE_EXP_HOURS);
if (empty($code->code)) {
$code->short_code = SignupCode::generateShortCode();
// FIXME: Replace this with something race-condition free
while (true) {
$code->code = Str::random($code_length);
if (!SignupCode::find($code->code)) {
break;
}
}
}
$code->headers = collect(request()->headers->all())
->filter(function ($value, $key) {
// remove some headers we don't care about
return !in_array($key, ['cookie', 'referer', 'x-test-payment-provider', 'origin']);
})
->map(function ($value) {
return is_array($value) && count($value) == 1 ? $value[0] : $value;
- });
+ })
+ ->all();
$code->expires_at = Carbon::now()->addHours($exp_hours);
$code->ip_address = request()->ip();
if ($code->email) {
$parts = explode('@', $code->email);
$code->local_part = $parts[0];
$code->domain_part = $parts[1];
}
}
/**
* Handle the "updating" event.
*
* @param SignupCode $code The code being updated.
*
* @return void
*/
public function updating(SignupCode $code)
{
if ($code->email) {
$parts = explode('@', $code->email);
$code->local_part = $parts[0];
$code->domain_part = $parts[1];
} else {
$code->local_part = null;
$code->domain_part = null;
}
}
}
diff --git a/src/app/Policy/Greylist/Connect.php b/src/app/Policy/Greylist/Connect.php
new file mode 100644
index 00000000..6bd1b2b1
--- /dev/null
+++ b/src/app/Policy/Greylist/Connect.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Policy\Greylist;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * @property \App\Domain $domain
+ * @property \App\Domain|\App\User $recipient
+ * @property \App\User $user
+ */
+class Connect extends Model
+{
+ protected $fillable = [
+ 'sender_local',
+ 'sender_domain',
+ 'net_id',
+ 'net_type',
+ 'recipient_hash',
+ 'recipient_id',
+ 'recipient_type',
+ 'connect_count',
+ 'created_at',
+ 'updated_at'
+ ];
+
+ protected $table = 'greylist_connect';
+
+ public function domain()
+ {
+ if ($this->recipient_type == \App\Domain::class) {
+ return $this->recipient;
+ }
+
+ return null;
+ }
+
+ // determine if the sender is a penpal of the recipient.
+ public function isPenpal()
+ {
+ return false;
+ }
+
+ public function user()
+ {
+ if ($this->recipient_type == \App\User::class) {
+ return $this->recipient;
+ }
+
+ return null;
+ }
+
+ public function net()
+ {
+ return $this->morphTo();
+ }
+
+ public function recipient()
+ {
+ return $this->morphTo();
+ }
+}
diff --git a/src/app/Policy/Greylist/Request.php b/src/app/Policy/Greylist/Request.php
new file mode 100644
index 00000000..0b364131
--- /dev/null
+++ b/src/app/Policy/Greylist/Request.php
@@ -0,0 +1,299 @@
+<?php
+
+namespace App\Policy\Greylist;
+
+use Illuminate\Support\Facades\DB;
+
+class Request
+{
+ protected $header;
+ protected $netID;
+ protected $netType;
+ protected $recipientHash;
+ protected $recipientID = null;
+ protected $recipientType = null;
+ protected $sender;
+ protected $senderLocal;
+ protected $senderDomain;
+ protected $timestamp;
+ protected $whitelist;
+ protected $request = [];
+
+ public function __construct($request)
+ {
+ $this->request = $request;
+
+ if (array_key_exists('timestamp', $this->request)) {
+ $this->timestamp = \Carbon\Carbon::parse($this->request['timestamp']);
+ } else {
+ $this->timestamp = \Carbon\Carbon::now();
+ }
+ }
+
+ public function headerGreylist()
+ {
+ if ($this->whitelist) {
+ if ($this->whitelist->sender_local) {
+ return sprintf(
+ "Received-Greylist: sender %s whitelisted since %s",
+ $this->sender,
+ $this->whitelist->created_at->toDateString()
+ );
+ }
+
+ return sprintf(
+ "Received-Greylist: domain %s from %s whitelisted since %s (UTC)",
+ $this->senderDomain,
+ $this->request['client_address'],
+ $this->whitelist->created_at->toDateTimeString()
+ );
+ }
+
+ $connect = $this->findConnectsCollection()->orderBy('created_at')->first();
+
+ if ($connect) {
+ return sprintf(
+ "Received-Greylist: greylisted from %s until %s.",
+ $connect->created_at,
+ $this->timestamp
+ );
+ }
+
+ return "Received-Greylist: no opinion here";
+ }
+
+ public function shouldDefer()
+ {
+ $deferIfPermit = true;
+
+ list($this->netID, $this->netType) = \App\Utils::getNetFromAddress($this->request['client_address']);
+
+ if (!$this->netID) {
+ return true;
+ }
+
+ $recipient = $this->recipientFromRequest();
+
+ $this->sender = $this->senderFromRequest();
+
+ list($this->senderLocal, $this->senderDomain) = explode('@', $this->sender);
+
+ $entry = $this->findConnectsCollectionRecent()->orderBy('updated_at')->first();
+
+ if (!$entry) {
+ // purge all entries to avoid a unique constraint violation.
+ $this->findConnectsCollection()->delete();
+
+ $entry = Connect::create(
+ [
+ 'sender_local' => $this->senderLocal,
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'recipient_hash' => $this->recipientHash,
+ 'recipient_id' => $this->recipientID,
+ 'recipient_type' => $this->recipientType,
+ 'connect_count' => 1,
+ 'created_at' => $this->timestamp,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+ }
+
+ // see if all recipients and their domains are opt-outs
+ $enabled = false;
+
+ if ($recipient) {
+ $setting = Setting::where(
+ [
+ 'object_id' => $this->recipientID,
+ 'object_type' => $this->recipientType,
+ 'key' => 'greylist_enabled'
+ ]
+ )->first();
+
+ if (!$setting) {
+ $setting = Setting::where(
+ [
+ 'object_id' => $recipient->domain()->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled'
+ ]
+ )->first();
+
+ if (!$setting) {
+ $enabled = true;
+ } else {
+ if ($setting->{'value'} !== 'false') {
+ $enabled = true;
+ }
+ }
+ } else {
+ if ($setting->{'value'} !== 'false') {
+ $enabled = true;
+ }
+ }
+ } else {
+ $enabled = true;
+ }
+
+ // the following block is to maintain statistics and state ...
+ $entries = Connect::where(
+ [
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType
+ ]
+ )
+ ->whereDate('updated_at', '>=', $this->timestamp->copy()->subDays(7));
+
+ // determine if the sender domain is a whitelist from this network
+ $this->whitelist = Whitelist::where(
+ [
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType
+ ]
+ )->first();
+
+ if ($this->whitelist) {
+ if ($this->whitelist->updated_at < $this->timestamp->copy()->subMonthsWithoutOverflow(1)) {
+ $this->whitelist->delete();
+ } else {
+ $this->whitelist->updated_at = $this->timestamp;
+ $this->whitelist->save(['timestamps' => false]);
+
+ $entries->update(
+ [
+ 'greylisting' => false,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+
+ return false;
+ }
+ } else {
+ if ($entries->count() >= 5) {
+ $this->whitelist = Whitelist::create(
+ [
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'created_at' => $this->timestamp,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+
+ $entries->update(
+ [
+ 'greylisting' => false,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+ }
+ }
+
+ // TODO: determine if the sender (individual) is a whitelist
+
+ // TODO: determine if the sender is a penpal of any of the recipients. First recipient wins.
+
+ if (!$enabled) {
+ return false;
+ }
+
+ // determine if the sender, net and recipient combination has existed before, for each recipient
+ // any one recipient matching should supersede the other recipients not having matched
+ $connect = Connect::where(
+ [
+ 'sender_local' => $this->senderLocal,
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'recipient_id' => $this->recipientID,
+ 'recipient_type' => $this->recipientType,
+ ]
+ )
+ ->whereDate('updated_at', '>=', $this->timestamp->copy()->subMonthsWithoutOverflow(1))
+ ->orderBy('updated_at')
+ ->first();
+
+ if (!$connect) {
+ $connect = Connect::create(
+ [
+ 'sender_local' => $this->senderLocal,
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'recipient_id' => $this->recipientID,
+ 'recipient_type' => $this->recipientType,
+ 'connect_count' => 0,
+ 'created_at' => $this->timestamp,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+ }
+
+ $connect->connect_count += 1;
+
+ // TODO: The period of time for which the greylisting persists is configurable.
+ if ($connect->created_at < $this->timestamp->copy()->subMinutes(5)) {
+ $deferIfPermit = false;
+
+ $connect->greylisting = false;
+ }
+
+ $connect->save();
+
+ return $deferIfPermit;
+ }
+
+ private function findConnectsCollection()
+ {
+ $collection = Connect::where(
+ [
+ 'sender_local' => $this->senderLocal,
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'recipient_id' => $this->recipientID,
+ 'recipient_type' => $this->recipientType
+ ]
+ );
+
+ return $collection;
+ }
+
+ private function findConnectsCollectionRecent()
+ {
+ return $this->findConnectsCollection()
+ ->where('updated_at', '>=', $this->timestamp->copy()->subDays(7));
+ }
+
+ private function recipientFromRequest()
+ {
+ $recipients = \App\Utils::findObjectsByRecipientAddress($this->request['recipient']);
+
+ if (sizeof($recipients) > 1) {
+ \Log::warning(
+ "Only taking the first recipient from the request in to account for {$this->request['recipient']}"
+ );
+ }
+
+ if (count($recipients) >= 1) {
+ $recipient = $recipients[0];
+ $this->recipientID = $recipient->id;
+ $this->recipientType = get_class($recipient);
+ } else {
+ $recipient = null;
+ }
+
+ $this->recipientHash = hash('sha256', $this->request['recipient']);
+
+ return $recipient;
+ }
+
+ public function senderFromRequest()
+ {
+ return \App\Utils::normalizeAddress($this->request['sender']);
+ }
+}
diff --git a/src/app/Policy/Greylist/Setting.php b/src/app/Policy/Greylist/Setting.php
new file mode 100644
index 00000000..ec223ef8
--- /dev/null
+++ b/src/app/Policy/Greylist/Setting.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Policy\Greylist;
+
+use Illuminate\Database\Eloquent\Model;
+
+class Setting extends Model
+{
+ protected $table = 'greylist_settings';
+
+ protected $fillable = [
+ 'object_id',
+ 'object_type',
+ 'key',
+ 'value'
+ ];
+}
diff --git a/src/app/Policy/Greylist/Whitelist.php b/src/app/Policy/Greylist/Whitelist.php
new file mode 100644
index 00000000..aa54a6f3
--- /dev/null
+++ b/src/app/Policy/Greylist/Whitelist.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Policy\Greylist;
+
+use Illuminate\Database\Eloquent\Model;
+
+class Whitelist extends Model
+{
+ protected $table = 'greylist_whitelist';
+
+ protected $fillable = [
+ 'sender_local',
+ 'sender_domain',
+ 'net_id',
+ 'net_type',
+ 'created_at',
+ 'updated_at'
+ ];
+}
diff --git a/src/app/Policy/SPF/Cache.php b/src/app/Policy/SPF/Cache.php
new file mode 100644
index 00000000..88c44709
--- /dev/null
+++ b/src/app/Policy/SPF/Cache.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Policy\SPF;
+
+use Illuminate\Support\Facades\Cache as LaravelCache;
+
+/**
+ * A caching layer for SPF check results, as sometimes the chasing of DNS entries can take a while but submissions
+ * inbound are virtually not rate-limited.
+ *
+ * A cache key should have the format of ip(4|6)_id_domain and last for 12 hours.
+ *
+ * A cache value should have a serialized version of the \SPFLib\Checker.
+ */
+class Cache
+{
+ public static function get($key)
+ {
+ if (LaravelCache::has($key)) {
+ return LaravelCache::get($key);
+ }
+
+ return null;
+ }
+
+ public static function has($key)
+ {
+ return LaravelCache::has($key);
+ }
+
+ public static function set($key, $value)
+ {
+ if (LaravelCache::has($key)) {
+ LaravelCache::forget($key);
+ }
+
+ // cache the DNS record result for 12 hours
+ LaravelCache::put($key, $value, 60 * 60 * 12);
+ }
+}
diff --git a/src/app/Traits/DomainConfigTrait.php b/src/app/Traits/DomainConfigTrait.php
new file mode 100644
index 00000000..a54dc7ab
--- /dev/null
+++ b/src/app/Traits/DomainConfigTrait.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Traits;
+
+trait DomainConfigTrait
+{
+ /**
+ * A helper to get the domain configuration.
+ */
+ public function getConfig(): array
+ {
+ $config = [];
+
+ $spf = $this->getSetting('spf_whitelist');
+
+ $config['spf_whitelist'] = $spf ? json_decode($spf, true) : [];
+
+ return $config;
+ }
+
+ /**
+ * A helper to update domain configuration.
+ *
+ * @param array $config An array of configuration options
+ *
+ * @return array A list of input validation errors
+ */
+ public function setConfig(array $config): array
+ {
+ $errors = [];
+
+ foreach ($config as $key => $value) {
+ // validate and save SPF whitelist entries
+ if ($key === 'spf_whitelist') {
+ if (!is_array($value)) {
+ $value = (array) $value;
+ }
+
+ foreach ($value as $i => $v) {
+ $v = rtrim($v, '.');
+
+ if (empty($v)) {
+ unset($value[$i]);
+ continue;
+ }
+
+ $value[$i] = $v;
+
+ if ($v[0] !== '.' || !filter_var(substr($v, 1), FILTER_VALIDATE_DOMAIN)) {
+ $errors[$key][$i] = \trans('validation.spf-entry-invalid');
+ }
+ }
+
+ if (empty($errors[$key])) {
+ $this->setSetting($key, json_encode($value));
+ }
+ } else {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ }
+ }
+
+ return $errors;
+ }
+}
diff --git a/src/app/Traits/UserConfigTrait.php b/src/app/Traits/UserConfigTrait.php
new file mode 100644
index 00000000..951488c0
--- /dev/null
+++ b/src/app/Traits/UserConfigTrait.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Traits;
+
+trait UserConfigTrait
+{
+ /**
+ * A helper to get the user configuration.
+ */
+ public function getConfig(): array
+ {
+ $config = [];
+
+ // TODO: Should we store the default value somewhere in config?
+
+ $config['greylisting'] = $this->getSetting('greylisting') !== 'false';
+
+ return $config;
+ }
+
+ /**
+ * A helper to update user configuration.
+ *
+ * @param array $config An array of configuration options
+ *
+ * @return array A list of input validation error messages
+ */
+ public function setConfig(array $config): array
+ {
+ $errors = [];
+
+ foreach ($config as $key => $value) {
+ if ($key == 'greylisting') {
+ $this->setSetting('greylisting', $value ? 'true' : 'false');
+ } else {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ }
+ }
+
+ return $errors;
+ }
+}
diff --git a/src/app/User.php b/src/app/User.php
index 173b749a..96dd0262 100644
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -1,758 +1,800 @@
<?php
namespace App;
use App\Entitlement;
use App\UserAlias;
use App\Sku;
+use App\Traits\UserConfigTrait;
use App\Traits\UserAliasesTrait;
use App\Traits\SettingsTrait;
use App\Wallet;
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 $password
* @property int $status
* @property int $tenant_id
*/
class User extends Authenticatable implements JWTSubject
{
use NullableFields;
+ use UserConfigTrait;
use UserAliasesTrait;
use SettingsTrait;
use SoftDeletes;
// a new user, default on creation
public const STATUS_NEW = 1 << 0;
// it's been activated
public const STATUS_ACTIVE = 1 << 1;
// user has been suspended
public const STATUS_SUSPENDED = 1 << 2;
// user has been deleted
public const STATUS_DELETED = 1 << 3;
// user has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
// user mailbox has been created in IMAP
public const STATUS_IMAP_READY = 1 << 5;
// change the default primary key type
public $incrementing = false;
protected $keyType = 'bigint';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'id',
'email',
'password',
'password_ldap',
'status',
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'password',
'password_ldap',
'role'
];
protected $nullable = [
'password',
'password_ldap'
];
/**
* Any wallets on which this user is a controller.
*
* This does not include wallets owned by the user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function accounts()
{
return $this->belongsToMany(
'App\Wallet', // The foreign object definition
'user_accounts', // The table name
'user_id', // The local foreign key
'wallet_id' // The remote foreign key
);
}
/**
* Email aliases of this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function aliases()
{
return $this->hasMany('App\UserAlias', 'user_id');
}
/**
* Assign a package to a user. The user should not have any existing entitlements.
*
* @param \App\Package $package The package to assign.
* @param \App\User|null $user Assign the package to another user.
*
* @return \App\User
*/
public function assignPackage($package, $user = null)
{
if (!$user) {
$user = $this;
}
$wallet_id = $this->wallets()->first()->id;
foreach ($package->skus as $sku) {
for ($i = $sku->pivot->qty; $i > 0; $i--) {
\App\Entitlement::create(
[
'wallet_id' => $wallet_id,
'sku_id' => $sku->id,
'cost' => $sku->pivot->cost(),
'fee' => $sku->pivot->fee(),
'entitleable_id' => $user->id,
'entitleable_type' => User::class
]
);
}
}
return $user;
}
/**
* Assign a package plan to a user.
*
* @param \App\Plan $plan The plan to assign
* @param \App\Domain $domain Optional domain object
*
* @return \App\User Self
*/
public function assignPlan($plan, $domain = null): User
{
$this->setSetting('plan_id', $plan->id);
foreach ($plan->packages as $package) {
if ($package->isDomain()) {
$domain->assignPackage($package, $this);
} else {
$this->assignPackage($package);
}
}
return $this;
}
/**
* Assign a Sku to a user.
*
* @param \App\Sku $sku The sku to assign.
* @param int $count Count of entitlements to add
*
* @return \App\User Self
* @throws \Exception
*/
public function assignSku(Sku $sku, int $count = 1): User
{
// TODO: I guess wallet could be parametrized in future
$wallet = $this->wallet();
$exists = $this->entitlements()->where('sku_id', $sku->id)->count();
while ($count > 0) {
\App\Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $sku->id,
'cost' => $exists >= $sku->units_free ? $sku->cost : 0,
'fee' => $exists >= $sku->units_free ? $sku->fee : 0,
'entitleable_id' => $this->id,
'entitleable_type' => User::class
]);
$exists++;
$count--;
}
return $this;
}
/**
* Check if current user can delete another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canDelete($object): bool
{
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
// TODO: For now controller can delete/update the account owner,
// this may change in future, controllers are not 0-regression feature
return $this->wallets->contains($wallet) || $this->accounts->contains($wallet);
}
/**
* Check if current user can read data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canRead($object): bool
{
if ($this->role == 'admin') {
return true;
}
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
if ($object instanceof Wallet) {
return $object->user_id == $this->id || $object->controllers->contains($this);
}
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
return $wallet && ($this->wallets->contains($wallet) || $this->accounts->contains($wallet));
}
/**
* Check if current user can update data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canUpdate($object): bool
{
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'admin') {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
return $this->canDelete($object);
}
/**
* Return the \App\Domain for this user.
*
* @return \App\Domain|null
*/
public function domain()
{
list($local, $domainName) = explode('@', $this->email);
$domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first();
return $domain;
}
/**
* List the domains to which this user is entitled.
* Note: Active public domains are also returned (for the user tenant).
*
* @return Domain[] List of Domain objects
*/
public function domains(): array
{
if ($this->tenant_id) {
$domains = Domain::where('tenant_id', $this->tenant_id);
} else {
$domains = Domain::withEnvTenantContext();
}
$domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC))
->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE))
->get()
->all();
foreach ($this->wallets as $wallet) {
$entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get();
foreach ($entitlements as $entitlement) {
$domains[] = $entitlement->entitleable;
}
}
foreach ($this->accounts as $wallet) {
$entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get();
foreach ($entitlements as $entitlement) {
$domains[] = $entitlement->entitleable;
}
}
return $domains;
}
/**
* The user entitlement.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphOne
*/
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')
->where('entitleable_type', User::class);
}
/**
* Find whether an email address exists as a user (including deleted users).
*
* @param string $email Email address
* @param bool $return_user Return User instance instead of boolean
*
* @return \App\User|bool True or User model object if found, False otherwise
*/
public static function emailExists(string $email, bool $return_user = false)
{
if (strpos($email, '@') === false) {
return false;
}
$email = \strtolower($email);
$user = self::withTrashed()->where('email', $email)->first();
if ($user) {
return $return_user ? $user : true;
}
return false;
}
/**
* Helper to find user by email address, whether it is
* main email address, alias or an external email.
*
* If there's more than one alias NULL will be returned.
*
* @param string $email Email address
* @param bool $external Search also for an external email
*
* @return \App\User User model object if found
*/
public static function findByEmail(string $email, bool $external = false): ?User
{
if (strpos($email, '@') === false) {
return null;
}
$email = \strtolower($email);
$user = self::where('email', $email)->first();
if ($user) {
return $user;
}
$aliases = UserAlias::where('alias', $email)->get();
if (count($aliases) == 1) {
return $aliases->first()->user;
}
// TODO: External email
return null;
}
public function getJWTIdentifier()
{
return $this->getKey();
}
public function getJWTCustomClaims()
{
return [];
}
/**
* Return groups controlled by the current user.
*
* @param bool $with_accounts Include groups assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function groups($with_accounts = true)
{
$wallets = $this->wallets()->pluck('id')->all();
if ($with_accounts) {
$wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
}
return Group::select(['groups.*', 'entitlements.wallet_id'])
->distinct()
->join('entitlements', 'entitlements.entitleable_id', '=', 'groups.id')
->whereIn('entitlements.wallet_id', $wallets)
->where('entitlements.entitleable_type', Group::class);
}
/**
* Check if user has an entitlement for the specified SKU.
*
* @param string $title The SKU title
*
* @return bool True if specified SKU entitlement exists
*/
public function hasSku(string $title): bool
{
$sku = Sku::withObjectTenantContext($this)->where('title', $title)->first();
if (!$sku) {
return false;
}
return $this->entitlements()->where('sku_id', $sku->id)->count() > 0;
}
/**
* Returns whether this domain is active.
*
* @return bool
*/
public function isActive(): bool
{
return ($this->status & self::STATUS_ACTIVE) > 0;
}
/**
* Returns whether this domain is deleted.
*
* @return bool
*/
public function isDeleted(): bool
{
return ($this->status & self::STATUS_DELETED) > 0;
}
/**
* Returns whether this (external) domain has been verified
* to exist in DNS.
*
* @return bool
*/
public function isImapReady(): bool
{
return ($this->status & self::STATUS_IMAP_READY) > 0;
}
/**
* Returns whether this user is registered in LDAP.
*
* @return bool
*/
public function isLdapReady(): bool
{
return ($this->status & self::STATUS_LDAP_READY) > 0;
}
/**
* Returns whether this user is new.
*
* @return bool
*/
public function isNew(): bool
{
return ($this->status & self::STATUS_NEW) > 0;
}
/**
* Returns whether this domain is suspended.
*
* @return bool
*/
public function isSuspended(): bool
{
return ($this->status & self::STATUS_SUSPENDED) > 0;
}
/**
* A shortcut to get the user name.
*
* @param bool $fallback Return "<aa.name> User" if there's no name
*
* @return string Full user name
*/
public function name(bool $fallback = false): string
{
$firstname = $this->getSetting('first_name');
$lastname = $this->getSetting('last_name');
$name = trim($firstname . ' ' . $lastname);
if (empty($name) && $fallback) {
return \config('app.name') . ' User';
}
return $name;
}
/**
* Remove a number of entitlements for the SKU.
*
* @param \App\Sku $sku The SKU
* @param int $count The number of entitlements to remove
*
* @return User Self
*/
public function removeSku(Sku $sku, int $count = 1): User
{
$entitlements = $this->entitlements()
->where('sku_id', $sku->id)
->orderBy('cost', 'desc')
->orderBy('created_at')
->get();
$entitlements_count = count($entitlements);
foreach ($entitlements as $entitlement) {
if ($entitlements_count <= $sku->units_free) {
continue;
}
if ($count > 0) {
$entitlement->delete();
$entitlements_count--;
$count--;
}
}
return $this;
}
+ public function senderPolicyFrameworkWhitelist($clientName)
+ {
+ $setting = $this->getSetting('spf_whitelist');
+
+ if (!$setting) {
+ return false;
+ }
+
+ $whitelist = json_decode($setting);
+
+ $matchFound = false;
+
+ foreach ($whitelist as $entry) {
+ if (substr($entry, 0, 1) == '/') {
+ $match = preg_match($entry, $clientName);
+
+ if ($match) {
+ $matchFound = true;
+ }
+
+ continue;
+ }
+
+ if (substr($entry, 0, 1) == '.') {
+ if (substr($clientName, (-1 * strlen($entry))) == $entry) {
+ $matchFound = true;
+ }
+
+ continue;
+ }
+
+ if ($entry == $clientName) {
+ $matchFound = true;
+ continue;
+ }
+ }
+
+ return $matchFound;
+ }
+
/**
* 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();
}
/**
* The tenant for this user account.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function tenant()
{
return $this->belongsTo('App\Tenant', 'tenant_id', 'id');
}
/**
* Unsuspend this domain.
*
* @return void
*/
public function unsuspend(): void
{
if (!$this->isSuspended()) {
return;
}
$this->status ^= User::STATUS_SUSPENDED;
$this->save();
}
/**
* Return users controlled by the current user.
*
* @param bool $with_accounts Include users assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function users($with_accounts = true)
{
$wallets = $this->wallets()->pluck('id')->all();
if ($with_accounts) {
$wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
}
return $this->select(['users.*', 'entitlements.wallet_id'])
->distinct()
->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id')
->whereIn('entitlements.wallet_id', $wallets)
->where('entitlements.entitleable_type', User::class);
}
/**
* Verification codes for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function verificationcodes()
{
return $this->hasMany('App\VerificationCode', 'user_id', 'id');
}
/**
* Returns the wallet by which the user is controlled
*
* @return ?\App\Wallet A wallet object
*/
public function wallet(): ?Wallet
{
$entitlement = $this->entitlement()->withTrashed()->orderBy('created_at', 'desc')->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)
{
$this->setPasswordAttribute($password);
}
/**
* User status mutator
*
* @throws \Exception
*/
public function setStatusAttribute($status)
{
$new_status = 0;
$allowed_values = [
self::STATUS_NEW,
self::STATUS_ACTIVE,
self::STATUS_SUSPENDED,
self::STATUS_DELETED,
self::STATUS_LDAP_READY,
self::STATUS_IMAP_READY,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid user status: {$status}");
}
$this->attributes['status'] = $new_status;
}
}
diff --git a/src/app/Utils.php b/src/app/Utils.php
index 6fa74742..472840ef 100644
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -1,462 +1,566 @@
<?php
namespace App;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Ramsey\Uuid\Uuid;
use Illuminate\Support\Facades\Cache;
/**
* Small utility functions for App.
*/
class Utils
{
// Note: Removed '0', 'O', '1', 'I' as problematic with some fonts
public const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
/**
* Count the number of lines in a file.
*
* Useful for progress bars.
*
* @param string $file The filepath to count the lines of.
*
* @return int
*/
public static function countLines($file)
{
$fh = fopen($file, 'rb');
$numLines = 0;
while (!feof($fh)) {
$numLines += substr_count(fread($fh, 8192), "\n");
}
fclose($fh);
return $numLines;
}
/**
* Return the country ISO code for an IP address.
*
* @return string
*/
public static function countryForIP($ip)
{
- if (strpos(':', $ip) === false) {
+ if (strpos($ip, ':') === false) {
$query = "
SELECT country FROM ip4nets
WHERE INET_ATON(net_number) <= INET_ATON(?)
AND INET_ATON(net_broadcast) >= INET_ATON(?)
ORDER BY INET_ATON(net_number), net_mask DESC LIMIT 1
";
} else {
$query = "
SELECT id FROM ip6nets
WHERE INET6_ATON(net_number) <= INET6_ATON(?)
AND INET6_ATON(net_broadcast) >= INET6_ATON(?)
ORDER BY INET6_ATON(net_number), net_mask DESC LIMIT 1
";
}
$nets = \Illuminate\Support\Facades\DB::select($query, [$ip, $ip]);
if (sizeof($nets) > 0) {
return $nets[0]->country;
}
return 'CH';
}
/**
* Return the country ISO code for the current request.
*/
public static function countryForRequest()
{
$request = \request();
$ip = $request->ip();
return self::countryForIP($ip);
}
/**
* Shortcut to creating a progress bar of a particular format with a particular message.
*
* @param \Illuminate\Console\OutputStyle $output Console output object
* @param int $count Number of progress steps
* @param string $message The description
*
* @return \Symfony\Component\Console\Helper\ProgressBar
*/
public static function createProgressBar($output, $count, $message = null)
{
$bar = $output->createProgressBar($count);
$bar->setFormat(
'%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% '
);
if ($message) {
$bar->setMessage($message . " ...");
}
$bar->start();
return $bar;
}
/**
* Return the number of days in the month prior to this one.
*
* @return int
*/
public static function daysInLastMonth()
{
$start = new Carbon('first day of last month');
$end = new Carbon('last day of last month');
return $start->diffInDays($end) + 1;
}
/**
* Download a file from the interwebz and store it locally.
*
* @param string $source The source location
* @param string $target The target location
* @param bool $force Force the download (and overwrite target)
*
* @return void
*/
public static function downloadFile($source, $target, $force = false)
{
if (is_file($target) && !$force) {
return;
}
\Log::info("Retrieving {$source}");
$fp = fopen($target, 'w');
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $source);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_FILE, $fp);
curl_exec($curl);
if (curl_errno($curl)) {
\Log::error("Request error on {$source}: " . curl_error($curl));
curl_close($curl);
fclose($fp);
unlink($target);
return;
}
curl_close($curl);
fclose($fp);
}
/**
* Generate a passphrase. Not intended for use in production, so limited to environments that are not production.
*
* @return string
*/
public static function generatePassphrase()
{
if (\config('app.env') == 'production') {
throw new \Exception("Thou shall not pass!");
}
if (\config('app.passphrase')) {
return \config('app.passphrase');
}
$alphaLow = 'abcdefghijklmnopqrstuvwxyz';
$alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$num = '0123456789';
$stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<';
$source = $alphaLow . $alphaUp . $num . $stdSpecial;
$result = '';
for ($x = 0; $x < 16; $x++) {
$result .= substr($source, rand(0, (strlen($source) - 1)), 1);
}
return $result;
}
+ /**
+ * Find an object that is the recipient for the specified address.
+ *
+ * @param string $address
+ *
+ * @return array
+ */
+ public static function findObjectsByRecipientAddress($address)
+ {
+ $address = \App\Utils::normalizeAddress($address);
+
+ list($local, $domainName) = explode('@', $address);
+
+ $domain = \App\Domain::where('namespace', $domainName)->first();
+
+ if (!$domain) {
+ return [];
+ }
+
+ $user = \App\User::where('email', $address)->first();
+
+ if ($user) {
+ return [$user];
+ }
+
+ $userAliases = \App\UserAlias::where('alias', $address)->get();
+
+ if (count($userAliases) > 0) {
+ $users = [];
+
+ foreach ($userAliases as $userAlias) {
+ $users[] = $userAlias->user;
+ }
+
+ return $users;
+ }
+
+ $userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get();
+
+ if (count($userAliases) > 0) {
+ $users = [];
+
+ foreach ($userAliases as $userAlias) {
+ $users[] = $userAlias->user;
+ }
+
+ return $users;
+ }
+
+ return [];
+ }
+
+ /**
+ * Retrieve the network ID and Type from a client address
+ *
+ * @param string $clientAddress The IPv4 or IPv6 address.
+ *
+ * @return array An array of ID and class or null and null.
+ */
+ public static function getNetFromAddress($clientAddress)
+ {
+ if (strpos($clientAddress, ':') === false) {
+ $net = \App\IP4Net::getNet($clientAddress);
+
+ if ($net) {
+ return [$net->id, \App\IP4Net::class];
+ }
+ } else {
+ $net = \App\IP6Net::getNet($clientAddress);
+
+ if ($net) {
+ return [$net->id, \App\IP6Net::class];
+ }
+ }
+
+ return [null, null];
+ }
+
/**
* Calculate the broadcast address provided a net number and a prefix.
*
* @param string $net A valid IPv6 network number.
* @param int $prefix The network prefix.
*
* @return string
*/
public static function ip6Broadcast($net, $prefix)
{
$netHex = bin2hex(inet_pton($net));
// Overwriting first address string to make sure notation is optimal
$net = inet_ntop(hex2bin($netHex));
// Calculate the number of 'flexible' bits
$flexbits = 128 - $prefix;
// Build the hexadecimal string of the last address
$lastAddrHex = $netHex;
// We start at the end of the string (which is always 32 characters long)
$pos = 31;
while ($flexbits > 0) {
// Get the character at this position
$orig = substr($lastAddrHex, $pos, 1);
// Convert it to an integer
$origval = hexdec($orig);
// OR it with (2^flexbits)-1, with flexbits limited to 4 at a time
$newval = $origval | (pow(2, min(4, $flexbits)) - 1);
// Convert it back to a hexadecimal character
$new = dechex($newval);
// And put that character back in the string
$lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1);
// We processed one nibble, move to previous position
$flexbits -= 4;
$pos -= 1;
}
// Convert the hexadecimal string to a binary string
$lastaddrbin = hex2bin($lastAddrHex);
// And create an IPv6 address from the binary string
$lastaddrstr = inet_ntop($lastaddrbin);
return $lastaddrstr;
}
+ /**
+ * Normalize an email address.
+ *
+ * This means to lowercase and strip components separated with recipient delimiters.
+ *
+ * @param string $address The address to normalize.
+ *
+ * @return string
+ */
+ public static function normalizeAddress($address)
+ {
+ $address = strtolower($address);
+
+ list($local, $domain) = explode('@', $address);
+
+ if (strpos($local, '+') === false) {
+ return "{$local}@{$domain}";
+ }
+
+ $localComponents = explode('+', $local);
+
+ $local = array_pop($localComponents);
+
+ return "{$local}@{$domain}";
+ }
+
/**
* 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 the current user's email address or null.
*
* @return string
*/
public static function userEmailOrNull(): ?string
{
$user = Auth::user();
if (!$user) {
return null;
}
return $user->email;
}
/**
* Returns a random string consisting of a quantity of segments of a certain length joined.
*
* Example:
*
* ```php
* $roomName = strtolower(\App\Utils::randStr(3, 3, '-');
* // $roomName == '3qb-7cs-cjj'
* ```
*
* @param int $length The length of each segment
* @param int $qty The quantity of segments
* @param string $join The string to use to join the segments
*
* @return string
*/
public static function randStr($length, $qty = 1, $join = '')
{
$chars = env('SHORTCODE_CHARS', self::CHARS);
$randStrs = [];
for ($x = 0; $x < $qty; $x++) {
$randStrs[$x] = [];
for ($y = 0; $y < $length; $y++) {
$randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)];
}
shuffle($randStrs[$x]);
$randStrs[$x] = implode('', $randStrs[$x]);
}
return implode($join, $randStrs);
}
/**
* 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 self URL
*
* @param string $route Route/Path
* @todo Move this to App\Http\Controllers\Controller
*
* @return string Full URL
*/
public static function serviceUrl(string $route): string
{
$url = \config('app.public_url');
if (!$url) {
$url = \config('app.url');
}
return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/');
}
/**
* Create a configuration/environment data to be passed to
* the UI
*
* @todo Move this to App\Http\Controllers\Controller
*
* @return array Configuration data
*/
public static function uiEnv(): array
{
$countries = include resource_path('countries.php');
$req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
$sys_domain = \config('app.domain');
$opts = [
'app.name',
'app.url',
'app.domain',
'app.theme',
'app.webmail_url',
'app.support_email',
'mail.from.address'
];
$env = \app('config')->getMany($opts);
$env['countries'] = $countries ?: [];
$env['view'] = 'root';
$env['jsapp'] = 'user.js';
if ($req_domain == "admin.$sys_domain") {
$env['jsapp'] = 'admin.js';
} elseif ($req_domain == "reseller.$sys_domain") {
$env['jsapp'] = 'reseller.js';
}
$env['paymentProvider'] = \config('services.payment_provider');
$env['stripePK'] = \config('services.stripe.public_key');
$env['languages'] = \App\Http\Controllers\ContentController::locales();
$env['menu'] = \App\Http\Controllers\ContentController::menu();
return $env;
}
/**
* Retrieve an exchange rate.
*
* @param string $sourceCurrency: Currency from which to convert
* @param string $targetCurrency: Currency to convert to
*
* @return float Exchange rate
*/
public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float
{
if (strcasecmp($sourceCurrency, $targetCurrency) == 0) {
return 1.0;
}
$currencyFile = resource_path("exchangerates-$sourceCurrency.php");
//Attempt to find the reverse exchange rate, if we don't have the file for the source currency
if (!file_exists($currencyFile)) {
$rates = include resource_path("exchangerates-$targetCurrency.php");
if (!isset($rates[$sourceCurrency])) {
throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency);
}
return 1.0 / floatval($rates[$sourceCurrency]);
}
$rates = include $currencyFile;
if (!isset($rates[$targetCurrency])) {
throw new \Exception("Failed to find exchange rate for " . $targetCurrency);
}
return floatval($rates[$targetCurrency]);
}
}
diff --git a/src/composer.json b/src/composer.json
index 6d67401d..a64337c3 100644
--- a/src/composer.json
+++ b/src/composer.json
@@ -1,84 +1,85 @@
{
"name": "laravel/laravel",
"type": "project",
"description": "The Laravel Framework.",
"keywords": [
"framework",
"laravel"
],
"license": "MIT",
"repositories": [
{
"type": "vcs",
"url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git"
}
],
"require": {
"php": "^7.3",
"barryvdh/laravel-dompdf": "^0.8.6",
"doctrine/dbal": "^2.13",
"dyrynda/laravel-nullable-fields": "*",
"fideloper/proxy": "^4.0",
"guzzlehttp/guzzle": "^7.3",
"kolab/net_ldap3": "dev-master",
"laravel/framework": "6.*",
"laravel/horizon": "^3",
"laravel/tinker": "^2.4",
+ "mlocati/spf-lib": "^3.0",
"mollie/laravel-mollie": "^2.9",
"morrislaptop/laravel-queue-clear": "^1.2",
"silviolleite/laravelpwa": "^2.0",
"spatie/laravel-translatable": "^4.2",
"spomky-labs/otphp": "~4.0.0",
"stripe/stripe-php": "^7.29",
"swooletw/laravel-swoole": "^2.6",
"tymon/jwt-auth": "^1.0"
},
"require-dev": {
"beyondcode/laravel-er-diagram-generator": "^1.3",
"code-lts/doctum": "^5.1",
"kirschbaum-development/mail-intercept": "^0.2.4",
"laravel/dusk": "~6.15.0",
"nunomaduro/larastan": "^0.7",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^9"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"autoload": {
"psr-4": {
"App\\": "app/"
},
"classmap": [
"database/seeds",
"database/factories",
"include"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"minimum-stability": "dev",
"prefer-stable": true,
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
]
}
}
diff --git a/src/config/cache.php b/src/config/cache.php
index 46751e62..93adfa06 100644
--- a/src/config/cache.php
+++ b/src/config/cache.php
@@ -1,103 +1,103 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache connection that gets used while
| using this caching library. This connection is used when another is
| not explicitly specified when executing a given caching function.
|
| Supported: "apc", "array", "database", "file",
| "memcached", "redis", "dynamodb"
|
*/
- 'default' => env('CACHE_DRIVER', 'file'),
+ 'default' => env('CACHE_DRIVER', 'redis'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
*/
'stores' => [
'apc' => [
'driver' => 'apc',
],
'array' => [
'driver' => 'array',
],
'database' => [
'driver' => 'database',
'table' => 'cache',
'connection' => null,
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing a RAM based store such as APC or Memcached, there might
| be other applications utilizing the same cache. So, we'll specify a
| value to get prefixed to all our keys so we can avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache'),
];
diff --git a/src/config/database.php b/src/config/database.php
index 59b3a1ca..1e5c49f0 100644
--- a/src/config/database.php
+++ b/src/config/database.php
@@ -1,152 +1,152 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for all database work. Of course
| you may use many connections at once using the Database library.
|
*/
'default' => env('DB_CONNECTION', 'mysql'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Here are each of the database connections setup for your application.
| Of course, examples of configuring each database platform that is
| supported by Laravel is shown below to make development simple.
|
|
| All database work in Laravel is done through the PHP PDO facilities
| so make sure you have the driver for your particular database of
| choice installed on your machine before you begin development.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DATABASE_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
],
'2fa' => [
'driver' => 'mysql',
'url' => env('MFA_DSN')
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'timezone' => '+00:00',
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'schema' => 'public',
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run in the database.
|
*/
'migrations' => 'migrations',
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as APC or Memcached. Laravel makes it easy to dig right in.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'predis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'predis'),
- 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
+ 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DB', 0),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_CACHE_DB', 1),
],
],
];
diff --git a/src/database/migrations/2020_10_18_091319_create_greylist_tables.php b/src/database/migrations/2020_10_18_091319_create_greylist_tables.php
new file mode 100644
index 00000000..4a1589f9
--- /dev/null
+++ b/src/database/migrations/2020_10_18_091319_create_greylist_tables.php
@@ -0,0 +1,123 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class CreateGreylistTables extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'greylist_connect',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('sender_local', 256);
+ $table->string('sender_domain', 256);
+ $table->string('recipient_hash', 64);
+ $table->bigInteger('recipient_id')->unsigned()->nullable();
+ $table->string('recipient_type', 16)->nullable();
+ $table->bigInteger('net_id');
+ $table->string('net_type', 16);
+ $table->boolean('greylisting')->default(true);
+ $table->bigInteger('connect_count')->unsigned()->default(1);
+ $table->timestamps();
+
+ /**
+ * Index for recipient request.
+ */
+ $table->index(
+ [
+ 'sender_local',
+ 'sender_domain',
+ 'recipient_hash',
+ 'net_id',
+ 'net_type'
+ ],
+ 'ssrnn_idx'
+ );
+
+ /**
+ * Index for domain whitelist query.
+ */
+ $table->index(
+ [
+ 'sender_domain',
+ 'net_id',
+ 'net_type'
+ ],
+ 'snn_idx'
+ );
+
+ /**
+ * Index for updated_at
+ */
+ $table->index('updated_at');
+
+ $table->unique(
+ ['sender_local', 'sender_domain', 'recipient_hash', 'net_id', 'net_type'],
+ 'ssrnn_unq'
+ );
+ }
+ );
+
+ Schema::create(
+ 'greylist_penpals',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('local_id');
+ $table->string('local_type', 16);
+ $table->string('remote_local', 128);
+ $table->string('remote_domain', 256);
+ $table->timestamps();
+ }
+ );
+
+ Schema::create(
+ 'greylist_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('object_id');
+ $table->string('object_type', 16);
+ $table->string('key', 64);
+ $table->text('value');
+ $table->timestamps();
+
+ $table->index(['object_id', 'object_type', 'key'], 'ook_idx');
+ }
+ );
+
+ Schema::create(
+ 'greylist_whitelist',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('sender_local', 128)->nullable();
+ $table->string('sender_domain', 256);
+ $table->bigInteger('net_id');
+ $table->string('net_type', 16);
+ $table->timestamps();
+
+ $table->index(['sender_local', 'sender_domain', 'net_id', 'net_type'], 'ssnn_idx');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('greylist_connect');
+ Schema::dropIfExists('greylist_penpals');
+ Schema::dropIfExists('greylist_settings');
+ Schema::dropIfExists('greylist_whitelist');
+ }
+}
diff --git a/src/database/migrations/2020_11_20_120000_add_domains_primary_key.php b/src/database/migrations/2020_11_20_120000_add_domains_primary_key.php
new file mode 100644
index 00000000..4d93c53d
--- /dev/null
+++ b/src/database/migrations/2020_11_20_120000_add_domains_primary_key.php
@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class AddDomainsPrimaryKey extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'domains',
+ function (Blueprint $table) {
+ $table->primary('id');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ if (Schema::hasTable('domains')) {
+ Schema::table(
+ 'domains',
+ function (Blueprint $table) {
+ $table->dropPrimary('id');
+ }
+ );
+ }
+ }
+}
diff --git a/src/database/migrations/2020_11_20_130000_create_domain_settings_table.php b/src/database/migrations/2020_11_20_130000_create_domain_settings_table.php
new file mode 100644
index 00000000..5bd2fa3a
--- /dev/null
+++ b/src/database/migrations/2020_11_20_130000_create_domain_settings_table.php
@@ -0,0 +1,44 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class CreateDomainSettingsTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'domain_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('domain_id');
+ $table->string('key');
+ $table->text('value');
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent();
+
+ $table->foreign('domain_id')->references('id')->on('domains')
+ ->onDelete('cascade')->onUpdate('cascade');
+
+ $table->unique(['domain_id', 'key']);
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('domain_settings');
+ }
+}
diff --git a/src/database/migrations/2020_11_20_140000_extend_settings_value_column.php b/src/database/migrations/2020_11_20_140000_extend_settings_value_column.php
new file mode 100644
index 00000000..075741be
--- /dev/null
+++ b/src/database/migrations/2020_11_20_140000_extend_settings_value_column.php
@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class ExtendSettingsValueColumn extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'user_settings',
+ function (Blueprint $table) {
+ $table->text('value')->change();
+ }
+ );
+
+ Schema::table(
+ 'wallet_settings',
+ function (Blueprint $table) {
+ $table->text('value')->change();
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ // do nothing
+ }
+}
diff --git a/src/database/seeds/local/DomainSeeder.php b/src/database/seeds/local/DomainSeeder.php
index e85e230a..777c3c4b 100644
--- a/src/database/seeds/local/DomainSeeder.php
+++ b/src/database/seeds/local/DomainSeeder.php
@@ -1,85 +1,86 @@
<?php
namespace Database\Seeds\Local;
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"
+ "libertymail.co",
+ "libertymail.net"
];
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,
]
);
}
// We're running in reseller mode, add a sample discount
$tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get();
foreach ($tenants as $tenant) {
$domainNamespace = strtolower(str_replace(' ', '-', $tenant->title)) . '.dev-local';
$domain = Domain::create(
[
'namespace' => $domainNamespace,
'status' => Domain::STATUS_CONFIRMED + Domain::STATUS_ACTIVE,
'type' => Domain::TYPE_PUBLIC,
]
);
$domain->tenant_id = $tenant->id;
$domain->save();
}
}
}
diff --git a/src/phpunit.xml b/src/phpunit.xml
index 1f3e446a..29013e4e 100644
--- a/src/phpunit.xml
+++ b/src/phpunit.xml
@@ -1,44 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
<testsuite name="Functional">
<directory suffix="Test.php">tests/Functional</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">tests/Feature</directory>
</testsuite>
<testsuite name="Browser">
<directory suffix="Test.php">tests/Browser</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<logging>
<testdoxHtml outputFile="./tests/report/testdox.html" />
</logging>
<php>
<server name="APP_ENV" value="testing"/>
<server name="APP_DEBUG" value="true"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="MAIL_DRIVER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
+ <server name="SWOOLE_HTTP_ACCESS_LOG" value="false"/>
</php>
</phpunit>
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index 7efba79d..fd83e210 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,517 +1,521 @@
/**
* First we will load all of this project's JavaScript dependencies which
* includes Vue and other libraries. It is a great starting point when
* building robust, powerful web applications using Vue and Laravel.
*/
require('./bootstrap')
import AppComponent from '../vue/App'
import MenuComponent from '../vue/Widgets/Menu'
import SupportForm from '../vue/Widgets/SupportForm'
import store from './store'
import { loadLangAsync, i18n } from './locale'
const loader = '<div class="app-loader"><div class="spinner-border" role="status"><span class="sr-only">Loading</span></div></div>'
let isLoading = 0
// Lock the UI with the 'loading...' element
const startLoading = () => {
isLoading++
let loading = $('#app > .app-loader').removeClass('fadeOut')
if (!loading.length) {
$('#app').append($(loader))
}
}
// Hide "loading" overlay
const stopLoading = () => {
if (isLoading > 0) {
$('#app > .app-loader').addClass('fadeOut')
isLoading--;
}
}
let loadingRoute
// Note: This has to be before the app is created
// Note: You cannot use app inside of the function
window.router.beforeEach((to, from, next) => {
// check if the route requires authentication and user is not logged in
if (to.meta.requiresAuth && !store.state.isLoggedIn) {
// remember the original request, to use after login
store.state.afterLogin = to;
// redirect to login page
next({ name: 'login' })
return
}
if (to.meta.loading) {
startLoading()
loadingRoute = to.name
}
next()
})
window.router.afterEach((to, from) => {
if (to.name && loadingRoute === to.name) {
stopLoading()
loadingRoute = null
}
// When changing a page remove old:
// - error page
// - modal backdrop
$('#error-page,.modal-backdrop.show').remove()
$('body').css('padding', 0) // remove padding added by unclosed modal
})
const app = new Vue({
components: {
AppComponent,
MenuComponent,
},
i18n,
store,
router: window.router,
data() {
return {
isUser: !window.isAdmin && !window.isReseller,
appName: window.config['app.name'],
appUrl: window.config['app.url'],
themeDir: '/themes/' + window.config['app.theme']
}
},
methods: {
// Clear (bootstrap) form validation state
clearFormValidation(form) {
$(form).find('.is-invalid').removeClass('is-invalid')
$(form).find('.invalid-feedback').remove()
},
hasPermission(type) {
const authInfo = store.state.authInfo
const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1)
return !!(authInfo && authInfo.statusInfo[key])
},
hasRoute(name) {
return this.$router.resolve({ name: name }).resolved.matched.length > 0
},
hasSKU(name) {
const authInfo = store.state.authInfo
return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1
},
isController(wallet_id) {
if (wallet_id && store.state.authInfo) {
let i
for (i = 0; i < store.state.authInfo.wallets.length; i++) {
if (wallet_id == store.state.authInfo.wallets[i].id) {
return true
}
}
for (i = 0; i < store.state.authInfo.accounts.length; i++) {
if (wallet_id == store.state.authInfo.accounts[i].id) {
return true
}
}
}
return false
},
// Set user state to "logged in"
loginUser(response, dashboard, update) {
if (!update) {
store.commit('logoutUser') // destroy old state data
store.commit('loginUser')
}
localStorage.setItem('token', response.access_token)
axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token
if (response.email) {
store.state.authInfo = response
}
if (dashboard !== false) {
this.$router.push(store.state.afterLogin || { name: 'dashboard' })
}
store.state.afterLogin = null
// Refresh the token before it expires
let timeout = response.expires_in || 0
// We'll refresh 60 seconds before the token expires
if (timeout > 60) {
timeout -= 60
}
// TODO: We probably should try a few times in case of an error
// TODO: We probably should prevent axios from doing any requests
// while the token is being refreshed
this.refreshTimeout = setTimeout(() => {
axios.post('/api/auth/refresh').then(response => {
this.loginUser(response.data, false, true)
})
}, timeout * 1000)
},
// Set user state to "not logged in"
logoutUser(redirect) {
store.commit('logoutUser')
localStorage.setItem('token', '')
delete axios.defaults.headers.common.Authorization
if (redirect !== false) {
this.$router.push({ name: 'login' })
}
clearTimeout(this.refreshTimeout)
},
logo(mode) {
let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png'
return `<img src="${src}" alt="${this.appName}">`
},
// Display "loading" overlay inside of the specified element
addLoader(elem, small = true) {
$(elem).css({position: 'relative'}).append(small ? $(loader).addClass('small') : $(loader))
},
// Remove loader element added in addLoader()
removeLoader(elem) {
$(elem).find('.app-loader').remove()
},
startLoading,
stopLoading,
isLoading() {
return isLoading > 0
},
+ tab(e) {
+ e.preventDefault()
+ $(e.target).tab('show')
+ },
errorPage(code, msg, hint) {
// Until https://github.com/vuejs/vue-router/issues/977 is implemented
// we can't really use router to display error page as it has two side
// effects: it changes the URL and adds the error page to browser history.
// For now we'll be replacing current view with error page "manually".
if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown')
if (!hint) hint = ''
const error_page = '<div id="error-page" class="error-page">'
+ `<div class="code">${code}</div><div class="message">${msg}</div><div class="hint">${hint}</div>`
+ '</div>'
$('#error-page').remove()
$('#app').append(error_page)
app.updateBodyClass('error')
},
errorHandler(error) {
this.stopLoading()
if (!error.response) {
// TODO: probably network connection error
} else if (error.response.status === 401) {
// Remember requested route to come back to it after log in
if (this.$route.meta.requiresAuth) {
store.state.afterLogin = this.$route
this.logoutUser()
} else {
this.logoutUser(false)
}
} else {
this.errorPage(error.response.status, error.response.statusText)
}
},
downloadFile(url) {
// TODO: This might not be a best way for big files as the content
// will be stored (temporarily) in browser memory
// TODO: This method does not show the download progress in the browser
// but it could be implemented in the UI, axios has 'progress' property
axios.get(url, { responseType: 'blob' })
.then(response => {
const link = document.createElement('a')
const contentDisposition = response.headers['content-disposition']
let filename = 'unknown'
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match.length === 2) {
filename = match[1];
}
}
link.href = window.URL.createObjectURL(response.data)
link.download = filename
link.click()
})
},
price(price, currency) {
// TODO: Set locale argument according to the currently used locale
return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' })
},
priceLabel(cost, discount) {
let index = ''
if (discount) {
cost = Math.floor(cost * ((100 - discount) / 100))
index = '\u00B9'
}
return this.price(cost) + '/month' + index
},
clickRecord(event) {
if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) {
let link = $(event.target).closest('tr').find('a')[0]
if (link) {
link.click()
}
}
},
domainStatusClass(domain) {
if (domain.isDeleted) {
return 'text-muted'
}
if (domain.isSuspended) {
return 'text-warning'
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
return 'text-danger'
}
return 'text-success'
},
domainStatusText(domain) {
if (domain.isDeleted) {
return this.$t('status.deleted')
}
if (domain.isSuspended) {
return this.$t('status.suspended')
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
return this.$t('status.notready')
}
return this.$t('status.active')
},
distlistStatusClass(list) {
if (list.isDeleted) {
return 'text-muted'
}
if (list.isSuspended) {
return 'text-warning'
}
if (!list.isLdapReady) {
return 'text-danger'
}
return 'text-success'
},
distlistStatusText(list) {
if (list.isDeleted) {
return this.$t('status.deleted')
}
if (list.isSuspended) {
return this.$t('status.suspended')
}
if (!list.isLdapReady) {
return this.$t('status.notready')
}
return this.$t('status.active')
},
pageName(path) {
let page = this.$route.path
// check if it is a "menu page", find the page name
// otherwise we'll use the real path as page name
window.config.menu.every(item => {
if (item.location == page && item.page) {
page = item.page
return false
}
})
page = page.replace(/^\//, '')
return page ? page : '404'
},
supportDialog(container) {
let dialog = $('#support-dialog')
// FIXME: Find a nicer way of doing this
if (!dialog.length) {
SupportForm.i18n = i18n
let form = new Vue(SupportForm)
form.$mount($('<div>').appendTo(container)[0])
form.$root = this
form.$toast = this.$toast
dialog = $(form.$el)
}
dialog.on('shown.bs.modal', () => {
dialog.find('input').first().focus()
}).modal()
},
userStatusClass(user) {
if (user.isDeleted) {
return 'text-muted'
}
if (user.isSuspended) {
return 'text-warning'
}
if (!user.isImapReady || !user.isLdapReady) {
return 'text-danger'
}
return 'text-success'
},
userStatusText(user) {
if (user.isDeleted) {
return this.$t('status.deleted')
}
if (user.isSuspended) {
return this.$t('status.suspended')
}
if (!user.isImapReady || !user.isLdapReady) {
return this.$t('status.notready')
}
return this.$t('status.active')
},
updateBodyClass(name) {
// Add 'class' attribute to the body, different for each page
// so, we can apply page-specific styles
document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '')
}
}
})
// Fetch the locale file and the start the app
loadLangAsync().then(() => app.$mount('#app'))
// Add a axios request interceptor
window.axios.interceptors.request.use(
config => {
// This is the only way I found to change configuration options
// on a running application. We need this for browser testing.
config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider
return config
},
error => {
// Do something with request error
return Promise.reject(error)
}
)
// Add a axios response interceptor for general/validation error handler
window.axios.interceptors.response.use(
response => {
if (response.config.onFinish) {
response.config.onFinish()
}
return response
},
error => {
let error_msg
let status = error.response ? error.response.status : 200
// Do not display the error in a toast message, pass the error as-is
if (error.config.ignoreErrors) {
return Promise.reject(error)
}
if (error.config.onFinish) {
error.config.onFinish()
}
if (error.response && status == 422) {
error_msg = "Form validation error"
const modal = $('div.modal.show')
$(modal.length ? modal : 'form').each((i, form) => {
form = $(form)
$.each(error.response.data.errors || {}, (idx, msg) => {
const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx
let input = form.find('#' + input_name)
if (!input.length) {
input = form.find('[name="' + input_name + '"]');
}
if (input.length) {
// Create an error message
// API responses can use a string, array or object
let msg_text = ''
if ($.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
let controls = input.children(':not(:first-child)')
if (!controls.length && typeof msg == 'string') {
// this is an empty list (the main input only)
// and the error message is not an array
input.find('.main-input').addClass('is-invalid')
} else {
controls.each((index, element) => {
if (msg[index]) {
$(element).find('input').addClass('is-invalid')
}
})
}
input.addClass('is-invalid').next('.invalid-feedback').remove()
input.after(feedback)
}
else {
// Standard form element
input.addClass('is-invalid')
input.parent().find('.invalid-feedback').remove()
input.parent().append(feedback)
}
}
})
form.find('.is-invalid:not(.listinput-widget)').first().focus()
})
}
else if (error.response && error.response.data) {
error_msg = error.response.data.message
}
else {
error_msg = error.request ? error.request.statusText : error.message
}
app.$toast.error(error_msg || app.$t('error.server'))
// Pass the error as-is
return Promise.reject(error)
}
)
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
index 57aac0ba..51509bac 100644
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -1,78 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used in the application.
*/
'mandate-delete-success' => 'The auto-payment has been removed.',
'mandate-update-success' => 'The auto-payment has been updated.',
'planbutton' => 'Choose :plan',
'process-async' => 'Setup process has been pushed. Please wait.',
'process-user-new' => 'Registering a user...',
'process-user-ldap-ready' => 'Creating a user...',
'process-user-imap-ready' => 'Creating a mailbox...',
'process-distlist-new' => 'Registering a distribution list...',
'process-distlist-ldap-ready' => 'Creating a distribution list...',
'process-domain-new' => 'Registering a custom domain...',
'process-domain-ldap-ready' => 'Creating a custom domain...',
'process-domain-verified' => 'Verifying a custom domain...',
'process-domain-confirmed' => 'Verifying an ownership of a custom domain...',
'process-success' => 'Setup process finished successfully.',
'process-error-user-ldap-ready' => 'Failed to create a user.',
'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.',
'process-error-domain-ldap-ready' => 'Failed to create a domain.',
'process-error-domain-verified' => 'Failed to verify a domain.',
'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.',
'process-distlist-new' => 'Registering a distribution list...',
'process-distlist-ldap-ready' => 'Creating a distribution list...',
'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.',
'distlist-update-success' => 'Distribution list updated successfully.',
'distlist-create-success' => 'Distribution list created successfully.',
'distlist-delete-success' => 'Distribution list deleted successfully.',
'distlist-suspend-success' => 'Distribution list suspended successfully.',
'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.',
'domain-verify-success' => 'Domain verified successfully.',
'domain-verify-error' => 'Domain ownership verification failed.',
'domain-suspend-success' => 'Domain suspended successfully.',
'domain-unsuspend-success' => 'Domain unsuspended successfully.',
+ 'domain-setconfig-success' => 'Domain settings updated successfully.',
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
'user-delete-success' => 'User deleted successfully.',
'user-suspend-success' => 'User suspended successfully.',
'user-unsuspend-success' => 'User unsuspended successfully.',
'user-reset-2fa-success' => '2-Factor authentication reset successfully.',
+ 'user-setconfig-success' => 'User settings updated successfully.',
'search-foundxdomains' => ':x domains have been found.',
'search-foundxgroups' => ':x distribution lists have been found.',
'search-foundxusers' => ':x user accounts have been found.',
'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.',
'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.',
'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.',
'signup-invitation-delete-success' => 'Invitation deleted successfully.',
'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.',
'support-request-success' => 'Support request submitted successfully.',
'support-request-error' => 'Failed to submit the support request.',
'wallet-award-success' => 'The bonus has been added to the wallet successfully.',
'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.',
'wallet-update-success' => 'User wallet updated successfully.',
'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).',
'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.',
'wallet-notice-today' => 'You will run out of credit today, top up your balance now.',
'wallet-notice-trial' => 'You are in your free trial period.',
'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.',
];
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
index 4b9fecec..73ab2599 100644
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -1,407 +1,419 @@
<?php
/**
* This file will be converted to a Vue-i18n compatible JSON format on build time
*
* Note: The Laravel localization features do not work here. Vue-i18n rules are different
*/
return [
'app' => [
'faq' => "FAQ",
],
'btn' => [
'add' => "Add",
'accept' => "Accept",
'back' => "Back",
'cancel' => "Cancel",
'close' => "Close",
'continue' => "Continue",
'delete' => "Delete",
'deny' => "Deny",
'download' => "Download",
'edit' => "Edit",
'file' => "Choose file...",
'moreinfo' => "More information",
'refresh' => "Refresh",
'reset' => "Reset",
'resend' => "Resend",
'save' => "Save",
'search' => "Search",
'signup' => "Sign Up",
'submit' => "Submit",
'suspend' => "Suspend",
'unsuspend' => "Unsuspend",
'verify' => "Verify",
],
'dashboard' => [
'beta' => "beta",
'distlists' => "Distribution lists",
'chat' => "Video chat",
'domains' => "Domains",
'invitations' => "Invitations",
'profile' => "Your profile",
'users' => "User accounts",
'wallet' => "Wallet",
'webmail' => "Webmail",
'stats' => "Stats",
],
'distlist' => [
'list-title' => "Distribution list | Distribution lists",
'create' => "Create list",
'delete' => "Delete list",
'email' => "Email",
'list-empty' => "There are no distribution lists in this account.",
'new' => "New distribution list",
'recipients' => "Recipients",
],
'domain' => [
'dns-verify' => "Domain DNS verification sample:",
'dns-config' => "Domain DNS configuration sample:",
'namespace' => "Namespace",
+ 'spf-whitelist' => "SPF Whitelist",
+ 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, "
+ . "which systems are allowed to send emails with an envelope sender address within said domain.",
+ 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: <var>.ess.barracuda.com</var>.",
'verify' => "Domain verification",
'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.",
'verify-dns' => "The domain <b>must have one of the following entries</b> in DNS:",
'verify-dns-txt' => "TXT entry with value:",
'verify-dns-cname' => "or CNAME entry:",
'verify-outro' => "When this is done press the button below to start the verification.",
'verify-sample' => "Here's a sample zone file for your domain:",
'config' => "Domain configuration",
'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.",
'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:",
'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.",
],
'error' => [
'400' => "Bad request",
'401' => "Unauthorized",
'403' => "Access denied",
'404' => "Not found",
'405' => "Method not allowed",
'500' => "Internal server error",
'unknown' => "Unknown Error",
'server' => "Server Error",
],
'form' => [
'amount' => "Amount",
'code' => "Confirmation Code",
'config' => "Configuration",
'date' => "Date",
'description' => "Description",
'details' => "Details",
+ 'disabled' => "disabled",
'domain' => "Domain",
'email' => "Email Address",
+ 'enabled' => "enabled",
'firstname' => "First Name",
+ 'general' => "General",
'lastname' => "Last Name",
'none' => "none",
'or' => "or",
'password' => "Password",
'password-confirm' => "Confirm Password",
'phone' => "Phone",
+ 'settings' => "Settings",
'status' => "Status",
'surname' => "Surname",
'user' => "User",
'primary-email' => "Primary Email",
'id' => "ID",
'created' => "Created",
'deleted' => "Deleted",
],
'invitation' => [
'create' => "Create invite(s)",
'create-title' => "Invite for a signup",
'create-email' => "Enter an email address of the person you want to invite.",
'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.",
'empty-list' => "There are no invitations in the database.",
'title' => "Signup invitations",
'search' => "Email address or domain",
'send' => "Send invite(s)",
'status-completed' => "User signed up",
'status-failed' => "Sending failed",
'status-sent' => "Sent",
'status-new' => "Not sent yet",
],
'lang' => [
'en' => "English",
'de' => "German",
'fr' => "French",
'it' => "Italian",
],
'login' => [
'2fa' => "Second factor code",
'2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.",
'forgot_password' => "Forgot password?",
'header' => "Please sign in",
'sign_in' => "Sign in",
'webmail' => "Webmail"
],
'meet' => [
'title' => "Voice & Video Conferencing",
'welcome' => "Welcome to our beta program for Voice & Video Conferencing.",
'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.",
'notice' => "This is a work in progress and more features will be added over time. Current features include:",
'sharing' => "Screen Sharing",
'sharing-text' => "Share your screen for presentations or show-and-tell.",
'security' => "Room Security",
'security-text' => "Increase the room security by setting a password that attendees will need to know"
. " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.",
'qa' => "Raise Hand (Q&A)",
'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.",
'moderation' => "Moderator Delegation",
'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly"
. " interrupted with attendees knocking and other moderator duties.",
'eject' => "Eject Attendees",
'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy"
. " violations. Click the user icon for effective dismissal.",
'silent' => "Silent Audience Members",
'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.",
'interpreters' => "Language Specific Audio Channels",
'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions"
. " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.",
'beta-notice' => "Keep in mind that this is still in beta and might come with some issues."
. " Should you encounter any on your way, let us know by contacting support.",
// Room options dialog
'options' => "Room options",
'password' => "Password",
'password-none' => "none",
'password-clear' => "Clear password",
'password-set' => "Set password",
'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.",
'lock' => "Locked room",
'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.",
'nomedia' => "Subscribers only",
'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)."
. " Moderators will be able to promote them to publishers throughout the session.",
// Room menu
'partcnt' => "Number of participants",
'menu-audio-mute' => "Mute audio",
'menu-audio-unmute' => "Unmute audio",
'menu-video-mute' => "Mute video",
'menu-video-unmute' => "Unmute video",
'menu-screen' => "Share screen",
'menu-hand-lower' => "Lower hand",
'menu-hand-raise' => "Raise hand",
'menu-channel' => "Interpreted language channel",
'menu-chat' => "Chat",
'menu-fullscreen' => "Full screen",
'menu-fullscreen-exit' => "Exit full screen",
'menu-leave' => "Leave session",
// Room setup screen
'setup-title' => "Set up your session",
'mic' => "Microphone",
'cam' => "Camera",
'nick' => "Nickname",
'nick-placeholder' => "Your name",
'join' => "JOIN",
'joinnow' => "JOIN NOW",
'imaowner' => "I'm the owner",
// Room
'qa' => "Q & A",
'leave-title' => "Room closed",
'leave-body' => "The session has been closed by the room owner.",
'media-title' => "Media setup",
'join-request' => "Join request",
'join-requested' => "{user} requested to join.",
// Status messages
'status-init' => "Checking the room...",
'status-323' => "The room is closed. Please, wait for the owner to start the session.",
'status-324' => "The room is closed. It will be open for others after you join.",
'status-325' => "The room is ready. Please, provide a valid password.",
'status-326' => "The room is locked. Please, enter your name and try again.",
'status-327' => "Waiting for permission to join the room.",
'status-404' => "The room does not exist.",
'status-429' => "Too many requests. Please, wait.",
'status-500' => "Failed to connect to the room. Server error.",
// Other menus
'media-setup' => "Media setup",
'perm' => "Permissions",
'perm-av' => "Audio &amp; Video publishing",
'perm-mod' => "Moderation",
'lang-int' => "Language interpreter",
'menu-options' => "Options",
],
'menu' => [
'cockpit' => "Cockpit",
'login' => "Login",
'logout' => "Logout",
'signup' => "Signup",
'toggle' => "Toggle navigation",
],
'msg' => [
'initializing' => "Initializing...",
'loading' => "Loading...",
'loading-failed' => "Failed to load data.",
'notfound' => "Resource not found.",
'info' => "Information",
'error' => "Error",
'warning' => "Warning",
'success' => "Success",
],
'nav' => [
'more' => "Load more",
'step' => "Step {i}/{n}",
],
'password' => [
'reset' => "Password Reset",
'reset-step1' => "Enter your email address to reset your password.",
'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.",
'reset-step2' => "We sent out a confirmation code to your external email address."
. " Enter the code we sent you, or click the link in the message.",
],
'signup' => [
'email' => "Existing Email Address",
'login' => "Login",
'title' => "Sign Up",
'step1' => "Sign up to start your free month.",
'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.",
'step3' => "Create your Kolab identity (you can choose additional addresses later).",
'voucher' => "Voucher Code",
],
'status' => [
'prepare-account' => "We are preparing your account.",
'prepare-domain' => "We are preparing the domain.",
'prepare-distlist' => "We are preparing the distribution list.",
'prepare-user' => "We are preparing the user account.",
'prepare-hint' => "Some features may be missing or readonly at the moment.",
'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.",
'ready-account' => "Your account is almost ready.",
'ready-domain' => "The domain is almost ready.",
'ready-distlist' => "The distribution list is almost ready.",
'ready-user' => "The user account is almost ready.",
'verify' => "Verify your domain to finish the setup process.",
'verify-domain' => "Verify domain",
'deleted' => "Deleted",
'suspended' => "Suspended",
'notready' => "Not Ready",
'active' => "Active",
],
'support' => [
'title' => "Contact Support",
'id' => "Customer number or email address you have with us",
'id-pl' => "e.g. 12345678 or john@kolab.org",
'id-hint' => "Leave blank if you are not a customer yet",
'name' => "Name",
'name-pl' => "how we should call you in our reply",
'email' => "Working email address",
'email-pl' => "make sure we can reach you at this address",
'summary' => "Issue Summary",
'summary-pl' => "one sentence that summarizes your issue",
'expl' => "Issue Explanation",
],
'user' => [
'2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.",
'2fa-hint2' => "Please, make sure to confirm the user identity properly.",
'address' => "Address",
'aliases' => "Aliases",
'aliases-email' => "Email Aliases",
'aliases-none' => "This user has no email aliases.",
'add-bonus' => "Add bonus",
'add-bonus-title' => "Add a bonus to the wallet",
'add-penalty' => "Add penalty",
'add-penalty-title' => "Add a penalty to the wallet",
'auto-payment' => "Auto-payment",
'auto-payment-text' => "Fill up by <b>{amount} CHF</b> when under <b>{balance} CHF</b> using {method}",
'country' => "Country",
'create' => "Create user",
'custno' => "Customer No.",
'delete' => "Delete user",
'delete-email' => "Delete {email}",
'delete-text' => "Do you really want to delete this user permanently?"
. " This will delete all account data and withdraw the permission to access the email account."
. " Please note that this action cannot be undone.",
'discount' => "Discount",
'discount-hint' => "applied discount",
'discount-title' => "Account discount",
'distlists' => "Distribution lists",
'distlists-none' => "There are no distribution lists in this account.",
'domains' => "Domains",
'domains-none' => "There are no domains in this account.",
'ext-email' => "External Email",
'finances' => "Finances",
+ 'greylisting' => "Greylisting",
+ 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender "
+ . "is temporarily rejected. The originating server should try again after a delay. "
+ . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.",
'list-title' => "User accounts",
'managed-by' => "Managed by",
'new' => "New user account",
'org' => "Organization",
'package' => "Package",
'price' => "Price",
'profile-title' => "Your profile",
'profile-delete' => "Delete account",
'profile-delete-title' => "Delete this account?",
'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.",
'profile-delete-warning' => "This operation is irreversible",
'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.",
'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. "
. "The best tool for improvement is feedback from users, and we would like to ask "
. "for a few words about your reasons for leaving our service. Please send your feedback to <a href=\"{href}\">{email}</a>.",
'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.",
'reset-2fa' => "Reset 2-Factor Auth",
'reset-2fa-title' => "2-Factor Authentication Reset",
'title' => "User account",
'search-pl' => "User ID, email or domain",
'skureq' => "{sku} requires {list}.",
'subscription' => "Subscription",
'subscriptions' => "Subscriptions",
'subscriptions-none' => "This user has no subscriptions.",
'users' => "Users",
'users-none' => "There are no users in this account.",
],
'wallet' => [
'add-credit' => "Add credit",
'auto-payment-cancel' => "Cancel auto-payment",
'auto-payment-change' => "Change auto-payment",
'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.",
'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose."
. " You can cancel or change the auto-payment option at any time.",
'auto-payment-setup' => "Set up auto-payment",
'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.",
'auto-payment-info' => "Auto-payment is <b>set</b> to fill up your account by <b>{amount} CHF</b> every time your account balance gets under <b>{balance} CHF</b>.",
'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.",
'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.",
'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.",
'auto-payment-update' => "Update auto-payment",
'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.",
'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}."
. " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.",
'fill-up' => "Fill up by",
'history' => "History",
'noperm' => "Only account owners can access a wallet.",
'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.",
'payment-method' => "Method of payment: {method}",
'payment-warning' => "You will be charged for {price}.",
'pending-payments' => "Pending Payments",
'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.",
'pending-payments-none' => "There are no pending payments for this account.",
'receipts' => "Receipts",
'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.",
'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.",
'title' => "Account balance",
'top-up' => "Top up your wallet",
'transactions' => "Transactions",
'transactions-none' => "There are no transactions for this account.",
'when-below' => "when account balance is below",
],
];
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
index 0e11d2d0..b61d95db 100644
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -1,171 +1,173 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => 'The :attribute must be accepted.',
'active_url' => 'The :attribute is not a valid URL.',
'after' => 'The :attribute must be a date after :date.',
'after_or_equal' => 'The :attribute must be a date after or equal to :date.',
'alpha' => 'The :attribute may only contain letters.',
'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.',
'alpha_num' => 'The :attribute may only contain letters and numbers.',
'array' => 'The :attribute must be an array.',
'before' => 'The :attribute must be a date before :date.',
'before_or_equal' => 'The :attribute must be a date before or equal to :date.',
'between' => [
'numeric' => 'The :attribute must be between :min and :max.',
'file' => 'The :attribute must be between :min and :max kilobytes.',
'string' => 'The :attribute must be between :min and :max characters.',
'array' => 'The :attribute must have between :min and :max items.',
],
'boolean' => 'The :attribute field must be true or false.',
'confirmed' => 'The :attribute confirmation does not match.',
'date' => 'The :attribute is not a valid date.',
'date_equals' => 'The :attribute must be a date equal to :date.',
'date_format' => 'The :attribute does not match the format :format.',
'different' => 'The :attribute and :other must be different.',
'digits' => 'The :attribute must be :digits digits.',
'digits_between' => 'The :attribute must be between :min and :max digits.',
'dimensions' => 'The :attribute has invalid image dimensions.',
'distinct' => 'The :attribute field has a duplicate value.',
'email' => 'The :attribute must be a valid email address.',
'ends_with' => 'The :attribute must end with one of the following: :values',
'exists' => 'The selected :attribute is invalid.',
'file' => 'The :attribute must be a file.',
'filled' => 'The :attribute field must have a value.',
'gt' => [
'numeric' => 'The :attribute must be greater than :value.',
'file' => 'The :attribute must be greater than :value kilobytes.',
'string' => 'The :attribute must be greater than :value characters.',
'array' => 'The :attribute must have more than :value items.',
],
'gte' => [
'numeric' => 'The :attribute must be greater than or equal :value.',
'file' => 'The :attribute must be greater than or equal :value kilobytes.',
'string' => 'The :attribute must be greater than or equal :value characters.',
'array' => 'The :attribute must have :value items or more.',
],
'image' => 'The :attribute must be an image.',
'in' => 'The selected :attribute is invalid.',
'in_array' => 'The :attribute field does not exist in :other.',
'integer' => 'The :attribute must be an integer.',
'ip' => 'The :attribute must be a valid IP address.',
'ipv4' => 'The :attribute must be a valid IPv4 address.',
'ipv6' => 'The :attribute must be a valid IPv6 address.',
'json' => 'The :attribute must be a valid JSON string.',
'lt' => [
'numeric' => 'The :attribute must be less than :value.',
'file' => 'The :attribute must be less than :value kilobytes.',
'string' => 'The :attribute must be less than :value characters.',
'array' => 'The :attribute must have less than :value items.',
],
'lte' => [
'numeric' => 'The :attribute must be less than or equal :value.',
'file' => 'The :attribute must be less than or equal :value kilobytes.',
'string' => 'The :attribute must be less than or equal :value characters.',
'array' => 'The :attribute must not have more than :value items.',
],
'max' => [
'numeric' => 'The :attribute may not be greater than :max.',
'file' => 'The :attribute may not be greater than :max kilobytes.',
'string' => 'The :attribute may not be greater than :max characters.',
'array' => 'The :attribute may not have more than :max items.',
],
'mimes' => 'The :attribute must be a file of type: :values.',
'mimetypes' => 'The :attribute must be a file of type: :values.',
'min' => [
'numeric' => 'The :attribute must be at least :min.',
'file' => 'The :attribute must be at least :min kilobytes.',
'string' => 'The :attribute must be at least :min characters.',
'array' => 'The :attribute must have at least :min items.',
],
'not_in' => 'The selected :attribute is invalid.',
'not_regex' => 'The :attribute format is invalid.',
'numeric' => 'The :attribute must be a number.',
'present' => 'The :attribute field must be present.',
'regex' => 'The :attribute format is invalid.',
'required' => 'The :attribute field is required.',
'required_if' => 'The :attribute field is required when :other is :value.',
'required_unless' => 'The :attribute field is required unless :other is in :values.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_with_all' => 'The :attribute field is required when :values are present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute and :other must match.',
'size' => [
'numeric' => 'The :attribute must be :size.',
'file' => 'The :attribute must be :size kilobytes.',
'string' => 'The :attribute must be :size characters.',
'array' => 'The :attribute must contain :size items.',
],
'starts_with' => 'The :attribute must start with one of the following: :values',
'string' => 'The :attribute must be a string.',
'timezone' => 'The :attribute must be a valid zone.',
'unique' => 'The :attribute has already been taken.',
'uploaded' => 'The :attribute failed to upload.',
'url' => 'The :attribute format is invalid.',
'uuid' => 'The :attribute must be a valid UUID.',
'2fareq' => 'Second factor code is required.',
'2fainvalid' => 'Second factor code is invalid.',
'emailinvalid' => 'The specified email address is invalid.',
'domaininvalid' => 'The specified domain is invalid.',
'domainnotavailable' => 'The specified domain is not available.',
'logininvalid' => 'The specified login is invalid.',
'loginexists' => 'The specified login is not available.',
'domainexists' => 'The specified domain is not available.',
'noemailorphone' => 'The specified text is neither a valid email address nor a phone number.',
'packageinvalid' => 'Invalid package selected.',
'packagerequired' => 'Package is required.',
'usernotexists' => 'Unable to find user.',
'voucherinvalid' => 'The voucher code is invalid or expired.',
'noextemail' => 'This user has no external email address.',
'entryinvalid' => 'The specified :attribute is invalid.',
'entryexists' => 'The specified :attribute is not available.',
'minamount' => 'Minimum amount for a single payment is :amount.',
'minamountdebt' => 'The specified amount does not cover the balance on the account.',
'notalocaluser' => 'The specified email address does not exist.',
'memberislist' => 'A recipient cannot be the same as the list address.',
'listmembersrequired' => 'At least one recipient is required.',
+ 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.',
+ 'invalid-config-parameter' => 'The requested configuration parameter is not supported.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap our attribute placeholder
| with something more reader friendly such as "E-Mail Address" instead
| of "email". This simply helps us make our message more expressive.
|
*/
'attributes' => [],
];
diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss
index 61bfaaf6..29968ff6 100644
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -1,471 +1,451 @@
html,
body,
body > .outer-container {
height: 100%;
}
#app {
display: flex;
flex-direction: column;
min-height: 100%;
overflow: hidden;
& > nav {
flex-shrink: 0;
z-index: 12;
}
& > div.container {
flex-grow: 1;
margin-top: 2rem;
margin-bottom: 2rem;
}
& > .filler {
flex-grow: 1;
}
& > div.container + .filler {
display: none;
}
}
.error-page {
position: absolute;
top: 0;
height: 100%;
width: 100%;
align-content: center;
align-items: center;
display: flex;
flex-wrap: wrap;
justify-content: center;
color: #636b6f;
z-index: 10;
background: white;
.code {
text-align: right;
border-right: 2px solid;
font-size: 26px;
padding: 0 15px;
}
.message {
font-size: 18px;
padding: 0 15px;
}
.hint {
margin-top: 3em;
text-align: center;
width: 100%;
}
}
.app-loader {
background-color: $body-bg;
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 8;
.spinner-border {
width: 120px;
height: 120px;
border-width: 15px;
color: #b2aa99;
}
&.small .spinner-border {
width: 25px;
height: 25px;
border-width: 3px;
}
&.fadeOut {
visibility: hidden;
opacity: 0;
transition: visibility 300ms linear, opacity 300ms linear;
}
}
pre {
margin: 1rem 0;
padding: 1rem;
background-color: $menu-bg-color;
}
.card-title {
font-size: 1.2rem;
font-weight: bold;
}
tfoot.table-fake-body {
background-color: #f8f8f8;
color: grey;
text-align: center;
td {
vertical-align: middle;
height: 8em;
border: 0;
}
tbody:not(:empty) + & {
display: none;
}
}
table {
th {
white-space: nowrap;
}
td.email,
td.price,
td.datetime,
td.selection {
width: 1%;
white-space: nowrap;
}
td.buttons,
th.price,
td.price {
width: 1%;
text-align: right;
white-space: nowrap;
}
&.form-list {
margin: 0;
td {
border: 0;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
button {
line-height: 1;
}
}
.btn-action {
line-height: 1;
padding: 0;
}
}
.list-details {
min-height: 1em;
& > ul {
margin: 0;
padding-left: 1.2em;
}
}
.plan-selector {
.plan-header {
display: flex;
}
.plan-ico {
margin:auto;
font-size: 3.8rem;
color: #f1a539;
border: 3px solid #f1a539;
width: 6rem;
height: 6rem;
border-radius: 50%;
}
}
.status-message {
display: flex;
align-items: center;
justify-content: center;
.app-loader {
width: auto;
position: initial;
.spinner-border {
color: $body-color;
}
}
svg {
font-size: 1.5em;
}
:first-child {
margin-right: 0.4em;
}
}
.form-separator {
position: relative;
margin: 1em 0;
display: flex;
justify-content: center;
hr {
border-color: #999;
margin: 0;
position: absolute;
top: 0.75em;
width: 100%;
}
span {
background: #fff;
padding: 0 1em;
z-index: 1;
}
}
#status-box {
background-color: lighten($green, 35);
.progress {
background-color: #fff;
height: 10px;
}
.progress-label {
font-size: 0.9em;
}
.progress-bar {
background-color: $green;
}
&.process-failed {
background-color: lighten($orange, 30);
.progress-bar {
background-color: $red;
}
}
}
@keyframes blinker {
50% {
opacity: 0;
}
}
.blinker {
animation: blinker 750ms step-start infinite;
}
#dashboard-nav {
display: flex;
flex-wrap: wrap;
justify-content: center;
& > a {
padding: 1rem;
text-align: center;
white-space: nowrap;
margin: 0.25rem;
text-decoration: none;
width: 150px;
&.disabled {
pointer-events: none;
opacity: 0.6;
}
// Some icons are too big, scale them down
&.link-invitations {
svg {
transform: scale(0.9);
}
}
.badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
}
svg {
width: 6rem;
height: 6rem;
margin: auto;
}
}
#payment-method-selection {
display: flex;
flex-wrap: wrap;
justify-content: center;
& > a {
padding: 1rem;
text-align: center;
white-space: nowrap;
margin: 0.25rem;
text-decoration: none;
width: 150px;
}
svg {
width: 6rem;
height: 6rem;
margin: auto;
}
}
#logon-form {
flex-basis: auto; // Bootstrap issue? See logon page with width < 992
}
#logon-form-footer {
a:not(:first-child) {
margin-left: 2em;
}
}
// Various improvements for mobile
@include media-breakpoint-down(sm) {
.card,
.card-footer {
border: 0;
}
.card-body {
padding: 0.5rem 0;
}
- .form-group {
- margin-bottom: 0.5rem;
- }
-
.nav-tabs {
flex-wrap: nowrap;
- overflow-x: auto;
.nav-link {
white-space: nowrap;
padding: 0.5rem 0.75rem;
}
}
- .tab-content {
- margin-top: 0.5rem;
- }
-
- .col-form-label {
- color: #666;
- font-size: 95%;
- }
-
- .form-group.plaintext .col-form-label {
- padding-bottom: 0;
- }
-
- form.read-only.short label {
- width: 35%;
-
- & + * {
- width: 65%;
- }
- }
-
#app > div.container {
margin-bottom: 1rem;
margin-top: 1rem;
max-width: 100%;
}
#header-menu-navbar {
padding: 0;
}
#dashboard-nav > a {
width: 135px;
}
.table-sm:not(.form-list) {
tbody td {
padding: 0.75rem 0.5rem;
svg {
vertical-align: -0.175em;
}
& > svg {
font-size: 125%;
margin-right: 0.25rem;
}
}
}
.table.transactions {
thead {
display: none;
}
tbody {
tr {
position: relative;
display: flex;
flex-wrap: wrap;
}
td {
width: auto;
border: 0;
padding: 0.5rem;
&.datetime {
width: 50%;
padding-left: 0;
}
&.description {
order: 3;
width: 100%;
border-bottom: 1px solid $border-color;
color: $secondary;
padding: 0 1.5em 0.5rem 0;
margin-top: -0.25em;
}
&.selection {
position: absolute;
right: 0;
border: 0;
top: 1.7em;
padding-right: 0;
}
&.price {
width: 50%;
padding-right: 0;
}
&.email {
display: none;
}
}
}
}
}
+
+@include media-breakpoint-down(sm) {
+ .tab-pane > .card-body {
+ padding: 0.5rem;
+ }
+}
diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss
index f8bd1d75..0f755350 100644
--- a/src/resources/themes/forms.scss
+++ b/src/resources/themes/forms.scss
@@ -1,77 +1,122 @@
.list-input {
& > div {
&:not(:last-child) {
margin-bottom: -1px;
input,
a.btn {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
}
&:not(:first-child) {
input,
a.btn {
border-top-right-radius: 0;
border-top-left-radius: 0;
}
}
}
input.is-invalid {
z-index: 2;
}
.btn svg {
vertical-align: middle;
}
}
.range-input {
display: flex;
label {
margin-right: 0.5em;
}
}
.input-group-activable {
&.active {
:not(.input-group-append):not(.activable) {
display: none;
}
}
&:not(.active) {
.activable {
display: none;
}
}
// Label is always visible
.label {
color: $body-color;
display: initial !important;
}
.input-group-text {
border-color: transparent;
background: transparent;
padding-left: 0;
&:not(.label) {
flex: 1;
}
}
}
.form-control-plaintext .btn-sm {
margin-top: -0.25rem;
}
form.read-only {
.row {
margin-bottom: 0;
}
}
+
+// Various improvements for mobile
+@include media-breakpoint-down(sm) {
+ .form-group {
+ margin-bottom: 0.5rem;
+ }
+
+ .form-group.plaintext .col-form-label {
+ padding-bottom: 0;
+ }
+
+ form.read-only.short label {
+ width: 35%;
+
+ & + * {
+ width: 65%;
+ }
+ }
+}
+
+@include media-breakpoint-down(xs) {
+ .col-form-label {
+ color: #666;
+ font-size: 95%;
+ }
+
+ .form-group.checkbox {
+ position: relative;
+
+ & > div {
+ position: initial;
+ padding-top: 0 !important;
+
+ input {
+ position: absolute;
+ top: 0.5rem;
+ right: 1rem;
+ }
+ }
+
+ label {
+ padding-right: 2.5rem;
+ }
+ }
+}
diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue
index c50d65a6..755b4a95 100644
--- a/src/resources/vue/Admin/Domain.vue
+++ b/src/resources/vue/Admin/Domain.vue
@@ -1,97 +1,118 @@
<template>
<div v-if="domain" class="container">
<div class="card" id="domain-info">
<div class="card-body">
<div class="card-title">{{ domain.namespace }}</div>
<div class="card-text">
<form class="read-only short">
<div class="form-group row">
<label for="domainid" class="col-sm-4 col-form-label">
{{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span>
</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="domainid">
{{ domain.id }} <span class="text-muted">({{ domain.created_at }})</span>
</span>
</div>
</div>
<div class="form-group row">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="status">
<span :class="$root.domainStatusClass(domain)">{{ $root.domainStatusText(domain) }}</span>
</span>
</div>
</div>
</form>
<div class="mt-2">
<button v-if="!domain.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendDomain">
{{ $t('btn.suspend') }}
</button>
<button v-if="domain.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendDomain">
{{ $t('btn.unsuspend') }}
</button>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
- <a class="nav-link active" id="tab-config" href="#domain-config" role="tab" aria-controls="domain-config" aria-selected="true">
+ <a class="nav-link active" id="tab-config" href="#domain-config" role="tab" aria-controls="domain-config" aria-selected="true" @click="$root.tab">
{{ $t('form.config') }}
</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#domain-settings" role="tab" aria-controls="domain-settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="domain-config" role="tabpanel" aria-labelledby="tab-config">
<div class="card-body">
<div class="card-text">
<p>{{ $t('domain.dns-verify') }}</p>
<p><pre id="dns-verify">{{ domain.dns.join("\n") }}</pre></p>
<p>{{ $t('domain.dns-config') }}</p>
- <p><pre id="dns-config">{{ domain.config.join("\n") }}</pre></p>
+ <p><pre id="dns-config">{{ domain.mx.join("\n") }}</pre></p>
+ </div>
+ </div>
+ </div>
+ <div class="tab-pane" id="domain-settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="form-group row plaintext">
+ <label for="spf_whitelist" class="col-sm-4 col-form-label">{{ $t('domain.spf-whitelist') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="spf_whitelist">
+ {{ domain.config && domain.config.spf_whitelist.length ? domain.config.spf_whitelist.join(', ') : 'none' }}
+ </span>
+ </div>
+ </div>
+ </form>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
domain: null
}
},
created() {
const domain_id = this.$route.params.domain;
axios.get('/api/v4/domains/' + domain_id)
.then(response => {
this.domain = response.data
})
.catch(this.$root.errorHandler)
},
methods: {
suspendDomain() {
axios.post('/api/v4/domains/' + this.domain.id + '/suspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.domain = Object.assign({}, this.domain, { isSuspended: true })
}
})
},
unsuspendDomain() {
axios.post('/api/v4/domains/' + this.domain.id + '/unsuspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.domain = Object.assign({}, this.domain, { isSuspended: false })
}
})
}
}
}
</script>
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
index 4de58e1e..ded084e5 100644
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -1,705 +1,725 @@
<template>
<div class="container">
<div class="card" id="user-info">
<div class="card-body">
<h1 class="card-title">{{ user.email }}</h1>
<div class="card-text">
<form class="read-only short">
<div v-if="user.wallet.user_id != user.id" class="form-group row plaintext">
<label for="manager" class="col-sm-4 col-form-label">{{ $t('user.managed-by') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="manager">
<router-link :to="{ path: '/user/' + user.wallet.user_id }">{{ user.wallet.user_email }}</router-link>
</span>
</div>
</div>
<div class="form-group row plaintext">
<label for="userid" class="col-sm-4 col-form-label">ID <span class="text-muted">({{ $t('form.created') }})</span></label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="userid">
{{ user.id }} <span class="text-muted">({{ user.created_at }})</span>
</span>
</div>
</div>
<div class="form-group row plaintext">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="status">
<span :class="$root.userStatusClass(user)">{{ $root.userStatusText(user) }}</span>
</span>
</div>
</div>
<div class="form-group row plaintext" v-if="user.first_name">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="first_name">{{ user.first_name }}</span>
</div>
</div>
<div class="form-group row plaintext" v-if="user.last_name">
<label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="last_name">{{ user.last_name }}</span>
</div>
</div>
<div class="form-group row plaintext" v-if="user.organization">
<label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="organization">{{ user.organization }}</span>
</div>
</div>
<div class="form-group row plaintext" v-if="user.phone">
<label for="phone" class="col-sm-4 col-form-label">{{ $t('form.phone') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="phone">{{ user.phone }}</span>
</div>
</div>
<div class="form-group row plaintext">
<label for="external_email" class="col-sm-4 col-form-label">{{ $t('user.ext-email') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="external_email">
<a v-if="user.external_email" :href="'mailto:' + user.external_email">{{ user.external_email }}</a>
<button type="button" class="btn btn-secondary btn-sm" @click="emailEdit">{{ $t('btn.edit') }}</button>
</span>
</div>
</div>
<div class="form-group row plaintext" v-if="user.billing_address">
<label for="billing_address" class="col-sm-4 col-form-label">{{ $t('user.address') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" style="white-space:pre" id="billing_address">{{ user.billing_address }}</span>
</div>
</div>
<div class="form-group row plaintext">
<label for="country" class="col-sm-4 col-form-label">{{ $t('user.country') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="country">{{ user.country }}</span>
</div>
</div>
</form>
<div class="mt-2">
<button v-if="!user.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendUser">
{{ $t('btn.suspend') }}
</button>
<button v-if="user.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendUser">
{{ $t('btn.unsuspend') }}
</button>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-finances" href="#user-finances" role="tab" aria-controls="user-finances" aria-selected="true">
{{ $t('user.finances') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-aliases" href="#user-aliases" role="tab" aria-controls="user-aliases" aria-selected="false">
{{ $t('user.aliases') }} ({{ user.aliases.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-subscriptions" href="#user-subscriptions" role="tab" aria-controls="user-subscriptions" aria-selected="false">
{{ $t('user.subscriptions') }} ({{ skus.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-domains" href="#user-domains" role="tab" aria-controls="user-domains" aria-selected="false">
{{ $t('user.domains') }} ({{ domains.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-users" href="#user-users" role="tab" aria-controls="user-users" aria-selected="false">
{{ $t('user.users') }} ({{ users.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-distlists" href="#user-distlists" role="tab" aria-controls="user-distlists" aria-selected="false">
{{ $t('user.distlists') }} ({{ distlists.length }})
</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#user-settings" role="tab" aria-controls="user-settings" aria-selected="false">
+ Settings
+ </a>
+ </li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="user-finances" role="tabpanel" aria-labelledby="tab-finances">
<div class="card-body">
<h2 class="card-title">
{{ $t('wallet.title') }}
<span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(wallet.balance) }}</strong></span>
</h2>
<div class="card-text">
<form class="read-only short">
<div class="form-group row">
<label class="col-sm-4 col-form-label">{{ $t('user.discount') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="discount">
<span>{{ wallet.discount ? (wallet.discount + '% - ' + wallet.discount_description) : 'none' }}</span>
<button type="button" class="btn btn-secondary btn-sm" @click="discountEdit">{{ $t('btn.edit') }}</button>
</span>
</div>
</div>
<div class="form-group row" v-if="wallet.mandate && wallet.mandate.id">
<label class="col-sm-4 col-form-label">{{ $t('user.auto-payment') }}</label>
<div class="col-sm-8">
<span id="autopayment" :class="'form-control-plaintext' + (wallet.mandateState ? ' text-danger' : '')"
v-html="$t('user.auto-payment-text', {
amount: wallet.mandate.amount,
balance: wallet.mandate.balance,
method: wallet.mandate.method
})"
>
<span v-if="wallet.mandateState">({{ wallet.mandateState }})</span>.
</span>
</div>
</div>
<div class="form-group row" v-if="wallet.providerLink">
<label class="col-sm-4 col-form-label">{{ capitalize(wallet.provider) }} {{ $t('form.id') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" v-html="wallet.providerLink"></span>
</div>
</div>
</form>
<div class="mt-2">
<button id="button-award" class="btn btn-success" type="button" @click="awardDialog">{{ $t('user.add-bonus') }}</button>
<button id="button-penalty" class="btn btn-danger" type="button" @click="penalizeDialog">{{ $t('user.add-penalty') }}</button>
</div>
</div>
<h2 class="card-title mt-4">{{ $t('wallet.transactions') }}</h2>
<transaction-log v-if="wallet.id && !walletReload" class="card-text" :wallet-id="wallet.id" :is-admin="true"></transaction-log>
</div>
</div>
<div class="tab-pane" id="user-aliases" role="tabpanel" aria-labelledby="tab-aliases">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
<th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(alias, index) in user.aliases" :id="'alias' + index" :key="index">
<td>{{ alias }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.aliases-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-subscriptions" role="tabpanel" aria-labelledby="tab-subscriptions">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead class="thead-light">
<tr>
<th scope="col">{{ $t('user.subscription') }}</th>
<th scope="col">{{ $t('user.price') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(sku, sku_id) in skus" :id="'sku' + sku.id" :key="sku_id">
<td>{{ sku.name }}</td>
<td>{{ sku.price }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td colspan="2">{{ $t('user.subscriptions-none') }}</td>
</tr>
</tfoot>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
&sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
<div class="mt-2">
<button type="button" class="btn btn-danger" id="reset2fa" v-if="has2FA" @click="reset2FADialog">
{{ $t('user.reset-2fa') }}
</button>
</div>
</div>
</div>
</div>
<div class="tab-pane" id="user-domains" role="tabpanel" aria-labelledby="tab-domains">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
<th scope="col">{{ $t('domain.namespace') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="domain in domains" :id="'domain' + domain.id" :key="domain.id" @click="$root.clickRecord">
<td>
<svg-icon icon="globe" :class="$root.domainStatusClass(domain)" :title="$root.domainStatusText(domain)"></svg-icon>
<router-link :to="{ path: '/domain/' + domain.id }">{{ domain.namespace }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.domains-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-users" role="tabpanel" aria-labelledby="tab-users">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
<th scope="col">{{ $t('form.primary-email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in users" :id="'user' + item.id" :key="item.id" @click="$root.clickRecord">
<td>
<svg-icon icon="user" :class="$root.userStatusClass(item)" :title="$root.userStatusText(item)"></svg-icon>
<router-link v-if="item.id != user.id" :to="{ path: '/user/' + item.id }">{{ item.email }}</router-link>
<span v-else>{{ item.email }}</span>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.users-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-distlists" role="tabpanel" aria-labelledby="tab-distlists">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
<th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="list in distlists" :key="list.id" @click="$root.clickRecord">
<td>
<svg-icon icon="users" :class="$root.distlistStatusClass(list)" :title="$root.distlistStatusText(list)"></svg-icon>
<router-link :to="{ path: '/distlist/' + list.id }">{{ list.email }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.distlists-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
+ <div class="tab-pane" id="user-settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="form-group row plaintext">
+ <label for="greylisting" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="greylisting">
+ <span v-if="user.config.greylisting" class="text-success">{{ $t('form.enabled') }}</span>
+ <span v-else class="text-danger">{{ $t('form.disabled') }}</span>
+ </span>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
</div>
<div id="discount-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.discount-title') }}</h5>
<button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p class="form-group">
<select v-model="wallet.discount_id" class="custom-select">
<option value="">- {{ $t('form.none') }} -</option>
<option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option>
</select>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="submitDiscount()">
<svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
</div>
</div>
</div>
</div>
<div id="email-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.ext-email') }}</h5>
<button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p class="form-group">
<input v-model="external_email" name="external_email" class="form-control">
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="submitEmail()">
<svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
</div>
</div>
</div>
</div>
<div id="oneoff-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t(oneoff_negative ? 'user.add-penalty-title' : 'user.add-bonus-title') }}</h5>
<button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form data-validation-prefix="oneoff_">
<div class="form-group">
<label for="oneoff_amount" class="col-form-label">{{ $t('form.amount') }}</label>
<div class="input-group">
<input type="text" class="form-control" id="oneoff_amount" v-model="oneoff_amount" required>
<span class="input-group-append">
<span class="input-group-text">{{ oneoff_currency }}</span>
</span>
</div>
</div>
<div class="form-group">
<label for="oneoff_description" class="col-form-label">{{ $t('form.description') }}</label>
<input class="form-control" id="oneoff_description" v-model="oneoff_description" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="submitOneOff()">
<svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
</div>
</div>
</div>
</div>
<div id="reset-2fa-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.reset-2fa-title') }}</h5>
<button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>{{ $t('user.2fa-hint1') }}</p>
<p>{{ $t('user.2fa-hint2') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-danger modal-action" @click="reset2FA()">{{ $t('btn.reset') }}</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import TransactionLog from '../Widgets/TransactionLog'
export default {
components: {
TransactionLog
},
beforeRouteUpdate (to, from, next) {
// An event called when the route that renders this component has changed,
// but this component is reused in the new route.
// Required to handle links from /user/XXX to /user/YYY
next()
this.$parent.routerReload()
},
data() {
return {
oneoff_amount: '',
oneoff_currency: 'CHF',
oneoff_description: '',
oneoff_negative: false,
discount: 0,
discount_description: '',
discounts: [],
external_email: '',
has2FA: false,
wallet: {},
walletReload: false,
distlists: [],
domains: [],
skus: [],
sku2FA: null,
users: [],
user: {
aliases: [],
+ config: {},
wallet: {},
skus: {},
}
}
},
created() {
const user_id = this.$route.params.user
this.$root.startLoading()
axios.get('/api/v4/users/' + user_id)
.then(response => {
this.$root.stopLoading()
this.user = response.data
const financesTab = '#user-finances'
const keys = ['first_name', 'last_name', 'external_email', 'billing_address', 'phone', 'organization']
let country = this.user.settings.country
if (country && country in window.config.countries) {
country = window.config.countries[country][1]
}
this.user.country = country
keys.forEach(key => { this.user[key] = this.user.settings[key] })
this.discount = this.user.wallet.discount
this.discount_description = this.user.wallet.discount_description
// TODO: currencies, multi-wallets, accounts
// Get more info about the wallet (e.g. payment provider related)
this.$root.addLoader(financesTab)
axios.get('/api/v4/wallets/' + this.user.wallets[0].id)
.then(response => {
this.$root.removeLoader(financesTab)
this.wallet = response.data
this.setMandateState()
})
.catch(error => {
this.$root.removeLoader(financesTab)
})
// Create subscriptions list
axios.get('/api/v4/users/' + user_id + '/skus')
.then(response => {
// "merge" SKUs with user entitlement-SKUs
response.data.forEach(sku => {
const userSku = this.user.skus[sku.id]
if (userSku) {
let cost = userSku.costs.reduce((sum, current) => sum + current)
let item = {
id: sku.id,
name: sku.name,
cost: cost,
price: this.$root.priceLabel(cost, this.discount)
}
if (sku.range) {
item.name += ' ' + userSku.count + ' ' + sku.range.unit
}
this.skus.push(item)
if (sku.handler == 'auth2f') {
this.has2FA = true
this.sku2FA = sku.id
}
}
})
})
// Fetch users
// TODO: Multiple wallets
axios.get('/api/v4/users?owner=' + user_id)
.then(response => {
this.users = response.data.list;
})
// Fetch domains
axios.get('/api/v4/domains?owner=' + user_id)
.then(response => {
this.domains = response.data.list
})
// Fetch distribution lists
axios.get('/api/v4/groups?owner=' + user_id)
.then(response => {
this.distlists = response.data.list
})
})
.catch(this.$root.errorHandler)
},
mounted() {
- $(this.$el).find('ul.nav-tabs a').on('click', e => {
- e.preventDefault()
- $(e.target).tab('show')
- })
+ $(this.$el).find('ul.nav-tabs a').on('click', this.$root.tab)
},
methods: {
capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
awardDialog() {
this.oneOffDialog(false)
},
discountEdit() {
$('#discount-dialog')
.on('shown.bs.modal', e => {
$(e.target).find('select').focus()
// Note: Vue v-model is strict, convert null to a string
this.wallet.discount_id = this.wallet_discount_id || ''
})
.modal()
if (!this.discounts.length) {
// Fetch discounts
axios.get('/api/v4/users/' + this.user.id + '/discounts')
.then(response => {
this.discounts = response.data.list
})
}
},
emailEdit() {
this.external_email = this.user.external_email
this.$root.clearFormValidation($('#email-dialog'))
$('#email-dialog')
.on('shown.bs.modal', e => {
$(e.target).find('input').focus()
})
.modal()
},
setMandateState() {
let mandate = this.wallet.mandate
if (mandate && mandate.id) {
if (!mandate.isValid) {
this.wallet.mandateState = mandate.isPending ? 'pending' : 'invalid'
} else if (mandate.isDisabled) {
this.wallet.mandateState = 'disabled'
}
}
},
oneOffDialog(negative) {
this.oneoff_negative = negative
this.dialog = $('#oneoff-dialog').on('shown.bs.modal', event => {
this.$root.clearFormValidation(event.target)
$(event.target).find('#oneoff_amount').focus()
}).modal()
},
penalizeDialog() {
this.oneOffDialog(true)
},
reload() {
// this is to reload transaction log
this.walletReload = true
this.$nextTick(() => { this.walletReload = false })
},
reset2FA() {
$('#reset-2fa-dialog').modal('hide')
axios.post('/api/v4/users/' + this.user.id + '/reset2FA')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.skus = this.skus.filter(sku => sku.id != this.sku2FA)
this.has2FA = false
}
})
},
reset2FADialog() {
$('#reset-2fa-dialog').modal()
},
submitDiscount() {
$('#discount-dialog').modal('hide')
axios.put('/api/v4/wallets/' + this.user.wallets[0].id, { discount: this.wallet.discount_id })
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.wallet = Object.assign({}, this.wallet, response.data)
// Update prices in Subscriptions tab
if (this.user.wallet.id == response.data.id) {
this.discount = this.wallet.discount
this.discount_description = this.wallet.discount_description
this.skus.forEach(sku => {
sku.price = this.$root.priceLabel(sku.cost, this.discount)
})
}
}
})
},
submitEmail() {
axios.put('/api/v4/users/' + this.user.id, { external_email: this.external_email })
.then(response => {
if (response.data.status == 'success') {
$('#email-dialog').modal('hide')
this.$toast.success(response.data.message)
this.user.external_email = this.external_email
this.external_email = null // required because of Vue
}
})
},
submitOneOff() {
let wallet_id = this.user.wallets[0].id
let post = {
amount: this.oneoff_amount,
description: this.oneoff_description
}
if (this.oneoff_negative && /^\d+(\.?\d+)?$/.test(post.amount)) {
post.amount *= -1
}
// TODO: We maybe should use system currency not wallet currency,
// or have a selector so the operator does not have to calculate
// exchange rates
this.$root.clearFormValidation(this.dialog)
axios.post('/api/v4/wallets/' + wallet_id + '/one-off', post)
.then(response => {
if (response.data.status == 'success') {
this.dialog.modal('hide')
this.$toast.success(response.data.message)
this.wallet = Object.assign({}, this.wallet, {balance: response.data.balance})
this.oneoff_amount = ''
this.oneoff_description = ''
this.reload()
}
})
},
suspendUser() {
axios.post('/api/v4/users/' + this.user.id + '/suspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.user = Object.assign({}, this.user, { isSuspended: true })
}
})
},
unsuspendUser() {
axios.post('/api/v4/users/' + this.user.id + '/unsuspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.user = Object.assign({}, this.user, { isSuspended: false })
}
})
}
}
}
</script>
diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue
index 12afc0df..4593ca5a 100644
--- a/src/resources/vue/Domain/Info.vue
+++ b/src/resources/vue/Domain/Info.vue
@@ -1,89 +1,142 @@
<template>
<div class="container">
<status-component :status="status" @status-update="statusUpdate"></status-component>
- <div v-if="domain && !domain.isConfirmed" class="card" id="domain-verify">
+ <div v-if="domain" class="card">
<div class="card-body">
- <div class="card-title">{{ $t('domain.verify') }}</div>
+ <div class="card-title">{{ domain.namespace }}</div>
<div class="card-text">
- <p>{{ $t('domain.verify-intro') }}</p>
- <p>
- <span v-html="$t('domain.verify-dns')"></span>
- <ul>
- <li>{{ $t('domain.verify-dns-txt') }} <code>{{ domain.hash_text }}</code></li>
- <li>{{ $t('domain.verify-dns-cname') }} <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
- </ul>
- <span>{{ $t('domain.verify-outro') }}</span>
- </p>
- <p>{{ $t('domain.verify-sample') }} <pre>{{ domain.dns.join("\n") }}</pre></p>
- <button class="btn btn-primary" type="button" @click="confirm"><svg-icon icon="sync-alt"></svg-icon> {{ $t('btn.verify') }}</button>
- </div>
- </div>
- </div>
- <div v-if="domain && domain.isConfirmed" class="card" id="domain-config">
- <div class="card-body">
- <div class="card-title">{{ $t('domain.config') }}</div>
- <div class="card-text">
- <p>{{ $t('domain.config-intro', { app: $root.appName }) }}</p>
- <p>{{ $t('domain.config-sample') }} <pre>{{ domain.config.join("\n") }}</pre></p>
- <p>{{ $t('domain.config-hint') }}</p>
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item" v-if="!domain.isConfirmed">
+ <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
+ {{ $t('domain.verify') }}
+ </a>
+ </li>
+ <li class="nav-item" v-if="domain.isConfirmed">
+ <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
+ {{ $t('domain.config') }}
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
+ <div v-if="!domain.isConfirmed" class="card-body" id="domain-verify">
+ <div class="card-text">
+ <p>{{ $t('domain.verify-intro') }}</p>
+ <p>
+ <span v-html="$t('domain.verify-dns')"></span>
+ <ul>
+ <li>{{ $t('domain.verify-dns-txt') }} <code>{{ domain.hash_text }}</code></li>
+ <li>{{ $t('domain.verify-dns-cname') }} <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
+ </ul>
+ <span>{{ $t('domain.verify-outro') }}</span>
+ </p>
+ <p>{{ $t('domain.verify-sample') }} <pre>{{ domain.dns.join("\n") }}</pre></p>
+ <button class="btn btn-primary" type="button" @click="confirm"><svg-icon icon="sync-alt"></svg-icon> {{ $t('btn.verify') }}</button>
+ </div>
+ </div>
+ <div v-if="domain.isConfirmed" class="card-body" id="domain-config">
+ <div class="card-text">
+ <p>{{ $t('domain.config-intro', { app: $root.appName }) }}</p>
+ <p>{{ $t('domain.config-sample') }} <pre>{{ domain.mx.join("\n") }}</pre></p>
+ <p>{{ $t('domain.config-hint') }}</p>
+ </div>
+ </div>
+ </div>
+ <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <form @submit.prevent="submitSettings">
+ <div class="form-group row">
+ <label for="spf_whitelist" class="col-sm-4 col-form-label">{{ $t('domain.spf-whitelist') }}</label>
+ <div class="col-sm-8">
+ <list-input id="spf_whitelist" name="spf_whitelist" :list="spf_whitelist"></list-input>
+ <small id="spf-hint" class="form-text text-muted">
+ {{ $t('domain.spf-whitelist-text') }}
+ <span class="d-block" v-html="$t('domain.spf-whitelist-ex')"></span>
+ </small>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ </form>
+ </div>
+ </div>
+ </div>
</div>
</div>
</div>
</div>
</template>
<script>
+ import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
+ ListInput,
StatusComponent
},
data() {
return {
domain_id: null,
domain: null,
+ spf_whitelist: [],
status: {}
}
},
created() {
if (this.domain_id = this.$route.params.domain) {
this.$root.startLoading()
axios.get('/api/v4/domains/' + this.domain_id)
.then(response => {
this.$root.stopLoading()
this.domain = response.data
+ this.spf_whitelist = this.domain.config.spf_whitelist || []
if (!this.domain.isConfirmed) {
$('#domain-verify button').focus()
}
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
} else {
this.$root.errorPage(404)
}
},
methods: {
confirm() {
axios.get('/api/v4/domains/' + this.domain_id + '/confirm')
.then(response => {
if (response.data.status == 'success') {
this.domain.isConfirmed = true
this.status = response.data.statusInfo
}
if (response.data.message) {
this.$toast[response.data.status](response.data.message)
}
})
},
statusUpdate(domain) {
this.domain = Object.assign({}, this.domain, domain)
+ },
+ submitSettings() {
+ this.$root.clearFormValidation($('#settings form'))
+
+ let post = { spf_whitelist: this.spf_whitelist }
+
+ axios.post('/api/v4/domains/' + this.domain_id + '/config', post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ })
}
}
}
</script>
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
index ee30a2c2..57065d25 100644
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -1,442 +1,485 @@
<template>
<div class="container">
<status-component v-if="user_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="user-info">
<div class="card-body">
<div class="card-title" v-if="user_id !== 'new'">{{ $t('user.title') }}
<button
class="btn btn-outline-danger button-delete float-right"
@click="showDeleteConfirmation()" type="button"
>
<svg-icon icon="trash-alt"></svg-icon> {{ $t('user.delete') }}
</button>
</div>
<div class="card-title" v-if="user_id === 'new'">{{ $t('user.new') }}</div>
<div class="card-text">
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item">
+ <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
+ {{ $t('form.general') }}
+ </a>
+ </li>
+ <li v-if="user_id !== 'new'" class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
+ <div class="card-body">
<form @submit.prevent="submit">
<div v-if="user_id !== 'new'" class="form-group row plaintext">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.userStatusClass(user) + ' form-control-plaintext'" id="status">{{ $root.userStatusText(user) }}</span>
</div>
</div>
<div class="form-group row">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="first_name" v-model="user.first_name">
</div>
</div>
<div class="form-group row">
<label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="last_name" v-model="user.last_name">
</div>
</div>
<div class="form-group row">
<label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="organization" v-model="user.organization">
</div>
</div>
<div class="form-group row">
<label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="email" :disabled="user_id !== 'new'" required v-model="user.email">
</div>
</div>
<div class="form-group row">
<label for="aliases-input" class="col-sm-4 col-form-label">{{ $t('user.aliases-email') }}</label>
<div class="col-sm-8">
<list-input id="aliases" :list="user.aliases"></list-input>
</div>
</div>
<div class="form-group row">
<label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
<div class="col-sm-8">
<input type="password" class="form-control" id="password" v-model="user.password" :required="user_id === 'new'">
</div>
</div>
<div class="form-group row">
<label for="password_confirmaton" class="col-sm-4 col-form-label">{{ $t('form.password-confirm') }}</label>
<div class="col-sm-8">
<input type="password" class="form-control" id="password_confirmation" v-model="user.password_confirmation" :required="user_id === 'new'">
</div>
</div>
<div v-if="user_id === 'new'" id="user-packages" class="form-group row">
<label class="col-sm-4 col-form-label">Package</label>
<div class="col-sm-8">
<table class="table table-sm form-list">
<thead class="thead-light sr-only">
<tr>
<th scope="col"></th>
<th scope="col">{{ $t('user.package') }}</th>
<th scope="col">{{ $t('user.price') }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="pkg in packages" :id="'p' + pkg.id" :key="pkg.id">
<td class="selection">
<input type="checkbox" @click="selectPackage"
:value="pkg.id"
:checked="pkg.id == package_id"
:id="'pkg-input-' + pkg.id"
>
</td>
<td class="name">
<label :for="'pkg-input-' + pkg.id">{{ pkg.name }}</label>
</td>
<td class="price text-nowrap">
{{ $root.priceLabel(pkg.cost, discount) }}
</td>
<td class="buttons">
<button v-if="pkg.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="pkg.description">
<svg-icon icon="info-circle"></svg-icon>
<span class="sr-only">{{ $t('btn.moreinfo') }}</span>
</button>
</td>
</tr>
</tbody>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
&sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
</div>
</div>
<div v-if="user_id !== 'new'" id="user-skus" class="form-group row">
<label class="col-sm-4 col-form-label">{{ $t('user.subscriptions') }}</label>
<div class="col-sm-8">
<table class="table table-sm form-list">
<thead class="thead-light sr-only">
<tr>
<th scope="col"></th>
<th scope="col">{{ $t('user.subscription') }}</th>
<th scope="col">{{ $t('user.price') }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="sku in skus" :id="'s' + sku.id" :key="sku.id">
<td class="selection">
<input type="checkbox" @input="onInputSku"
:value="sku.id"
:disabled="sku.readonly"
:checked="sku.enabled"
:id="'sku-input-' + sku.title"
>
</td>
<td class="name">
<label :for="'sku-input-' + sku.title">{{ sku.name }}</label>
<div v-if="sku.range" class="range-input">
<label class="text-nowrap">{{ sku.range.min }} {{ sku.range.unit }}</label>
<input
type="range" class="custom-range" @input="rangeUpdate"
:value="sku.value || sku.range.min"
:min="sku.range.min"
:max="sku.range.max"
>
</div>
</td>
<td class="price text-nowrap">
{{ $root.priceLabel(sku.cost, discount) }}
</td>
<td class="buttons">
<button v-if="sku.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="sku.description">
<svg-icon icon="info-circle"></svg-icon>
<span class="sr-only">{{ $t('btn.moreinfo') }}</span>
</button>
</td>
</tr>
</tbody>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
&sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
</div>
</div>
<button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
</form>
+ </div>
+ </div>
+ <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <form @submit.prevent="submitSettings">
+ <div class="form-group row checkbox">
+ <label for="greylisting" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
+ <div class="col-sm-8 pt-2">
+ <input type="checkbox" id="greylisting" name="greylisting" value="1" :checked="user.config.greylisting">
+ <small id="greylisting-hint" class="form-text text-muted">
+ {{ $t('user.greylisting-text') }}
+ </small>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ </form>
+ </div>
+ </div>
+ </div>
</div>
</div>
</div>
<div id="delete-warning" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.delete-email', { email: user.email }) }}</h5>
<button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>{{ $t('user.delete-text') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-danger modal-action" @click="deleteUser()">
<svg-icon icon="trash-alt"></svg-icon> {{ $t('btn.delete') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
ListInput,
StatusComponent
},
data() {
return {
discount: 0,
discount_description: '',
user_id: null,
- user: { aliases: [] },
+ user: { aliases: [], config: [] },
packages: [],
package_id: null,
skus: [],
status: {}
}
},
created() {
this.user_id = this.$route.params.user
let wallet = this.$store.state.authInfo.accounts[0]
if (!wallet) {
wallet = this.$store.state.authInfo.wallets[0]
}
if (wallet && wallet.discount) {
this.discount = wallet.discount
this.discount_description = wallet.discount_description
}
this.$root.startLoading()
if (this.user_id === 'new') {
// do nothing (for now)
axios.get('/api/v4/packages')
.then(response => {
this.$root.stopLoading()
this.packages = response.data.filter(pkg => !pkg.isDomain)
this.package_id = this.packages[0].id
})
.catch(this.$root.errorHandler)
}
else {
axios.get('/api/v4/users/' + this.user_id)
.then(response => {
this.$root.stopLoading()
this.user = response.data
this.user.first_name = response.data.settings.first_name
this.user.last_name = response.data.settings.last_name
this.user.organization = response.data.settings.organization
this.discount = this.user.wallet.discount
this.discount_description = this.user.wallet.discount_description
this.status = response.data.statusInfo
axios.get('/api/v4/users/' + this.user_id + '/skus?type=user')
.then(response => {
// "merge" SKUs with user entitlement-SKUs
this.skus = response.data
.map(sku => {
const userSku = this.user.skus[sku.id]
if (userSku) {
sku.enabled = true
sku.skuCost = sku.cost
sku.cost = userSku.costs.reduce((sum, current) => sum + current)
sku.value = userSku.count
sku.costs = userSku.costs
} else if (!sku.readonly) {
sku.enabled = false
}
return sku
})
// Update all range inputs (and price)
this.$nextTick(() => {
$('#user-skus input[type=range]').each((idx, elem) => { this.rangeUpdate(elem) })
})
})
.catch(this.$root.errorHandler)
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#first_name').focus()
},
methods: {
submit() {
this.$root.clearFormValidation($('#user-info form'))
let method = 'post'
let location = '/api/v4/users'
if (this.user_id !== 'new') {
method = 'put'
location += '/' + this.user_id
let skus = {}
$('#user-skus input[type=checkbox]:checked').each((idx, input) => {
let id = $(input).val()
let range = $(input).parents('tr').first().find('input[type=range]').val()
skus[id] = range || 1
})
this.user.skus = skus
} else {
this.user.package = this.package_id
}
axios[method](location, this.user)
.then(response => {
if (response.data.statusInfo) {
this.$store.state.authInfo.statusInfo = response.data.statusInfo
}
this.$toast.success(response.data.message)
this.$router.push({ name: 'users' })
})
},
+ submitSettings() {
+ this.$root.clearFormValidation($('#settings form'))
+ let post = { greylisting: $('#greylisting').prop('checked') ? 1 : 0 }
+
+ axios.post('/api/v4/users/' + this.user_id + '/config', post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ })
+ },
onInputSku(e) {
let input = e.target
let sku = this.findSku(input.value)
let required = []
// We use 'readonly', not 'disabled', because we might want to handle
// input events. For example to display an error when someone clicks
// the locked input
if (input.readOnly) {
input.checked = !input.checked
// TODO: Display an alert explaining why it's locked
return
}
// TODO: Following code might not work if we change definition of forbidden/required
// or we just need more sophisticated SKU dependency rules
if (input.checked) {
// Check if a required SKU is selected, alert the user if not
(sku.required || []).forEach(title => {
this.skus.forEach(item => {
let checkbox
if (item.handler == title && (checkbox = $('#s' + item.id).find('input[type=checkbox]')[0])) {
if (!checkbox.checked) {
required.push(item.name)
}
}
})
})
if (required.length) {
input.checked = false
return alert(this.$t('user.skureq', { sku: sku.name, list: required.join(', ') }))
}
} else {
// Uncheck all dependent SKUs, e.g. when unchecking Groupware we also uncheck Activesync
// TODO: Should we display an alert instead?
this.skus.forEach(item => {
if (item.required && item.required.indexOf(sku.handler) > -1) {
$('#s' + item.id).find('input[type=checkbox]').prop('checked', false)
}
})
}
// Uncheck+lock/unlock conflicting SKUs
(sku.forbidden || []).forEach(title => {
this.skus.forEach(item => {
let checkbox
if (item.handler == title && (checkbox = $('#s' + item.id).find('input[type=checkbox]')[0])) {
if (input.checked) {
checkbox.checked = false
checkbox.readOnly = true
} else {
checkbox.readOnly = false
}
}
})
})
},
selectPackage(e) {
// Make sure there always is only one package selected
$('#user-packages input').prop('checked', false)
this.package_id = $(e.target).prop('checked', false).val()
},
rangeUpdate(e) {
let input = $(e.target || e)
let value = input.val()
let record = input.parents('tr').first()
let sku_id = record.find('input[type=checkbox]').val()
let sku = this.findSku(sku_id)
let existing = sku.costs ? sku.costs.length : 0
let cost
// Calculate cost, considering both existing entitlement cost and sku cost
if (existing) {
cost = sku.costs
.sort((a, b) => a - b) // sort by cost ascending (free units first)
.slice(0, value)
.reduce((sum, current) => sum + current)
if (value > existing) {
cost += sku.skuCost * (value - existing)
}
} else {
cost = sku.cost * (value - sku.units_free)
}
// Update the label
input.prev().text(value + ' ' + sku.range.unit)
// Update the price
record.find('.price').text(this.$root.priceLabel(cost, this.discount))
},
findSku(id) {
for (let i = 0; i < this.skus.length; i++) {
if (this.skus[i].id == id) {
return this.skus[i];
}
}
},
statusUpdate(user) {
this.user = Object.assign({}, this.user, user)
},
deleteUser() {
// Delete the user from the confirm dialog
axios.delete('/api/v4/users/' + this.user_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'users' })
}
})
},
showDeleteConfirmation() {
// Deleting self, redirect to /profile/delete page
if (this.user_id == this.$store.state.authInfo.id) {
this.$router.push({ name: 'profile-delete' })
} else {
// Display the warning
let dialog = $('#delete-warning')
dialog.on('shown.bs.modal', () => {
dialog.find('button.modal-cancel').focus()
}).modal()
}
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/ListInput.vue b/src/resources/vue/Widgets/ListInput.vue
index 95fe7f00..f7f7e4de 100644
--- a/src/resources/vue/Widgets/ListInput.vue
+++ b/src/resources/vue/Widgets/ListInput.vue
@@ -1,75 +1,78 @@
<template>
<div class="list-input" :id="id">
<div class="input-group">
<input :id="id + '-input'" type="text" class="form-control main-input" @keydown="keyDown">
<div class="input-group-append">
<a href="#" class="btn btn-outline-secondary" @click.prevent="addItem">
<svg-icon icon="plus"></svg-icon>
<span class="sr-only">{{ $t('btn.add') }}</span>
</a>
</div>
</div>
<div class="input-group" v-for="(item, index) in list" :key="index">
<input type="text" class="form-control" v-model="list[index]">
<div class="input-group-append">
<a href="#" class="btn btn-outline-secondary" @click.prevent="deleteItem(index)">
<svg-icon icon="trash-alt"></svg-icon>
<span class="sr-only">{{ $t('btn.delete') }}</span>
</a>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
list: { type: Array, default: () => [] },
id: { type: String, default: '' }
},
mounted() {
this.input = $(this.$el).find('.main-input')[0]
// On form submit add the text from main input to the list
// Users tend to forget about pressing the "plus" button
// Note: We can't use form.onsubmit (too late)
// Note: Use of input.onblur has been proven to be problematic
// TODO: What with forms that have no submit button?
$(this.$el).closest('form').find('button[type=submit]').on('click', () => {
this.addItem(false)
})
},
methods: {
addItem(focus) {
let value = this.input.value
if (value) {
this.list.push(value)
this.input.value = ''
this.input.classList.remove('is-invalid')
if (focus !== false) {
this.input.focus()
}
if (this.list.length == 1) {
this.$el.classList.remove('is-invalid')
}
+
+ this.$emit('change', this.$el)
}
},
deleteItem(index) {
this.$delete(this.list, index)
+ this.$emit('change', this.$el)
if (!this.list.length) {
this.$el.classList.remove('is-invalid')
}
},
keyDown(e) {
if (e.which == 13 && e.target.value) {
this.addItem()
e.preventDefault()
}
}
}
}
</script>
diff --git a/src/routes/api.php b/src/routes/api.php
index faa46877..7e204194 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,215 +1,229 @@
<?php
use Illuminate\Http\Request;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
$prefix = \trim(\parse_url(\config('app.url'), PHP_URL_PATH), '/') . '/';
Route::group(
[
'middleware' => 'api',
'prefix' => $prefix . 'api/auth'
],
function ($router) {
Route::post('login', 'API\AuthController@login');
Route::group(
['middleware' => 'auth:api'],
function ($router) {
Route::get('info', 'API\AuthController@info');
Route::post('logout', 'API\AuthController@logout');
Route::post('refresh', 'API\AuthController@refresh');
}
);
}
);
Route::group(
[
'domain' => \config('app.domain'),
'middleware' => 'api',
'prefix' => $prefix . 'api/auth'
],
function ($router) {
Route::post('password-reset/init', 'API\PasswordResetController@init');
Route::post('password-reset/verify', 'API\PasswordResetController@verify');
Route::post('password-reset', 'API\PasswordResetController@reset');
Route::post('signup/init', 'API\SignupController@init');
Route::get('signup/invitations/{id}', 'API\SignupController@invitation');
Route::get('signup/plans', 'API\SignupController@plans');
Route::post('signup/verify', 'API\SignupController@verify');
Route::post('signup', 'API\SignupController@signup');
}
);
Route::group(
[
'domain' => \config('app.domain'),
'middleware' => 'auth:api',
'prefix' => $prefix . 'api/v4'
],
function () {
Route::apiResource('domains', API\V4\DomainsController::class);
Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm');
Route::get('domains/{id}/status', 'API\V4\DomainsController@status');
+ Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig');
Route::apiResource('groups', API\V4\GroupsController::class);
Route::get('groups/{id}/status', 'API\V4\GroupsController@status');
Route::apiResource('packages', API\V4\PackagesController::class);
Route::apiResource('skus', API\V4\SkusController::class);
Route::apiResource('users', API\V4\UsersController::class);
+ Route::post('users/{id}/config', 'API\V4\UsersController@setConfig');
Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus');
Route::get('users/{id}/status', 'API\V4\UsersController@status');
Route::apiResource('wallets', API\V4\WalletsController::class);
Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions');
Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts');
Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload');
Route::post('payments', 'API\V4\PaymentsController@store');
//Route::delete('payments', 'API\V4\PaymentsController@cancel');
Route::get('payments/mandate', 'API\V4\PaymentsController@mandate');
Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate');
Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate');
Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete');
Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods');
Route::get('payments/pending', 'API\V4\PaymentsController@payments');
Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments');
Route::get('openvidu/rooms', 'API\V4\OpenViduController@index');
Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom');
Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig');
// FIXME: I'm not sure about this one, should we use DELETE request maybe?
Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection');
Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection');
Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
// Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group
Route::group(
[
'domain' => \config('app.domain'),
'prefix' => $prefix . 'api/v4'
],
function () {
Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom');
Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection');
// FIXME: I'm not sure about this one, should we use DELETE request maybe?
Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection');
Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection');
Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
Route::group(
[
'domain' => \config('app.domain'),
'middleware' => 'api',
'prefix' => $prefix . 'api/v4'
],
function ($router) {
Route::post('support/request', 'API\V4\SupportController@request');
}
);
Route::group(
[
'domain' => \config('app.domain'),
- 'prefix' => $prefix . 'api/webhooks',
+ 'prefix' => $prefix . 'api/webhooks'
],
function () {
Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook');
Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook');
}
);
+Route::group(
+ [
+ 'domain' => 'services.' . \config('app.domain'),
+ 'prefix' => $prefix . 'api/webhooks/policy'
+ ],
+ function () {
+ Route::post('greylist', 'API\V4\PolicyController@greylist');
+ Route::post('ratelimit', 'API\V4\PolicyController@ratelimit');
+ Route::post('spf', 'API\V4\PolicyController@senderPolicyFramework');
+ }
+);
+
Route::group(
[
'domain' => 'admin.' . \config('app.domain'),
'middleware' => ['auth:api', 'admin'],
'prefix' => $prefix . 'api/v4',
],
function () {
Route::apiResource('domains', API\V4\Admin\DomainsController::class);
Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend');
Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend');
Route::apiResource('groups', API\V4\Admin\GroupsController::class);
Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend');
Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend');
Route::apiResource('skus', API\V4\Admin\SkusController::class);
Route::apiResource('users', API\V4\Admin\UsersController::class);
Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts');
Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA');
Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus');
Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend');
Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend');
Route::apiResource('wallets', API\V4\Admin\WalletsController::class);
Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff');
Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions');
Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart');
}
);
Route::group(
[
'domain' => 'reseller.' . \config('app.domain'),
'middleware' => ['auth:api', 'reseller'],
'prefix' => $prefix . 'api/v4',
],
function () {
Route::apiResource('domains', API\V4\Reseller\DomainsController::class);
Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend');
Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend');
Route::apiResource('groups', API\V4\Reseller\GroupsController::class);
Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend');
Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend');
Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class);
Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend');
Route::post('payments', 'API\V4\Reseller\PaymentsController@store');
Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate');
Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate');
Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate');
Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete');
Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods');
Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments');
Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments');
Route::apiResource('skus', API\V4\Reseller\SkusController::class);
Route::apiResource('users', API\V4\Reseller\UsersController::class);
Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts');
Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA');
Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus');
Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend');
Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend');
Route::apiResource('wallets', API\V4\Reseller\WalletsController::class);
Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff');
Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts');
Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload');
Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions');
Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart');
}
);
diff --git a/src/tests/Browser/Admin/DomainTest.php b/src/tests/Browser/Admin/DomainTest.php
index 620f8119..c90a464b 100644
--- a/src/tests/Browser/Admin/DomainTest.php
+++ b/src/tests/Browser/Admin/DomainTest.php
@@ -1,119 +1,143 @@
<?php
namespace Tests\Browser\Admin;
use App\Domain;
use Tests\Browser;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\Domain as DomainPage;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class DomainTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->setSetting('spf_whitelist', null);
+
parent::tearDown();
}
/**
* Test domain info page (unauthenticated)
*/
public function testDomainUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('kolab.org');
$browser->visit('/domain/' . $domain->id)->on(new Home());
});
}
/**
* Test domain info page
*/
public function testDomainInfo(): void
{
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('kolab.org');
$domain_page = new DomainPage($domain->id);
$john = $this->getTestUser('john@kolab.org');
$user_page = new UserPage($john->id);
+ $domain->setSetting('spf_whitelist', null);
+
// Goto the domain page
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->visit($user_page)
->on($user_page)
->click('@nav #tab-domains')
->pause(1000)
->click('@user-domains table tbody tr:first-child td a');
$browser->on($domain_page)
->assertSeeIn('@domain-info .card-title', 'kolab.org')
->with('@domain-info form', function (Browser $browser) use ($domain) {
$browser->assertElementsCount('.row', 2)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 1);
+ ->assertElementsCount('@nav a', 2);
// Assert Configuration tab
$browser->assertSeeIn('@nav #tab-config', 'Configuration')
->with('@domain-config', function (Browser $browser) {
$browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.')
->assertSeeIn('pre#dns-config', 'kolab.org.');
});
+
+ // Assert Settings tab
+ $browser->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->with('@domain-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:first-child label', 'SPF Whitelist')
+ ->assertSeeIn('.row:first-child .form-control-plaintext', 'none');
+ });
+
+ // Assert non-empty SPF whitelist
+ $domain->setSetting('spf_whitelist', json_encode(['.test1.com', '.test2.com']));
+
+ $browser->refresh()
+ ->waitFor('@nav #tab-settings')
+ ->click('@nav #tab-settings')
+ ->with('@domain-settings form', function (Browser $browser) {
+ $browser->assertSeeIn('.row:first-child .form-control-plaintext', '.test1.com, .test2.com');
+ });
});
}
/**
* Test suspending/unsuspending a domain
*
* @depends testDomainInfo
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('domainscontroller.com', [
'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE
| Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED
| Domain::STATUS_VERIFIED,
'type' => Domain::TYPE_EXTERNAL,
]);
$browser->visit(new DomainPage($domain->id))
->assertVisible('@domain-info #button-suspend')
->assertMissing('@domain-info #button-unsuspend')
->click('@domain-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.')
->assertSeeIn('@domain-info #status span.text-warning', 'Suspended')
->assertMissing('@domain-info #button-suspend')
->click('@domain-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.')
->assertSeeIn('@domain-info #status span.text-success', 'Active')
->assertVisible('@domain-info #button-suspend')
->assertMissing('@domain-info #button-unsuspend');
});
}
}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
index e0924ceb..f8302037 100644
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -1,498 +1,518 @@
<?php
namespace Tests\Browser\Admin;
use App\Auth\SecondFactor;
use App\Discount;
use App\Entitlement;
use App\Sku;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
Entitlement::where('cost', '>=', 5000)->delete();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
Entitlement::where('cost', '>=', 5000)->delete();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testUserUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$browser->visit('/user/' . $jack->id)->on(new Home());
});
}
/**
* Test user info page
*/
public function testUserInfo(): void
{
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$page = new UserPage($jack->id);
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->visit($page)
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $jack->email)
->with('@user-info form', function (Browser $browser) use ($jack) {
$browser->assertElementsCount('.row', 7)
->assertSeeIn('.row:nth-child(1) label', 'Managed by')
->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org')
->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})")
->assertSeeIn('.row:nth-child(3) label', 'Status')
->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(4) label', 'First Name')
->assertSeeIn('.row:nth-child(4) #first_name', 'Jack')
->assertSeeIn('.row:nth-child(5) label', 'Last Name')
->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels')
->assertSeeIn('.row:nth-child(6) label', 'External Email')
->assertMissing('.row:nth-child(6) #external_email a')
->assertSeeIn('.row:nth-child(7) label', 'Country')
->assertSeeIn('.row:nth-child(7) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 6);
+ ->assertElementsCount('@nav a', 7);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '5,00 CHF')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,90 CHF')
->assertMissing('table tfoot')
->assertMissing('#reset2fa');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
+
+ // Assert Settings tab
+ $browser->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->whenAvailable('@user-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:first-child label', 'Greylisting')
+ ->assertSeeIn('.row:first-child .text-success', 'enabled');
+ });
});
}
/**
* Test user info page (continue)
*
* @depends testUserInfo
*/
public function testUserInfo2(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$page = new UserPage($john->id);
$discount = Discount::where('code', 'TEST')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($john->wallets->first());
+ $john->setSetting('greylisting', null);
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $john->email)
->with('@user-info form', function (Browser $browser) use ($john) {
$ext_email = $john->getSetting('external_email');
$browser->assertElementsCount('.row', 9)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(3) label', 'First Name')
->assertSeeIn('.row:nth-child(3) #first_name', 'John')
->assertSeeIn('.row:nth-child(4) label', 'Last Name')
->assertSeeIn('.row:nth-child(4) #last_name', 'Doe')
->assertSeeIn('.row:nth-child(5) label', 'Organization')
->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers')
->assertSeeIn('.row:nth-child(6) label', 'Phone')
->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone'))
->assertSeeIn('.row:nth-child(7) label', 'External Email')
->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email)
->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email")
->assertSeeIn('.row:nth-child(8) label', 'Address')
->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address'))
->assertSeeIn('.row:nth-child(9) label', 'Country')
->assertSeeIn('.row:nth-child(9) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 6);
+ ->assertElementsCount('@nav a', 7);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (1)')
->click('@nav #tab-domains')
->with('@user-domains table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertMissing('tfoot');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)')
->click('@nav #tab-distlists')
->with('@user-distlists table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'group-test@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger')
->assertMissing('tfoot');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (4)')
->click('@nav #tab-users')
->with('@user-users table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
->assertMissing('tfoot');
});
});
// Now we go to Ned's info page, he's a controller on John's wallet
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
$beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$wallet = $ned->wallet();
// Add an extra storage and beta entitlement with different prices
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $beta_sku->id,
'cost' => 5010,
'entitleable_id' => $ned->id,
'entitleable_type' => User::class
]);
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $storage_sku->id,
'cost' => 5000,
'entitleable_id' => $ned->id,
'entitleable_type' => User::class
]);
$page = new UserPage($ned->id);
+ $ned->setSetting('greylisting', 'false');
$browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $ned->email)
->with('@user-info form', function (Browser $browser) use ($ned) {
$browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})");
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 6);
+ ->assertElementsCount('@nav a', 7);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'This user has no email aliases.');
});
// Assert Subscriptions tab, we expect John's discount here
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (6)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 6)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 6 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '45,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(6) td:first-child', 'Private Beta (invitation only)')
->assertSeeIn('table tbody tr:nth-child(6) td:last-child', '45,09 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher')
->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth');
});
// We don't expect John's domains here
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// We don't expect John's users here
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// We don't expect John's distribution lists here
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
+
+ // Assert Settings tab
+ $browser->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->whenAvailable('@user-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:first-child label', 'Greylisting')
+ ->assertSeeIn('.row:first-child .text-danger', 'disabled');
+ });
});
}
/**
* Test editing an external email
*
* @depends testUserInfo2
*/
public function testExternalEmail(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->waitFor('@user-info #external_email button')
->click('@user-info #external_email button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'External Email')
->assertFocused('@body input')
->assertValue('@body input', 'john.doe.external@gmail.com')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#email-dialog')
->click('@user-info #external_email button')
// Test email validation error handling, and email update
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->type('@body input', 'test')
->click('@button-action')
->waitFor('@body input.is-invalid')
->assertSeeIn(
'@body input + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('@body input', 'test@test.com')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->assertSeeIn('@user-info #external_email a', 'test@test.com')
->click('@user-info #external_email button')
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertValue('@body input', 'test@test.com')
->assertMissing('@body input.is-invalid')
->assertMissing('@body input + .invalid-feedback')
->click('@button-cancel');
})
->assertSeeIn('@user-info #external_email a', 'test@test.com');
// $john->getSetting() may not work here as it uses internal cache
// read the value form database
$current_ext_email = $john->settings()->where('key', 'external_email')->first()->value;
$this->assertSame('test@test.com', $current_ext_email);
});
}
/**
* Test suspending/unsuspending the user
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend')
->click('@user-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.')
->assertSeeIn('@user-info #status span.text-warning', 'Suspended')
->assertMissing('@user-info #button-suspend')
->click('@user-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.')
->assertSeeIn('@user-info #status span.text-success', 'Active')
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend');
});
}
/**
* Test resetting 2FA for the user
*/
public function testReset2FA(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
$sku2fa = Sku::withEnvTenantContext()->where('title', '2fa')->first();
$user->assignSku($sku2fa);
SecondFactor::seed('userstest1@kolabnow.com');
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) {
$browser->waitFor('#reset2fa')
->assertVisible('#sku' . $sku2fa->id);
})
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)')
->click('#reset2fa')
->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', '2-Factor Authentication Reset')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Reset')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.')
->assertMissing('#sku' . $sku2fa->id)
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)');
});
}
}
diff --git a/src/tests/Browser/Components/ListInput.php b/src/tests/Browser/Components/ListInput.php
index c5928943..90ba6109 100644
--- a/src/tests/Browser/Components/ListInput.php
+++ b/src/tests/Browser/Components/ListInput.php
@@ -1,104 +1,104 @@
<?php
namespace Tests\Browser\Components;
use Laravel\Dusk\Component as BaseComponent;
use PHPUnit\Framework\Assert as PHPUnit;
class ListInput extends BaseComponent
{
protected $selector;
public function __construct($selector)
{
$this->selector = $selector;
}
/**
* Get the root selector for the component.
*
* @return string
*/
public function selector()
{
return $this->selector;
}
/**
* Assert that the browser page contains the component.
*
* @param \Laravel\Dusk\Browser $browser
*
* @return void
*/
public function assert($browser)
{
$browser->assertVisible($this->selector())
->assertVisible('@input')
->assertVisible('@add-btn');
}
/**
* Get the element shortcuts for the component.
*
* @return array
*/
public function elements()
{
return [
'@input' => '.input-group:first-child input',
'@add-btn' => '.input-group:first-child a.btn',
];
}
/**
* Assert list input content
*/
public function assertListInputValue($browser, array $list)
{
if (empty($list)) {
$browser->assertMissing('.input-group:not(:first-child)');
return;
}
foreach ($list as $idx => $value) {
$selector = '.input-group:nth-child(' . ($idx + 2) . ') input';
$browser->assertVisible($selector)->assertValue($selector, $value);
}
}
/**
* Add list entry
*/
public function addListEntry($browser, string $value)
{
$browser->type('@input', $value)
->click('@add-btn')
->assertValue('.input-group:last-child input', $value);
}
/**
* Remove list entry
*/
public function removeListEntry($browser, int $num)
{
$selector = '.input-group:nth-child(' . ($num + 1) . ') a.btn';
- $browser->click($selector)->assertMissing($selector);
+ $browser->click($selector);
}
/**
* Assert an error message on the widget
*/
public function assertFormError($browser, int $num, string $msg, bool $focused = false)
{
$selector = '.input-group:nth-child(' . ($num + 1) . ') input.is-invalid';
$browser->assertVisible($selector)
->assertSeeIn(' + .invalid-feedback', $msg);
if ($focused) {
$browser->assertFocused($selector);
}
}
}
diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php
index 2322bc5f..9bdfed5c 100644
--- a/src/tests/Browser/DomainTest.php
+++ b/src/tests/Browser/DomainTest.php
@@ -1,158 +1,202 @@
<?php
namespace Tests\Browser;
use App\Domain;
use App\User;
use Tests\Browser;
+use Tests\Browser\Components\ListInput;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\DomainInfo;
use Tests\Browser\Pages\DomainList;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class DomainTest extends TestCaseDusk
{
/**
* Test domain info page (unauthenticated)
*/
public function testDomainInfoUnauth(): void
{
// Test that the page requires authentication
$this->browse(function ($browser) {
$browser->visit('/domain/123')->on(new Home());
});
}
/**
* Test domain info page (non-existing domain id)
*/
public function testDomainInfo404(): void
{
$this->browse(function ($browser) {
// FIXME: I couldn't make loginAs() method working
// Note: Here we're also testing that unauthenticated request
// is passed to logon form and then "redirected" to the requested page
$browser->visit('/domain/123')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123')
->assertErrorPage(404);
});
}
/**
* Test domain info page (existing domain)
*
* @depends testDomainInfo404
*/
public function testDomainInfo(): void
{
$this->browse(function ($browser) {
// Unconfirmed domain
$domain = Domain::where('namespace', 'kolab.org')->first();
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
+ $domain->setSetting('spf_whitelist', \json_encode(['.test.com']));
+
$browser->visit('/domain/' . $domain->id)
->on(new DomainInfo())
->whenAvailable('@verify', function ($browser) use ($domain) {
$browser->assertSeeIn('pre', $domain->namespace)
->assertSeeIn('pre', $domain->hash())
->click('button')
->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.');
// TODO: Test scenario when a domain confirmation failed
})
->whenAvailable('@config', function ($browser) use ($domain) {
$browser->assertSeeIn('pre', $domain->namespace);
})
->assertMissing('@verify');
// Check that confirmed domain page contains only the config box
$browser->visit('/domain/' . $domain->id)
->on(new DomainInfo())
->assertMissing('@verify')
->assertPresent('@config');
});
}
+ /**
+ * Test domain settings
+ */
+ public function testDomainSettings(): void
+ {
+ $this->browse(function ($browser) {
+ $domain = Domain::where('namespace', 'kolab.org')->first();
+ $domain->setSetting('spf_whitelist', \json_encode(['.test.com']));
+
+ $browser->visit('/domain/' . $domain->id)
+ ->on(new DomainInfo())
+ ->assertElementsCount('@nav a', 2)
+ ->assertSeeIn('@nav #tab-general', 'Domain configuration')
+ ->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->with('#settings form', function (Browser $browser) {
+ // Test whitelist widget
+ $widget = new ListInput('#spf_whitelist');
+
+ $browser->assertSeeIn('div.row:nth-child(1) label', 'SPF Whitelist')
+ ->assertVisible('div.row:nth-child(1) .list-input')
+ ->with($widget, function (Browser $browser) {
+ $browser->assertListInputValue(['.test.com'])
+ ->assertValue('@input', '')
+ ->addListEntry('invalid domain');
+ })
+ ->click('button[type=submit]')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->with($widget, function (Browser $browser) {
+ $err = 'The entry format is invalid. Expected a domain name starting with a dot.';
+ $browser->assertFormError(2, $err, false)
+ ->removeListEntry(2)
+ ->removeListEntry(1)
+ ->addListEntry('.new.domain.tld');
+ })
+ ->click('button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Domain settings updated successfully.');
+ });
+ });
+ }
+
/**
* Test domains list page (unauthenticated)
*/
public function testDomainListUnauth(): void
{
// Test that the page requires authentication
$this->browse(function ($browser) {
$browser->visit('/logout')
->visit('/domains')
->on(new Home());
});
}
/**
* Test domains list page
*
* @depends testDomainListUnauth
*/
public function testDomainList(): void
{
$this->browse(function ($browser) {
// Login the user
$browser->visit('/login')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
// On dashboard click the "Domains" link
->on(new Dashboard())
->assertSeeIn('@links a.link-domains', 'Domains')
->click('@links a.link-domains')
// On Domains List page click the domain entry
->on(new DomainList())
->waitFor('@table tbody tr')
->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-success')
->assertText('@table tbody tr:first-child td:first-child svg title', 'Active')
->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org')
->assertMissing('@table tfoot')
->click('@table tbody tr:first-child td:first-child a')
// On Domain Info page verify that's the clicked domain
->on(new DomainInfo())
->whenAvailable('@config', function ($browser) {
$browser->assertSeeIn('pre', 'kolab.org');
});
});
// TODO: Test domains list acting as Ned (John's "delegatee")
}
/**
* Test domains list page (user with no domains)
*/
public function testDomainListEmpty(): void
{
$this->browse(function ($browser) {
// Login the user
$browser->visit('/login')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertVisible('@links a.link-profile')
->assertMissing('@links a.link-domains')
->assertMissing('@links a.link-users')
->assertMissing('@links a.link-wallet');
/*
// On dashboard click the "Domains" link
->assertSeeIn('@links a.link-domains', 'Domains')
->click('@links a.link-domains')
// On Domains List page click the domain entry
->on(new DomainList())
->assertMissing('@table tbody')
->assertSeeIn('tfoot td', 'There are no domains in this account.');
*/
});
}
}
diff --git a/src/tests/Browser/Pages/Admin/Domain.php b/src/tests/Browser/Pages/Admin/Domain.php
index 5c8d3d63..a7f9a20a 100644
--- a/src/tests/Browser/Pages/Admin/Domain.php
+++ b/src/tests/Browser/Pages/Admin/Domain.php
@@ -1,58 +1,59 @@
<?php
namespace Tests\Browser\Pages\Admin;
use Laravel\Dusk\Page;
class Domain extends Page
{
protected $domainid;
/**
* Object constructor.
*
* @param int $domainid Domain Id
*/
public function __construct($domainid)
{
$this->domainid = $domainid;
}
/**
* Get the URL for the page.
*
* @return string
*/
public function url(): string
{
return '/domain/' . $this->domainid;
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function assert($browser): void
{
$browser->waitForLocation($this->url())
->waitFor('@domain-info');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
return [
'@app' => '#app',
'@domain-info' => '#domain-info',
'@nav' => 'ul.nav-tabs',
'@domain-config' => '#domain-config',
+ '@domain-settings' => '#domain-settings',
];
}
}
diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php
index 120c67bd..63dead31 100644
--- a/src/tests/Browser/Pages/Admin/User.php
+++ b/src/tests/Browser/Pages/Admin/User.php
@@ -1,64 +1,65 @@
<?php
namespace Tests\Browser\Pages\Admin;
use Laravel\Dusk\Page;
class User extends Page
{
protected $userid;
/**
* Object constructor.
*
* @param int $userid User Id
*/
public function __construct($userid)
{
$this->userid = $userid;
}
/**
* Get the URL for the page.
*
* @return string
*/
public function url(): string
{
return '/user/' . $this->userid;
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function assert($browser): void
{
$browser->waitForLocation($this->url())
->waitUntilMissing('@app .app-loader')
->waitFor('@user-info');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
return [
'@app' => '#app',
'@user-info' => '#user-info',
'@nav' => 'ul.nav-tabs',
'@user-finances' => '#user-finances',
'@user-aliases' => '#user-aliases',
'@user-subscriptions' => '#user-subscriptions',
'@user-distlists' => '#user-distlists',
'@user-domains' => '#user-domains',
'@user-users' => '#user-users',
+ '@user-settings' => '#user-settings',
];
}
}
diff --git a/src/tests/Browser/Pages/DomainInfo.php b/src/tests/Browser/Pages/DomainInfo.php
index b7f989d3..13820c3c 100644
--- a/src/tests/Browser/Pages/DomainInfo.php
+++ b/src/tests/Browser/Pages/DomainInfo.php
@@ -1,45 +1,47 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Page;
class DomainInfo extends Page
{
/**
* Get the URL for the page.
*
* @return string
*/
public function url(): string
{
return '';
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function assert($browser)
{
$browser->waitUntilMissing('@app .app-loader');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
return [
'@app' => '#app',
'@config' => '#domain-config',
- '@verify' => '#domain-verify',
+ '@nav' => 'ul.nav-tabs',
+ '@settings' => '#settings',
'@status' => '#status-box',
+ '@verify' => '#domain-verify',
];
}
}
diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php
index 53046e82..d67112ce 100644
--- a/src/tests/Browser/Pages/UserInfo.php
+++ b/src/tests/Browser/Pages/UserInfo.php
@@ -1,47 +1,49 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Page;
class UserInfo extends Page
{
/**
* Get the URL for the page.
*
* @return string
*/
public function url(): string
{
return '';
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function assert($browser)
{
$browser->waitFor('@form')
->waitUntilMissing('.app-loader');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
return [
'@app' => '#app',
'@form' => '#user-info form',
+ '@nav' => 'ul.nav-tabs',
'@packages' => '#user-packages',
+ '@settings' => '#settings',
'@skus' => '#user-skus',
'@status' => '#status-box',
];
}
}
diff --git a/src/tests/Browser/Reseller/DomainTest.php b/src/tests/Browser/Reseller/DomainTest.php
index 2d6ea5ef..85b3ccf1 100644
--- a/src/tests/Browser/Reseller/DomainTest.php
+++ b/src/tests/Browser/Reseller/DomainTest.php
@@ -1,120 +1,120 @@
<?php
namespace Tests\Browser\Reseller;
use App\Domain;
use Tests\Browser;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\Domain as DomainPage;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class DomainTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
parent::tearDown();
}
/**
* Test domain info page (unauthenticated)
*/
public function testDomainUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('kolab.org');
$browser->visit('/domain/' . $domain->id)->on(new Home());
});
}
/**
* Test domain info page
*/
public function testDomainInfo(): void
{
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('kolab.org');
$domain_page = new DomainPage($domain->id);
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
$user = $this->getTestUser('john@kolab.org');
$user_page = new UserPage($user->id);
// Goto the domain page
$browser->visit(new Home())
->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->visit($user_page)
->on($user_page)
->click('@nav #tab-domains')
->pause(1000)
->click('@user-domains table tbody tr:first-child td a');
$browser->on($domain_page)
->assertSeeIn('@domain-info .card-title', 'kolab.org')
->with('@domain-info form', function (Browser $browser) use ($domain) {
$browser->assertElementsCount('.row', 2)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 1);
+ ->assertElementsCount('@nav a', 2);
// Assert Configuration tab
$browser->assertSeeIn('@nav #tab-config', 'Configuration')
->with('@domain-config', function (Browser $browser) {
$browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.')
->assertSeeIn('pre#dns-config', 'kolab.org.');
});
});
}
/**
* Test suspending/unsuspending a domain
*
* @depends testDomainInfo
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('domainscontroller.com', [
'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE
| Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED
| Domain::STATUS_VERIFIED,
'type' => Domain::TYPE_EXTERNAL,
]);
$browser->visit(new DomainPage($domain->id))
->assertVisible('@domain-info #button-suspend')
->assertMissing('@domain-info #button-unsuspend')
->click('@domain-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.')
->assertSeeIn('@domain-info #status span.text-warning', 'Suspended')
->assertMissing('@domain-info #button-suspend')
->click('@domain-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.')
->assertSeeIn('@domain-info #status span.text-success', 'Active')
->assertVisible('@domain-info #button-suspend')
->assertMissing('@domain-info #button-unsuspend');
});
}
}
diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php
index bb7f4d10..ac70bcf3 100644
--- a/src/tests/Browser/Reseller/UserTest.php
+++ b/src/tests/Browser/Reseller/UserTest.php
@@ -1,473 +1,473 @@
<?php
namespace Tests\Browser\Reseller;
use App\Auth\SecondFactor;
use App\Discount;
use App\Sku;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testUserUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$browser->visit('/user/' . $jack->id)->on(new Home());
});
}
/**
* Test user info page
*/
public function testUserInfo(): void
{
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$page = new UserPage($jack->id);
$browser->visit(new Home())
->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->visit($page)
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $jack->email)
->with('@user-info form', function (Browser $browser) use ($jack) {
$browser->assertElementsCount('.row', 7)
->assertSeeIn('.row:nth-child(1) label', 'Managed by')
->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org')
->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})")
->assertSeeIn('.row:nth-child(3) label', 'Status')
->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(4) label', 'First Name')
->assertSeeIn('.row:nth-child(4) #first_name', 'Jack')
->assertSeeIn('.row:nth-child(5) label', 'Last Name')
->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels')
->assertSeeIn('.row:nth-child(6) label', 'External Email')
->assertMissing('.row:nth-child(6) #external_email a')
->assertSeeIn('.row:nth-child(7) label', 'Country')
->assertSeeIn('.row:nth-child(7) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 6);
+ ->assertElementsCount('@nav a', 7);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '5,00 CHF/month')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,90 CHF/month')
->assertMissing('table tfoot')
->assertMissing('#reset2fa');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
});
}
/**
* Test user info page (continue)
*
* @depends testUserInfo
*/
public function testUserInfo2(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$page = new UserPage($john->id);
$discount = Discount::where('code', 'TEST')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($john->wallets->first());
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $john->email)
->with('@user-info form', function (Browser $browser) use ($john) {
$ext_email = $john->getSetting('external_email');
$browser->assertElementsCount('.row', 9)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(3) label', 'First Name')
->assertSeeIn('.row:nth-child(3) #first_name', 'John')
->assertSeeIn('.row:nth-child(4) label', 'Last Name')
->assertSeeIn('.row:nth-child(4) #last_name', 'Doe')
->assertSeeIn('.row:nth-child(5) label', 'Organization')
->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers')
->assertSeeIn('.row:nth-child(6) label', 'Phone')
->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone'))
->assertSeeIn('.row:nth-child(7) label', 'External Email')
->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email)
->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email")
->assertSeeIn('.row:nth-child(8) label', 'Address')
->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address'))
->assertSeeIn('.row:nth-child(9) label', 'Country')
->assertSeeIn('.row:nth-child(9) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 6);
+ ->assertElementsCount('@nav a', 7);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (1)')
->click('@nav #tab-domains')
->with('@user-domains table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertMissing('tfoot');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)')
->click('@nav #tab-distlists')
->with('@user-distlists table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'group-test@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger')
->assertMissing('tfoot');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (4)')
->click('@nav #tab-users')
->with('@user-users table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
->assertMissing('tfoot');
});
});
// Now we go to Ned's info page, he's a controller on John's wallet
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
$page = new UserPage($ned->id);
$browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $ned->email)
->with('@user-info form', function (Browser $browser) use ($ned) {
$browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})");
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 6);
+ ->assertElementsCount('@nav a', 7);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'This user has no email aliases.');
});
// Assert Subscriptions tab, we expect John's discount here
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 5)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher')
->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth');
});
// We don't expect John's domains here
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// We don't expect John's users here
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// We don't expect John's distribution lists here
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
});
}
/**
* Test editing an external email
*
* @depends testUserInfo2
*/
public function testExternalEmail(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->waitFor('@user-info #external_email button')
->click('@user-info #external_email button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'External Email')
->assertFocused('@body input')
->assertValue('@body input', 'john.doe.external@gmail.com')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#email-dialog')
->click('@user-info #external_email button')
// Test email validation error handling, and email update
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->type('@body input', 'test')
->click('@button-action')
->waitFor('@body input.is-invalid')
->assertSeeIn(
'@body input + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('@body input', 'test@test.com')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->assertSeeIn('@user-info #external_email a', 'test@test.com')
->click('@user-info #external_email button')
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertValue('@body input', 'test@test.com')
->assertMissing('@body input.is-invalid')
->assertMissing('@body input + .invalid-feedback')
->click('@button-cancel');
})
->assertSeeIn('@user-info #external_email a', 'test@test.com');
// $john->getSetting() may not work here as it uses internal cache
// read the value form database
$current_ext_email = $john->settings()->where('key', 'external_email')->first()->value;
$this->assertSame('test@test.com', $current_ext_email);
});
}
/**
* Test suspending/unsuspending the user
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend')
->click('@user-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.')
->assertSeeIn('@user-info #status span.text-warning', 'Suspended')
->assertMissing('@user-info #button-suspend')
->click('@user-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.')
->assertSeeIn('@user-info #status span.text-success', 'Active')
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend');
});
}
/**
* Test resetting 2FA for the user
*/
public function testReset2FA(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
- $sku2fa = Sku::firstOrCreate(['title' => '2fa']);
+ $sku2fa = Sku::withEnvTenantContext()->where(['title' => '2fa'])->first();
$user->assignSku($sku2fa);
SecondFactor::seed('userstest1@kolabnow.com');
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) {
$browser->waitFor('#reset2fa')
->assertVisible('#sku' . $sku2fa->id);
})
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)')
->click('#reset2fa')
->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', '2-Factor Authentication Reset')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Reset')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.')
->assertMissing('#sku' . $sku2fa->id)
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)');
});
}
}
diff --git a/src/tests/Browser/StatusTest.php b/src/tests/Browser/StatusTest.php
index 781e2e76..d49e1bbd 100644
--- a/src/tests/Browser/StatusTest.php
+++ b/src/tests/Browser/StatusTest.php
@@ -1,289 +1,289 @@
<?php
namespace Tests\Browser;
use App\Domain;
use App\User;
use Carbon\Carbon;
use Tests\Browser;
use Tests\Browser\Components\Status;
use Tests\Browser\Components\Toast;
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();
$domain_status = Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED;
DB::statement("UPDATE domains SET status = (status | {$domain_status})"
. " 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
{
$domain_status = Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED;
DB::statement("UPDATE domains SET status = (status | {$domain_status})"
. " 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 and user
$domain = Domain::where('namespace', 'kolab.org')->first();
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$john = $this->getTestUser('john@kolab.org');
$john->created_at = Carbon::now();
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
}
$john->save();
$this->browse(function ($browser) use ($john, $domain) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->with(new Status(), function ($browser) use ($john) {
$browser->assertSeeIn('@body', 'We are preparing your account')
->assertProgress(71, 'Creating a mailbox...', 'pending')
->assertMissing('#status-verify')
->assertMissing('#status-link')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text');
$john->status |= User::STATUS_IMAP_READY;
$john->save();
// Wait for auto-refresh, expect domain-confirmed step
$browser->pause(6000)
->assertSeeIn('@body', 'Your account is almost ready')
->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-verify')
->assertVisible('#status-link');
})
// check if the link to domain info page works
->click('#status-link')
->on(new DomainInfo())
->back()
->on(new Dashboard())
->with(new Status(), function ($browser) {
$browser->assertMissing('@refresh-button')
->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed');
});
// Confirm the domain and wait until the whole status box disappears
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// This should take less than 10 seconds
$browser->waitUntilMissing('@status', 10);
});
// Test the Refresh button
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$john->created_at = Carbon::now()->subSeconds(3600);
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
}
$john->save();
$this->browse(function ($browser) use ($john, $domain) {
$browser->visit(new Dashboard())
->with(new Status(), function ($browser) use ($john, $domain) {
$browser->assertSeeIn('@body', 'We are preparing your account')
->assertProgress(71, 'Creating a mailbox...', 'failed')
->assertVisible('@refresh-button')
->assertVisible('@refresh-text');
if ($john->refresh()->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
$john->save();
}
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
$browser->click('@refresh-button')
->assertToast(Toast::TYPE_SUCCESS, 'Setup process finished successfully.');
})
->assertMissing('@status');
});
}
/**
* Test domain status on domains list and domain info page
*
* @depends testDashboard
*/
public function testDomainStatus(): void
{
$domain = Domain::where('namespace', 'kolab.org')->first();
$domain->created_at = Carbon::now();
$domain->status = Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY;
$domain->save();
// side-step
$this->assertFalse($domain->isNew());
$this->assertTrue($domain->isActive());
$this->assertTrue($domain->isLdapReady());
$this->assertTrue($domain->isExternal());
$this->assertFalse($domain->isHosted());
$this->assertFalse($domain->isConfirmed());
$this->assertFalse($domain->isVerified());
$this->assertFalse($domain->isSuspended());
$this->assertFalse($domain->isDeleted());
$this->browse(function ($browser) use ($domain) {
// Test auto-refresh
$browser->on(new Dashboard())
->click('@links a.link-domains')
->on(new DomainList())
->waitFor('@table tbody tr')
// 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())
->with(new Status(), function ($browser) {
$browser->assertSeeIn('@body', 'We are preparing the domain')
->assertProgress(50, 'Verifying a custom domain...', 'pending')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-link')
->assertMissing('#status-verify');
});
$domain->status |= Domain::STATUS_VERIFIED;
$domain->save();
// This should take less than 10 seconds
$browser->waitFor('@status.process-failed')
->with(new Status(), function ($browser) {
$browser->assertSeeIn('@body', 'The domain is almost ready')
->assertProgress(75, 'Verifying an ownership of a custom domain...', 'failed')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-link')
->assertVisible('#status-verify');
});
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// Test Verify button
$browser->click('@status #status-verify')
->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.')
->waitUntilMissing('@status')
- ->assertMissing('@verify')
+ ->waitUntilMissing('@verify')
->assertVisible('@config');
});
}
/**
* Test user status on users list and user info page
*
* @depends testDashboard
*/
public function testUserStatus(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->created_at = Carbon::now();
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
}
$john->save();
$domain = Domain::where('namespace', 'kolab.org')->first();
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$this->browse(function ($browser) use ($john, $domain) {
$browser->visit(new Dashboard())
->click('@links a.link-users')
->on(new UserList())
->waitFor('@table tbody tr')
// 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(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 state in the user edit form
$browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) #status', 'Not Ready');
})
->with(new Status(), function ($browser) use ($john) {
$browser->assertSeeIn('@body', 'We are preparing the user account')
->assertProgress(71, 'Creating a mailbox...', 'pending')
->assertMissing('#status-verify')
->assertMissing('#status-link')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text');
$john->status |= User::STATUS_IMAP_READY;
$john->save();
// Wait for auto-refresh, expect domain-confirmed step
$browser->pause(6000)
->assertSeeIn('@body', 'The user account is almost ready')
->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-verify')
->assertVisible('#status-link');
})
->assertSeeIn('#status', 'Active');
// Confirm the domain and wait until the whole status box disappears
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// This should take less than 10 seconds
$browser->waitUntilMissing('@status', 10);
});
}
}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
index 116f4bdb..33276e90 100644
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -1,734 +1,761 @@
<?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',
'organization' => 'Kolab Developers',
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
$activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete();
Entitlement::where('cost', '>=', 5000)->delete();
Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
$activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete();
Entitlement::where('cost', '>=', 5000)->delete();
Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user 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->waitFor('tbody tr')
->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org')
->assertMissing('tfoot');
});
});
}
/**
* 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(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', 'Organization')
->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization'])
->assertSeeIn('div.row:nth-child(5) label', 'Email')
->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org')
->assertDisabled('div.row:nth-child(5) input[type=text]')
->assertSeeIn('div.row:nth-child(6) label', 'Email Aliases')
->assertVisible('div.row:nth-child(6) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['john.doe@kolab.org'])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(7) label', 'Password')
->assertValue('div.row:nth-child(7) input[type=password]', '')
->assertSeeIn('div.row:nth-child(8) label', 'Confirm Password')
->assertValue('div.row:nth-child(8) input[type=password]', '')
->assertSeeIn('button[type=submit]', 'Submit')
// Clear some fields and submit
->vueClear('#first_name')
->vueClear('#last_name')
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@form', function (Browser $browser) {
// Test error handling (password)
$browser->type('#password', 'aaaaaa')
->vueClear('#password_confirmation')
->click('button[type=submit]')
->waitFor('#password + .invalid-feedback')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.')
->assertFocused('#password')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
// TODO: Test password change
// Test form error handling (aliases)
$browser->vueClear('#password')
->vueClear('#password_confirmation')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(2, 'The specified alias is invalid.', false);
});
// Test adding aliases
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(2)
->addListEntry('john.test@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$john = User::where('email', 'john@kolab.org')->first();
$alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first();
$this->assertTrue(!empty($alias));
// Test subscriptions
$browser->with('@form', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions')
->assertVisible('@skus.row:nth-child(9)')
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox')
->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 CHF/month')
->assertChecked('tbody tr:nth-child(1) td.selection input')
->assertDisabled('tbody tr:nth-child(1) td.selection input')
->assertTip(
'tbody tr:nth-child(1) td.buttons button',
'Just a mailbox'
)
// Storage SKU
->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota')
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(2) td.selection input')
->assertDisabled('tbody tr:nth-child(2) td.selection input')
->assertTip(
'tbody tr:nth-child(2) td.buttons button',
'Some wiggle room'
)
->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) {
$browser->assertQuotaValue(5)->setQuotaValue(6);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features')
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,90 CHF/month')
->assertChecked('tbody tr:nth-child(3) td.selection input')
->assertEnabled('tbody tr:nth-child(3) td.selection input')
->assertTip(
'tbody tr:nth-child(3) td.buttons button',
'Groupware functions like Calendar, Tasks, Notes, etc.'
)
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync')
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(4) td.selection input')
->assertEnabled('tbody tr:nth-child(4) td.selection input')
->assertTip(
'tbody tr:nth-child(4) td.buttons button',
'Mobile synchronization'
)
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication')
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(5) td.selection input')
->assertEnabled('tbody tr:nth-child(5) td.selection input')
->assertTip(
'tbody tr:nth-child(5) td.buttons button',
'Two factor authentication for webmail and administration panel'
)
// Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(6) td.selection input')
->assertEnabled('tbody tr:nth-child(6) td.selection input')
->assertTip(
'tbody tr:nth-child(6) td.buttons button',
'Video conferencing tool'
)
->click('tbody tr:nth-child(4) td.selection input');
})
->assertMissing('@skus table + .hint')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$expected = ['activesync', 'groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage', 'storage'];
$this->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 'meet', expect an alert
->click('#sku-input-meet')
->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-meet')
// Check '2FA', expect 'activesync' unchecked and readonly
->click('#sku-input-2fa')
->assertChecked('#sku-input-2fa')
->assertNotChecked('#sku-input-activesync')
->assertReadonly('#sku-input-activesync')
// Uncheck '2FA'
->click('#sku-input-2fa')
->assertNotChecked('#sku-input-2fa')
->assertNotReadonly('#sku-input-activesync');
});
});
});
}
+ /**
+ * Test user settings tab
+ *
+ * @depends testInfo
+ */
+ public function testUserSettings(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $john->setSetting('greylisting', null);
+
+ $this->browse(function (Browser $browser) {
+ $browser->on(new UserInfo())
+ ->assertElementsCount('@nav a', 2)
+ ->assertSeeIn('@nav #tab-general', 'General')
+ ->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->with('#settings form', function (Browser $browser) {
+ $browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting')
+ ->click('div.row:nth-child(1) input[type=checkbox]:checked')
+ ->click('button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.');
+ });
+ });
+
+ $this->assertSame('false', $john->fresh()->getSetting('greylisting'));
+ }
+
/**
* 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', 'Organization')
->assertValue('div.row:nth-child(3) input[type=text]', '')
->assertSeeIn('div.row:nth-child(4) label', 'Email')
->assertValue('div.row:nth-child(4) input[type=text]', '')
->assertEnabled('div.row:nth-child(4) input[type=text]')
->assertSeeIn('div.row:nth-child(5) label', 'Email Aliases')
->assertVisible('div.row:nth-child(5) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue([])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(6) label', 'Password')
->assertValue('div.row:nth-child(6) input[type=password]', '')
->assertSeeIn('div.row:nth-child(7) label', 'Confirm Password')
->assertValue('div.row:nth-child(7) input[type=password]', '')
->assertSeeIn('div.row:nth-child(8) label', 'Package')
// assert packages list widget, select "Lite Account"
->with('@packages', function ($browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account')
->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account')
->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 CHF/month')
->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 CHF/month')
->assertChecked('tbody tr:nth-child(1) input')
->click('tbody tr:nth-child(2) input')
->assertNotChecked('tbody tr:nth-child(1) input')
->assertChecked('tbody tr:nth-child(2) input');
})
->assertMissing('@packages table + .hint')
->assertSeeIn('button[type=submit]', 'Submit');
// Test browser-side required fields and error handling
$browser->click('button[type=submit]')
->assertFocused('#email')
->type('#email', 'invalid email')
->click('button[type=submit]')
->assertFocused('#password')
->type('#password', 'simple123')
->click('button[type=submit]')
->assertFocused('#password_confirmation')
->type('#password_confirmation', 'simple')
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.');
});
// Test form error handling (aliases)
$browser->with('@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]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(1, 'The specified alias is invalid.', false);
});
});
// Successful account creation
$browser->with('@form', function (Browser $browser) {
$browser->type('#first_name', 'Julia')
->type('#last_name', 'Roberts')
->type('#organization', 'Test Org')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(1)
->addListEntry('julia.roberts2@kolab.org');
})
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.')
// check redirection to users list
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 5)
->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first();
$this->assertTrue(!empty($alias));
$this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']);
$this->assertSame('Julia', $julia->getSetting('first_name'));
$this->assertSame('Roberts', $julia->getSetting('last_name'));
$this->assertSame('Test Org', $julia->getSetting('organization'));
// Some additional tests for the list input widget
$browser->click('tbody tr:nth-child(4) a')
->on(new UserInfo())
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['julia.roberts2@kolab.org'])
->addListEntry('invalid address')
->type('.input-group:nth-child(2) input', '@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertVisible('.input-group:nth-child(2) input.is-invalid')
->assertVisible('.input-group:nth-child(3) input.is-invalid')
->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org')
->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all();
$this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases);
});
}
/**
* Test user delete
*
* @depends testNewUser
*/
public function testDeleteUser(): void
{
// First create a new user
$john = $this->getTestUser('john@kolab.org');
$julia = $this->getTestUser('julia.roberts@kolab.org');
$package_kolab = \App\Package::where('title', 'kolab')->first();
$john->assignPackage($package_kolab, $julia);
// Test deleting non-controller user
$this->browse(function (Browser $browser) use ($julia) {
$browser->visit('/user/' . $julia->id)
->on(new UserInfo())
->assertSeeIn('button.button-delete', 'Delete user')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org')
->assertFocused('@button-cancel')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Delete')
->click('@button-cancel');
})
->waitUntilMissing('#delete-warning')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->click('@button-action');
})
->waitUntilMissing('#delete-warning')
->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.')
->on(new UserList())
->with('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$this->assertTrue(empty($julia));
});
// Test that non-controller user cannot see/delete himself on the users list
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->visit('/users')
->assertErrorPage(403);
});
// Test that controller user (Ned) can see all the users
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('ned@kolab.org', 'simple123', true)
->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4);
});
// TODO: Test the delete action in details
});
// TODO: Test what happens with the logged in user session after he's been deleted by another user
}
/**
* Test discounted sku/package prices in the UI
*/
public function testDiscountedPrices(): void
{
// Add 10% discount
$discount = Discount::where('code', 'TEST')->first();
$john = User::where('email', 'john@kolab.org')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->save();
// SKUs on user edit page
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->visit(new UserList())
->waitFor('@table tr:nth-child(2)')
->click('@table tr:nth-child(2) a') // joe@kolab.org
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '4,50 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(100);
})
->assertSeeIn('tr:nth-child(2) td.price', '21,37 CHF/month¹')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,41 CHF/month¹')
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹')
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Packages on new user page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->click('button.create-user')
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@packages', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) .price', '8,91 CHF/month¹') // Groupware
->assertSeeIn('tbody tr:nth-child(2) .price', '4,50 CHF/month¹'); // Lite
})
->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Test using entitlement cost instead of the SKU cost
$this->browse(function (Browser $browser) use ($wallet) {
$joe = User::where('email', 'joe@kolab.org')->first();
$beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
// Add an extra storage and beta entitlement with different prices
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $beta_sku->id,
'cost' => 5010,
'entitleable_id' => $joe->id,
'entitleable_type' => User::class
]);
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $storage_sku->id,
'cost' => 5000,
'entitleable_id' => $joe->id,
'entitleable_type' => User::class
]);
$browser->visit('/user/' . $joe->id)
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
// Beta SKU
->assertSeeIn('tbody tr:nth-child(7) td.price', '45,09 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(7);
})
->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(5);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
}
/**
* Test beta entitlements
*
* @depends testList
*/
public function testBetaEntitlements(): void
{
$this->browse(function (Browser $browser) {
$john = User::where('email', 'john@kolab.org')->first();
$sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$john->assignSku($sku);
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 8)
// Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(6) td.selection input')
->assertEnabled('tbody tr:nth-child(6) td.selection input')
->assertTip(
'tbody tr:nth-child(6) td.buttons button',
'Video conferencing tool'
)
// Beta SKU
->assertSeeIn('tbody tr:nth-child(7) td.name', 'Private Beta (invitation only)')
->assertSeeIn('tbody tr:nth-child(7) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(7) td.selection input')
->assertEnabled('tbody tr:nth-child(7) td.selection input')
->assertTip(
'tbody tr:nth-child(7) td.buttons button',
'Access to the private beta program subscriptions'
)
// Distlist SKU
->assertSeeIn('tbody tr:nth-child(8) td.name', 'Distribution lists')
->assertSeeIn('tr:nth-child(8) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(8) td.selection input')
->assertEnabled('tbody tr:nth-child(8) td.selection input')
->assertTip(
'tbody tr:nth-child(8) td.buttons button',
'Access to mail distribution lists'
)
// Check Distlist, Uncheck Beta, expect Distlist unchecked
->click('#sku-input-distlist')
->click('#sku-input-beta')
->assertNotChecked('#sku-input-beta')
->assertNotChecked('#sku-input-distlist')
// Click Distlist expect an alert
->click('#sku-input-distlist')
->assertDialogOpened('Distribution lists requires Private Beta (invitation only).')
->acceptDialog()
// Enable Beta and Distlist and submit
->click('#sku-input-beta')
->click('#sku-input-distlist');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$expected = [
'beta',
'distlist',
'groupware',
'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage'
];
$this->assertUserEntitlements($john, $expected);
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->click('#sku-input-beta')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$expected = [
'groupware',
'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage'
];
$this->assertUserEntitlements($john, $expected);
});
// TODO: Test that the Distlist SKU is not available for users that aren't a group account owners
// TODO: Test that entitlements change has immediate effect on the available items in dashboard
// i.e. does not require a page reload nor re-login.
}
}
diff --git a/src/tests/Feature/Auth/SecondFactorTest.php b/src/tests/Feature/Auth/SecondFactorTest.php
index 68bc4067..124ce306 100644
--- a/src/tests/Feature/Auth/SecondFactorTest.php
+++ b/src/tests/Feature/Auth/SecondFactorTest.php
@@ -1,63 +1,63 @@
<?php
namespace Tests\Feature\Auth;
use App\Auth\SecondFactor;
use App\Entitlement;
use App\Sku;
use App\User;
use Tests\TestCase;
class SecondFactorTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('entitlement-test@kolabnow.com');
}
public function tearDown(): void
{
$this->deleteTestUser('entitlement-test@kolabnow.com');
parent::tearDown();
}
/**
* Test that 2FA config is removed from Roundcube database
* on entitlement delete
*/
public function testEntitlementDelete(): void
{
// Create the user, and assign 2FA to him, and add Roundcube setup
- $sku_2fa = Sku::where('title', '2fa')->first();
+ $sku_2fa = Sku::withEnvTenantContext()->where('title', '2fa')->first();
$user = $this->getTestUser('entitlement-test@kolabnow.com');
$user->assignSku($sku_2fa);
SecondFactor::seed('entitlement-test@kolabnow.com');
$entitlement = Entitlement::where('sku_id', $sku_2fa->id)
->where('entitleable_id', $user->id)
->first();
$this->assertTrue(!empty($entitlement));
$sf = new SecondFactor($user);
$factors = $sf->factors();
$this->assertCount(1, $factors);
$this->assertSame('totp:8132a46b1f741f88de25f47e', $factors[0]);
// $this->assertSame('dummy:dummy', $factors[1]);
// Delete the entitlement, expect all configured 2FA methods in Roundcube removed
$entitlement->delete();
$this->assertTrue($entitlement->trashed());
$sf = new SecondFactor($user);
$factors = $sf->factors();
$this->assertCount(0, $factors);
}
}
diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php
index 74aec7f5..a5f0a74a 100644
--- a/src/tests/Feature/Controller/Admin/UsersTest.php
+++ b/src/tests/Feature/Controller/Admin/UsersTest.php
@@ -1,385 +1,385 @@
<?php
namespace Tests\Feature\Controller\Admin;
use App\Auth\SecondFactor;
use App\Sku;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class UsersTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('test@testsearch.com');
$this->deleteTestDomain('testsearch.com');
$this->deleteTestGroup('group-test@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$jack->setSetting('external_email', null);
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('test@testsearch.com');
$this->deleteTestDomain('testsearch.com');
$jack = $this->getTestUser('jack@kolab.org');
$jack->setSetting('external_email', null);
parent::tearDown();
}
/**
* Test user deleting (DELETE /api/v4/users/<id>)
*/
public function testDestroy(): void
{
$john = $this->getTestUser('john@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauth access
$response = $this->delete("api/v4/users/{$user->id}");
$response->assertStatus(401);
// The end-point does not exist
$response = $this->actingAs($admin)->delete("api/v4/users/{$user->id}");
$response->assertStatus(404);
}
/**
* Test users searching (/api/v4/users)
*/
public function testIndex(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
// Non-admin user
$response = $this->actingAs($user)->get("api/v4/users");
$response->assertStatus(403);
// Search with no search criteria
$response = $this->actingAs($admin)->get("api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
// Search with no matches expected
$response = $this->actingAs($admin)->get("api/v4/users?search=abcd1234efgh5678");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
// Search by domain
$response = $this->actingAs($admin)->get("api/v4/users?search=kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by user ID
$response = $this->actingAs($admin)->get("api/v4/users?search={$user->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by email (primary)
$response = $this->actingAs($admin)->get("api/v4/users?search=john@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by email (alias)
$response = $this->actingAs($admin)->get("api/v4/users?search=john.doe@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by email (external), expect two users in a result
$jack = $this->getTestUser('jack@kolab.org');
$jack->setSetting('external_email', 'john.doe.external@gmail.com');
$response = $this->actingAs($admin)->get("api/v4/users?search=john.doe.external@gmail.com");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(2, $json['count']);
$this->assertCount(2, $json['list']);
$emails = array_column($json['list'], 'email');
$this->assertContains($user->email, $emails);
$this->assertContains($jack->email, $emails);
// Search by owner
$response = $this->actingAs($admin)->get("api/v4/users?owner={$user->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(4, $json['count']);
$this->assertCount(4, $json['list']);
// Search by owner (Ned is a controller on John's wallets,
// here we expect only users assigned to Ned's wallet(s))
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($admin)->get("api/v4/users?owner={$ned->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
// Search by distribution list email
$response = $this->actingAs($admin)->get("api/v4/users?search=group-test@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Deleted users/domains
$domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]);
$user = $this->getTestUser('test@testsearch.com');
$plan = \App\Plan::where('title', 'group')->first();
$user->assignPlan($plan, $domain);
$user->setAliases(['alias@testsearch.com']);
Queue::fake();
$user->delete();
$response = $this->actingAs($admin)->get("api/v4/users?search=test@testsearch.com");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
$response = $this->actingAs($admin)->get("api/v4/users?search=alias@testsearch.com");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
$response = $this->actingAs($admin)->get("api/v4/users?search=testsearch.com");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
}
/**
* Test reseting 2FA (POST /api/v4/users/<user-id>/reset2FA)
*/
public function testReset2FA(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
- $sku2fa = Sku::firstOrCreate(['title' => '2fa']);
+ $sku2fa = Sku::withEnvTenantContext()->where(['title' => '2fa'])->first();
$user->assignSku($sku2fa);
SecondFactor::seed('userscontrollertest1@userscontroller.com');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []);
$response->assertStatus(403);
$entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get();
$this->assertCount(1, $entitlements);
$sf = new SecondFactor($user);
$this->assertCount(1, $sf->factors());
// Test reseting 2FA
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("2-Factor authentication reset successfully.", $json['message']);
$this->assertCount(2, $json);
$entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get();
$this->assertCount(0, $entitlements);
$sf = new SecondFactor($user);
$this->assertCount(0, $sf->factors());
}
/**
* Test user creation (POST /api/v4/users)
*/
public function testStore(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// The end-point does not exist
$response = $this->actingAs($admin)->post("/api/v4/users", []);
$response->assertStatus(404);
}
/**
* Test user suspending (POST /api/v4/users/<user-id>/suspend)
*/
public function testSuspend(): void
{
Queue::fake(); // disable jobs
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/suspend", []);
$response->assertStatus(403);
$this->assertFalse($user->isSuspended());
// Test suspending the user
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User suspended successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertTrue($user->fresh()->isSuspended());
}
/**
* Test user un-suspending (POST /api/v4/users/<user-id>/unsuspend)
*/
public function testUnsuspend(): void
{
Queue::fake(); // disable jobs
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/unsuspend", []);
$response->assertStatus(403);
$this->assertFalse($user->isSuspended());
$user->suspend();
$this->assertTrue($user->isSuspended());
// Test suspending the user
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User unsuspended successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertFalse($user->fresh()->isSuspended());
}
/**
* Test user update (PUT /api/v4/users/<user-id>)
*/
public function testUpdate(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", []);
$response->assertStatus(403);
// Test updatig the user data (empty data)
$response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertCount(2, $json);
// Test error handling
$post = ['external_email' => 'aaa'];
$response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The external email must be a valid email address.", $json['errors']['external_email'][0]);
$this->assertCount(2, $json);
// Test real update
$post = ['external_email' => 'modified@test.com'];
$response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertSame('modified@test.com', $user->getSetting('external_email'));
}
}
diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php
index ae480297..ae8fbc27 100644
--- a/src/tests/Feature/Controller/DomainsTest.php
+++ b/src/tests/Feature/Controller/DomainsTest.php
@@ -1,242 +1,321 @@
<?php
namespace Tests\Feature\Controller;
use App\Domain;
use App\Entitlement;
use App\Sku;
use App\User;
use App\Wallet;
use Illuminate\Support\Str;
use Tests\TestCase;
class DomainsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('test1@domainscontroller.com');
$this->deleteTestDomain('domainscontroller.com');
}
public function tearDown(): void
{
$this->deleteTestUser('test1@domainscontroller.com');
$this->deleteTestDomain('domainscontroller.com');
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->settings()->whereIn('key', ['spf_whitelist'])->delete();
+
parent::tearDown();
}
/**
* Test domain confirm request
*/
public function testConfirm(): void
{
$sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$user = $this->getTestUser('test1@domainscontroller.com');
$domain = $this->getTestDomain('domainscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]);
Entitlement::create([
'wallet_id' => $user->wallets()->first()->id,
'sku_id' => $sku_domain->id,
'entitleable_id' => $domain->id,
'entitleable_type' => Domain::class
]);
$response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertEquals('error', $json['status']);
$this->assertEquals('Domain ownership verification failed.', $json['message']);
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
$response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('Domain verified successfully.', $json['message']);
$this->assertTrue(is_array($json['statusInfo']));
// Not authorized access
$response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}/confirm");
$response->assertStatus(403);
// Authorized access by additional account controller
$domain = $this->getTestDomain('kolab.org');
$response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}/confirm");
$response->assertStatus(200);
}
/**
* Test fetching domains list
*/
public function testIndex(): void
{
// User with no domains
$user = $this->getTestUser('test1@domainscontroller.com');
$response = $this->actingAs($user)->get("api/v4/domains");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame([], $json);
// User with custom domain(s)
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($john)->get("api/v4/domains");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('kolab.org', $json[0]['namespace']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isConfirmed', $json[0]);
$this->assertArrayHasKey('isDeleted', $json[0]);
$this->assertArrayHasKey('isVerified', $json[0]);
$this->assertArrayHasKey('isSuspended', $json[0]);
$this->assertArrayHasKey('isActive', $json[0]);
$this->assertArrayHasKey('isLdapReady', $json[0]);
$response = $this->actingAs($ned)->get("api/v4/domains");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('kolab.org', $json[0]['namespace']);
}
+ /**
+ * Test domain config update (POST /api/v4/domains/<domain>/config)
+ */
+ public function testSetConfig(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->setSetting('spf_whitelist', null);
+
+ // Test unknown domain id
+ $post = ['spf_whitelist' => []];
+ $response = $this->actingAs($john)->post("/api/v4/domains/123/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(404);
+
+ // Test access by user not being a wallet controller
+ $post = ['spf_whitelist' => []];
+ $response = $this->actingAs($jack)->post("/api/v4/domains/{$domain->id}/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(403);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Access denied", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test some invalid data
+ $post = ['grey' => 1];
+ $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']);
+
+ $this->assertNull($domain->fresh()->getSetting('spf_whitelist'));
+
+ // Test some valid data
+ $post = ['spf_whitelist' => ['.test.domain.com']];
+ $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('Domain settings updated successfully.', $json['message']);
+
+ $expected = \json_encode($post['spf_whitelist']);
+ $this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist'));
+
+ // Test input validation
+ $post = ['spf_whitelist' => ['aaa']];
+ $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame(
+ 'The entry format is invalid. Expected a domain name starting with a dot.',
+ $json['errors']['spf_whitelist'][0]
+ );
+
+ $this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist'));
+ }
+
/**
* Test fetching domain info
*/
public function testShow(): void
{
$sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$user = $this->getTestUser('test1@domainscontroller.com');
$domain = $this->getTestDomain('domainscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]);
Entitlement::create([
'wallet_id' => $user->wallets()->first()->id,
'sku_id' => $sku_domain->id,
'entitleable_id' => $domain->id,
'entitleable_type' => Domain::class
]);
$response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals($domain->id, $json['id']);
$this->assertEquals($domain->namespace, $json['namespace']);
$this->assertEquals($domain->status, $json['status']);
$this->assertEquals($domain->type, $json['type']);
$this->assertSame($domain->hash(Domain::HASH_TEXT), $json['hash_text']);
$this->assertSame($domain->hash(Domain::HASH_CNAME), $json['hash_cname']);
$this->assertSame($domain->hash(Domain::HASH_CODE), $json['hash_code']);
- $this->assertCount(4, $json['config']);
- $this->assertTrue(strpos(implode("\n", $json['config']), $domain->namespace) !== false);
+ $this->assertSame([], $json['config']['spf_whitelist']);
+ $this->assertCount(4, $json['mx']);
+ $this->assertTrue(strpos(implode("\n", $json['mx']), $domain->namespace) !== false);
$this->assertCount(8, $json['dns']);
$this->assertTrue(strpos(implode("\n", $json['dns']), $domain->namespace) !== false);
$this->assertTrue(strpos(implode("\n", $json['dns']), $domain->hash()) !== false);
$this->assertTrue(is_array($json['statusInfo']));
// Values below are tested by Unit tests
$this->assertArrayHasKey('isConfirmed', $json);
$this->assertArrayHasKey('isDeleted', $json);
$this->assertArrayHasKey('isVerified', $json);
$this->assertArrayHasKey('isSuspended', $json);
$this->assertArrayHasKey('isActive', $json);
$this->assertArrayHasKey('isLdapReady', $json);
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
// Not authorized - Other account domain
$response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}");
$response->assertStatus(403);
$domain = $this->getTestDomain('kolab.org');
// Ned is an additional controller on kolab.org's wallet
$response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}");
$response->assertStatus(200);
// Jack has no entitlement/control over kolab.org
$response = $this->actingAs($jack)->get("api/v4/domains/{$domain->id}");
$response->assertStatus(403);
}
/**
* Test fetching domain status (GET /api/v4/domains/<domain-id>/status)
* and forcing setup process update (?refresh=1)
*
* @group dns
*/
public function testStatus(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$domain = $this->getTestDomain('kolab.org');
// Test unauthorized access
$response = $this->actingAs($jack)->get("/api/v4/domains/{$domain->id}/status");
$response->assertStatus(403);
$domain->status = Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY;
$domain->save();
// Get domain status
$response = $this->actingAs($john)->get("/api/v4/domains/{$domain->id}/status");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isVerified']);
$this->assertFalse($json['isReady']);
$this->assertCount(4, $json['process']);
$this->assertSame('domain-verified', $json['process'][2]['label']);
$this->assertSame(false, $json['process'][2]['state']);
$this->assertTrue(empty($json['status']));
$this->assertTrue(empty($json['message']));
// Now "reboot" the process and verify the domain
$response = $this->actingAs($john)->get("/api/v4/domains/{$domain->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue($json['isVerified']);
$this->assertTrue($json['isReady']);
$this->assertCount(4, $json['process']);
$this->assertSame('domain-verified', $json['process'][2]['label']);
$this->assertSame(true, $json['process'][2]['state']);
$this->assertSame('domain-confirmed', $json['process'][3]['label']);
$this->assertSame(true, $json['process'][3]['state']);
$this->assertSame('success', $json['status']);
$this->assertSame('Setup process finished successfully.', $json['message']);
// TODO: Test completing all process steps
}
}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
index 09f7673b..48422366 100644
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -1,1286 +1,1357 @@
<?php
namespace Tests\Feature\Controller;
use App\Discount;
use App\Domain;
use App\Http\Controllers\API\V4\UsersController;
use App\Package;
use App\Sku;
use App\User;
use App\Wallet;
use Carbon\Carbon;
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('jane@kolabnow.com');
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UsersControllerTest2@userscontroller.com');
$this->deleteTestUser('UsersControllerTest3@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
$this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestUser('deleted@kolab.org');
$this->deleteTestUser('deleted@kolabnow.com');
$this->deleteTestDomain('userscontroller.com');
$this->deleteTestGroup('group-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolab.org');
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->status |= User::STATUS_IMAP_READY;
$user->save();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('jane@kolabnow.com');
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UsersControllerTest2@userscontroller.com');
$this->deleteTestUser('UsersControllerTest3@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
$this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestUser('deleted@kolab.org');
$this->deleteTestUser('deleted@kolabnow.com');
$this->deleteTestDomain('userscontroller.com');
$this->deleteTestGroup('group-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolab.org');
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
+ $user->settings()->whereIn('key', ['greylisting'])->delete();
$user->status |= User::STATUS_IMAP_READY;
$user->save();
parent::tearDown();
}
/**
* 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 none of those
// However, this is not 0-regression scenario as we
// do not fully support additional controllers.
//$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}");
//$response->assertStatus(403);
$response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(200);
$response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(200);
// Note: More detailed assertions in testDestroy() above
$this->assertTrue($user1->fresh()->trashed());
$this->assertTrue($user2->fresh()->trashed());
$this->assertTrue($user3->fresh()->trashed());
}
/**
* Test user listing (GET /api/v4/users)
*/
public function testIndex(): void
{
// Test unauth access
$response = $this->get("api/v4/users");
$response->assertStatus(401);
$jack = $this->getTestUser('jack@kolab.org');
$joe = $this->getTestUser('joe@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($jack)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->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 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->assertTrue($json['config']['greylisting']);
$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::withEnvTenantContext()->where('title', 'storage')->first();
$groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first();
$this->assertCount(5, $json['skus']);
$this->assertSame(5, $json['skus'][$storage_sku->id]['count']);
$this->assertSame([0,0,0,0,0], $json['skus'][$storage_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$groupware_sku->id]['count']);
$this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']);
$this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']);
$this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']);
}
/**
* Test fetching user status (GET /api/v4/users/<user-id>/status)
* and forcing setup process update (?refresh=1)
*
* @group imap
* @group dns
*/
public function testStatus(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
// Test unauthorized access
$response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status");
$response->assertStatus(403);
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
$john->save();
}
// Get user status
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isImapReady']);
$this->assertFalse($json['isReady']);
$this->assertCount(7, $json['process']);
$this->assertSame('user-imap-ready', $json['process'][2]['label']);
$this->assertSame(false, $json['process'][2]['state']);
$this->assertTrue(empty($json['status']));
$this->assertTrue(empty($json['message']));
// Make sure the domain is confirmed (other test might unset that status)
$domain = $this->getTestDomain('kolab.org');
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// Now "reboot" the process and verify the user in imap synchronously
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue($json['isImapReady']);
$this->assertTrue($json['isReady']);
$this->assertCount(7, $json['process']);
$this->assertSame('user-imap-ready', $json['process'][2]['label']);
$this->assertSame(true, $json['process'][2]['state']);
$this->assertSame('success', $json['status']);
$this->assertSame('Setup process finished successfully.', $json['message']);
Queue::size(1);
// Test case for when the verify job is dispatched to the worker
$john->refresh();
$john->status ^= User::STATUS_IMAP_READY;
$john->save();
\config(['imap.admin_password' => null]);
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isImapReady']);
$this->assertFalse($json['isReady']);
$this->assertSame('success', $json['status']);
$this->assertSame('waiting', $json['processState']);
$this->assertSame('Setup process has been pushed. Please wait.', $json['message']);
Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1);
}
/**
* Test UsersController::statusInfo()
*/
public function testStatusInfo(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user->created_at = Carbon::now();
$user->status = User::STATUS_NEW;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertFalse($result['isReady']);
$this->assertSame([], $result['skus']);
$this->assertCount(3, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(false, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(false, $result['process'][2]['state']);
$this->assertSame('running', $result['processState']);
$user->created_at = Carbon::now()->subSeconds(181);
$user->save();
$result = UsersController::statusInfo($user);
$this->assertSame('failed', $result['processState']);
$user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertTrue($result['isReady']);
$this->assertCount(3, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$this->assertSame('done', $result['processState']);
$domain->status |= Domain::STATUS_VERIFIED;
$domain->type = Domain::TYPE_EXTERNAL;
$domain->save();
$result = UsersController::statusInfo($user);
$this->assertFalse($result['isReady']);
$this->assertSame([], $result['skus']);
$this->assertCount(7, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$this->assertSame('domain-new', $result['process'][3]['label']);
$this->assertSame(true, $result['process'][3]['state']);
$this->assertSame('domain-ldap-ready', $result['process'][4]['label']);
$this->assertSame(false, $result['process'][4]['state']);
$this->assertSame('domain-verified', $result['process'][5]['label']);
$this->assertSame(true, $result['process'][5]['state']);
$this->assertSame('domain-confirmed', $result['process'][6]['label']);
$this->assertSame(false, $result['process'][6]['state']);
// Test 'skus' property
$user->assignSku(Sku::withEnvTenantContext()->where('title', 'beta')->first());
$result = UsersController::statusInfo($user);
$this->assertSame(['beta'], $result['skus']);
$user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first());
$result = UsersController::statusInfo($user);
$this->assertSame(['beta', 'meet'], $result['skus']);
$user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first());
$result = UsersController::statusInfo($user);
$this->assertSame(['beta', 'meet'], $result['skus']);
}
+ /**
+ * Test user config update (POST /api/v4/users/<user>/config)
+ */
+ public function testSetConfig(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+
+ $john->setSetting('greylisting', null);
+
+ // Test unknown user id
+ $post = ['greylisting' => 1];
+ $response = $this->actingAs($john)->post("/api/v4/users/123/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(404);
+
+ // Test access by user not being a wallet controller
+ $post = ['greylisting' => 1];
+ $response = $this->actingAs($jack)->post("/api/v4/users/{$john->id}/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(403);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Access denied", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test some invalid data
+ $post = ['grey' => 1];
+ $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']);
+
+ $this->assertNull($john->fresh()->getSetting('greylisting'));
+
+ // Test some valid data
+ $post = ['greylisting' => 1];
+ $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('User settings updated successfully.', $json['message']);
+
+ $this->assertSame('true', $john->fresh()->getSetting('greylisting'));
+
+ // Test some valid data
+ $post = ['greylisting' => 0];
+ $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('User settings updated successfully.', $json['message']);
+
+ $this->assertSame('false', $john->fresh()->getSetting('greylisting'));
+ }
+
/**
* Test user creation (POST /api/v4/users)
*/
public function testStore(): void
{
Queue::fake();
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->delete();
// Test empty request
$response = $this->actingAs($john)->post("/api/v4/users", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The email field is required.", $json['errors']['email']);
$this->assertSame("The password field is required.", $json['errors']['password'][0]);
$this->assertCount(2, $json);
// Test access by user not being a wallet controller
$post = ['first_name' => 'Test'];
$response = $this->actingAs($jack)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(403);
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test some invalid data
$post = ['password' => '12345678', 'email' => 'invalid'];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
$this->assertSame('The specified email is invalid.', $json['errors']['email']);
// Test existing user email
$post = [
'password' => 'simple',
'password_confirmation' => 'simple',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'jack.daniels@kolab.org',
];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The specified email is not available.', $json['errors']['email']);
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$post = [
'password' => 'simple',
'password_confirmation' => 'simple',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'john2.doe2@kolab.org',
'organization' => 'TestOrg',
'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'],
];
// Missing package
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Package is required.", $json['errors']['package']);
$this->assertCount(2, $json);
// Invalid package
$post['package'] = $package_domain->id;
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Invalid package selected.", $json['errors']['package']);
$this->assertCount(2, $json);
// Test full and valid data
$post['package'] = $package_kolab->id;
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = User::where('email', 'john2.doe2@kolab.org')->first();
$this->assertInstanceOf(User::class, $user);
$this->assertSame('John2', $user->getSetting('first_name'));
$this->assertSame('Doe2', $user->getSetting('last_name'));
$this->assertSame('TestOrg', $user->getSetting('organization'));
$aliases = $user->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('deleted@kolab.org', $aliases[0]->alias);
$this->assertSame('useralias1@kolab.org', $aliases[1]->alias);
// Assert the new user entitlements
$this->assertUserEntitlements($user, ['groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage']);
// Assert the wallet to which the new user should be assigned to
$wallet = $user->wallet();
$this->assertSame($john->wallets()->first()->id, $wallet->id);
// Attempt to create a user previously deleted
$user->delete();
$post['package'] = $package_kolab->id;
$post['aliases'] = [];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = User::where('email', 'john2.doe2@kolab.org')->first();
$this->assertInstanceOf(User::class, $user);
$this->assertSame('John2', $user->getSetting('first_name'));
$this->assertSame('Doe2', $user->getSetting('last_name'));
$this->assertSame('TestOrg', $user->getSetting('organization'));
$this->assertCount(0, $user->aliases()->get());
$this->assertUserEntitlements($user, ['groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage']);
// Test acting as account controller (not owner)
$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->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
// Test some invalid data
$post = ['password' => '12345678', 'currency' => 'invalid'];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
$this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]);
// Test full profile update including password
$post = [
'password' => 'simple',
'password_confirmation' => 'simple',
'first_name' => 'John2',
'last_name' => 'Doe2',
'organization' => 'TestOrg',
'phone' => '+123 123 123',
'external_email' => 'external@gmail.com',
'billing_address' => 'billing',
'country' => 'CH',
'currency' => 'CHF',
'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
$this->assertTrue($userA->password != $userA->fresh()->password);
unset($post['password'], $post['password_confirmation'], $post['aliases']);
foreach ($post as $key => $value) {
$this->assertSame($value, $userA->getSetting($key));
}
$aliases = $userA->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias);
$this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias);
// Test unsetting values
$post = [
'first_name' => '',
'last_name' => '',
'organization' => '',
'phone' => '',
'external_email' => '',
'billing_address' => '',
'country' => '',
'currency' => '',
'aliases' => ['useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
unset($post['aliases']);
foreach ($post as $key => $value) {
$this->assertNull($userA->getSetting($key));
}
$aliases = $userA->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias);
// Test error on some invalid aliases missing password confirmation
$post = [
'password' => 'simple123',
'aliases' => [
'useralias2@' . \config('app.domain'),
'useralias1@kolab.org',
'@kolab.org',
]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertCount(2, $json['errors']['aliases']);
$this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]);
$this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]);
$this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
// Test authorized update of other user
$response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue(empty($json['statusInfo']));
// TODO: Test error on aliases with invalid/non-existing/other-user's domain
// Create entitlements and additional user for following tests
$owner = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$package_kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_lite = Package::withEnvTenantContext()->where('title', 'lite')->first();
$sku_mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$sku_storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$sku_groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$domain = $this->getTestDomain(
'userscontroller.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$domain->assignPackage($package_domain, $owner);
$owner->assignPackage($package_kolab);
$owner->assignPackage($package_lite, $user);
// Non-controller cannot update his own entitlements
$post = ['skus' => []];
$response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(422);
// Test updating entitlements
$post = [
'skus' => [
$sku_mailbox->id => 1,
$sku_storage->id => 6,
$sku_groupware->id => 1,
],
];
$response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$storage_cost = $user->entitlements()
->where('sku_id', $sku_storage->id)
->orderBy('cost')
->pluck('cost')->all();
$this->assertUserEntitlements(
$user,
['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage']
);
$this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost);
$this->assertTrue(empty($json['statusInfo']));
}
/**
* Test UsersController::updateEntitlements()
*/
public function testUpdateEntitlements(): void
{
$jane = $this->getTestUser('jane@kolabnow.com');
$kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$activesync = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
// standard package, 1 mailbox, 1 groupware, 2 storage
$jane->assignPackage($kolab);
// add 2 storage, 1 activesync
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 7,
$activesync->id => 1
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertUserEntitlements(
$jane,
[
'activesync',
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// add 2 storage, remove 1 activesync
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 9,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertUserEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// add mailbox
$post = [
'skus' => [
$mailbox->id => 2,
$groupware->id => 1,
$storage->id => 9,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(500);
$this->assertUserEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// remove mailbox
$post = [
'skus' => [
$mailbox->id => 0,
$groupware->id => 1,
$storage->id => 9,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(500);
$this->assertUserEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// less than free storage
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 1,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertUserEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
}
/**
* Test user data response used in show and info actions
*/
public function testUserResponse(): void
{
$provider = \config('services.payment_provider') ?: 'mollie';
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]);
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
$this->assertEquals($user->id, $result['id']);
$this->assertEquals($user->email, $result['email']);
$this->assertEquals($user->status, $result['status']);
$this->assertTrue(is_array($result['statusInfo']));
$this->assertTrue(is_array($result['aliases']));
$this->assertCount(1, $result['aliases']);
$this->assertSame('john.doe@kolab.org', $result['aliases'][0]);
$this->assertTrue(is_array($result['settings']));
$this->assertSame('US', $result['settings']['country']);
$this->assertSame('USD', $result['settings']['currency']);
$this->assertTrue(is_array($result['accounts']));
$this->assertTrue(is_array($result['wallets']));
$this->assertCount(0, $result['accounts']);
$this->assertCount(1, $result['wallets']);
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertArrayNotHasKey('discount', $result['wallet']);
$this->assertTrue($result['statusInfo']['enableDomains']);
$this->assertTrue($result['statusInfo']['enableWallets']);
$this->assertTrue($result['statusInfo']['enableUsers']);
// Ned is John's wallet controller
$ned = $this->getTestUser('ned@kolab.org');
$ned_wallet = $ned->wallets()->first();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]);
$this->assertEquals($ned->id, $result['id']);
$this->assertEquals($ned->email, $result['email']);
$this->assertTrue(is_array($result['accounts']));
$this->assertTrue(is_array($result['wallets']));
$this->assertCount(1, $result['accounts']);
$this->assertCount(1, $result['wallets']);
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertSame($wallet->id, $result['accounts'][0]['id']);
$this->assertSame($ned_wallet->id, $result['wallets'][0]['id']);
$this->assertSame($provider, $result['wallet']['provider']);
$this->assertSame($provider, $result['wallets'][0]['provider']);
$this->assertTrue($result['statusInfo']['enableDomains']);
$this->assertTrue($result['statusInfo']['enableWallets']);
$this->assertTrue($result['statusInfo']['enableUsers']);
// Test discount in a response
$discount = Discount::where('code', 'TEST')->first();
$wallet->discount()->associate($discount);
$wallet->save();
$mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie';
$wallet->setSetting($mod_provider . '_id', 123);
$user->refresh();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
$this->assertEquals($user->id, $result['id']);
$this->assertSame($discount->id, $result['wallet']['discount_id']);
$this->assertSame($discount->discount, $result['wallet']['discount']);
$this->assertSame($discount->description, $result['wallet']['discount_description']);
$this->assertSame($mod_provider, $result['wallet']['provider']);
$this->assertSame($discount->id, $result['wallets'][0]['discount_id']);
$this->assertSame($discount->discount, $result['wallets'][0]['discount']);
$this->assertSame($discount->description, $result['wallets'][0]['discount_description']);
$this->assertSame($mod_provider, $result['wallets'][0]['provider']);
// Jack is not a John's wallet controller
$jack = $this->getTestUser('jack@kolab.org');
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]);
$this->assertFalse($result['statusInfo']['enableDomains']);
$this->assertFalse($result['statusInfo']['enableWallets']);
$this->assertFalse($result['statusInfo']['enableUsers']);
}
/**
* List of email address validation cases for testValidateEmail()
*
* @return array Arguments for testValidateEmail()
*/
public function dataValidateEmail(): array
{
$this->refreshApplication();
$public_domains = Domain::getPublicDomains();
$domain = reset($public_domains);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
return [
// Invalid format
["$domain", $john, 'The specified email is invalid.'],
[".@$domain", $john, 'The specified email is invalid.'],
["test123456@localhost", $john, 'The specified domain is invalid.'],
["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'],
["$domain", $john, 'The specified email is invalid.'],
[".@$domain", $john, 'The specified email is invalid.'],
// forbidden local part on public domains
["admin@$domain", $john, 'The specified email is not available.'],
["administrator@$domain", $john, 'The specified email is not available.'],
// forbidden (other user's domain)
["testtest@kolab.org", $user, 'The specified domain is not available.'],
// existing alias of other user, to be a user email
["jack.daniels@kolab.org", $john, 'The specified email is not available.'],
// valid (user domain)
["admin@kolab.org", $john, null],
// valid (public domain)
["test.test@$domain", $john, null],
];
}
/**
* User email address validation.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*
* @dataProvider dataValidateEmail
*/
public function testValidateEmail($email, $user, $expected_result): void
{
$result = UsersController::validateEmail($email, $user);
$this->assertSame($expected_result, $result);
}
/**
* User email validation - tests for $deleted argument
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateEmailDeleted(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->delete();
$deleted_pub = $this->getTestUser('deleted@kolabnow.com');
$deleted_pub->delete();
$result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted);
$this->assertSame(null, $result);
$this->assertSame($deleted_priv->id, $deleted->id);
$result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertSame(null, $deleted);
$result = UsersController::validateEmail('jack@kolab.org', $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertSame(null, $deleted);
}
/**
* User email validation - tests for an address being a group email address
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateEmailGroup(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$pub_group = $this->getTestGroup('group-test@kolabnow.com');
$priv_group = $this->getTestGroup('group-test@kolab.org');
// A group in a public domain, existing
$result = UsersController::validateEmail($pub_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
$pub_group->delete();
// A group in a public domain, deleted
$result = UsersController::validateEmail($pub_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
// A group in a private domain, existing
$result = UsersController::validateEmail($priv_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
$priv_group->delete();
// A group in a private domain, deleted
$result = UsersController::validateEmail($priv_group->email, $john, $deleted);
$this->assertSame(null, $result);
$this->assertSame($priv_group->id, $deleted->id);
}
/**
* List of alias validation cases for testValidateAlias()
*
* @return array Arguments for testValidateAlias()
*/
public function dataValidateAlias(): array
{
$this->refreshApplication();
$public_domains = Domain::getPublicDomains();
$domain = reset($public_domains);
$john = $this->getTestUser('john@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
return [
// Invalid format
["$domain", $john, 'The specified alias is invalid.'],
[".@$domain", $john, 'The specified alias is invalid.'],
["test123456@localhost", $john, 'The specified domain is invalid.'],
["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'],
["$domain", $john, 'The specified alias is invalid.'],
[".@$domain", $john, 'The specified alias is invalid.'],
// forbidden local part on public domains
["admin@$domain", $john, 'The specified alias is not available.'],
["administrator@$domain", $john, 'The specified alias is not available.'],
// forbidden (other user's domain)
["testtest@kolab.org", $user, 'The specified domain is not available.'],
// existing alias of other user, to be an alias, user in the same group account
["jack.daniels@kolab.org", $john, null],
// existing user
["jack@kolab.org", $john, 'The specified alias is not available.'],
// valid (user domain)
["admin@kolab.org", $john, null],
// valid (public domain)
["test.test@$domain", $john, null],
];
}
/**
* User email alias validation.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*
* @dataProvider dataValidateAlias
*/
public function testValidateAlias($alias, $user, $expected_result): void
{
$result = UsersController::validateAlias($alias, $user);
$this->assertSame($expected_result, $result);
}
/**
* User alias validation - more cases.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateAlias2(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->setAliases(['deleted-alias@kolab.org']);
$deleted_priv->delete();
$deleted_pub = $this->getTestUser('deleted@kolabnow.com');
$deleted_pub->setAliases(['deleted-alias@kolabnow.com']);
$deleted_pub->delete();
$group = $this->getTestGroup('group-test@kolabnow.com');
// An alias that was a user email before is allowed, but only for custom domains
$result = UsersController::validateAlias('deleted@kolab.org', $john);
$this->assertSame(null, $result);
$result = UsersController::validateAlias('deleted-alias@kolab.org', $john);
$this->assertSame(null, $result);
$result = UsersController::validateAlias('deleted@kolabnow.com', $john);
$this->assertSame('The specified alias is not available.', $result);
$result = UsersController::validateAlias('deleted-alias@kolabnow.com', $john);
$this->assertSame('The specified alias is not available.', $result);
// A grpoup with the same email address exists
$result = UsersController::validateAlias($group->email, $john);
$this->assertSame('The specified alias is not available.', $result);
}
}
diff --git a/src/tests/Feature/Stories/GreylistTest.php b/src/tests/Feature/Stories/GreylistTest.php
new file mode 100644
index 00000000..b5bb811b
--- /dev/null
+++ b/src/tests/Feature/Stories/GreylistTest.php
@@ -0,0 +1,617 @@
+<?php
+
+namespace Tests\Feature\Stories;
+
+use App\Policy\Greylist;
+use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
+
+/**
+ * @group slow
+ * @group data
+ * @group greylist
+ */
+class GreylistTest extends TestCase
+{
+ private $clientAddress;
+ private $instance;
+ private $requests = [];
+ private $net;
+
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->setUpTest();
+ $this->useServicesUrl();
+ $this->instance = $this->generateInstanceId();
+ $this->clientAddress = '212.103.80.148';
+
+ $this->net = \App\IP4Net::getNet($this->clientAddress);
+
+ DB::delete("DELETE FROM greylist_connect WHERE sender_domain = 'sender.domain';");
+ DB::delete("DELETE FROM greylist_settings;");
+ DB::delete("DELETE FROM greylist_whitelist WHERE sender_domain = 'sender.domain';");
+ }
+
+ public function tearDown(): void
+ {
+ DB::delete("DELETE FROM greylist_connect WHERE sender_domain = 'sender.domain';");
+ DB::delete("DELETE FROM greylist_settings;");
+ DB::delete("DELETE FROM greylist_whitelist WHERE sender_domain = 'sender.domain';");
+
+ parent::tearDown();
+ }
+
+ public function testWithTimestamp()
+ {
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(7)->toString()
+ ]
+ );
+
+ $timestamp = $this->getObjectProperty($request, 'timestamp');
+
+ $this->assertTrue(
+ \Carbon\Carbon::parse($timestamp, 'UTC') < \Carbon\Carbon::now()
+ );
+ }
+
+ public function testNoNet()
+ {
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => '127.128.129.130',
+ 'client_name' => 'some.mx'
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ public function testIp6Net()
+ {
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => '2a00:1450:400a:803::2005',
+ 'client_name' => 'some.mx'
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ // public function testMultiRecipientThroughAlias() {}
+
+ public function testWhitelistNew()
+ {
+ $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNull($whitelist);
+
+ for ($i = 0; $i < 5; $i++) {
+ $request = new Greylist\Request(
+ [
+ 'sender' => "someone{$i}@sender.domain",
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(1)
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNotNull($whitelist);
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => "someone5@sender.domain",
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(1)
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ // public function testWhitelistedHit() {}
+
+ public function testWhitelistStale()
+ {
+ $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNull($whitelist);
+
+ for ($i = 0; $i < 5; $i++) {
+ $request = new Greylist\Request(
+ [
+ 'sender' => "someone{$i}@sender.domain",
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(1)
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNotNull($whitelist);
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => "someone5@sender.domain",
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(1)
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+
+ $whitelist->updated_at = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2);
+ $whitelist->save(['timestamps' => false]);
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ // public function testWhitelistUpdate() {}
+
+ public function testNew()
+ {
+ $data = [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx'
+ ];
+
+ $response = $this->post('/api/webhooks/policy/greylist', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testRetry()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
+ $connect->save();
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainDisabled()
+ {
+ $setting = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainEnabled()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => \App\IP4Net::getNet('212.103.80.148')->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $setting = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'true'
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+
+ $connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
+ $connect->save();
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainDisabledUserDisabled()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $settingDomain = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $settingUser = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainDisabledUserEnabled()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $settingDomain = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $settingUser = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'true'
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+
+ $connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
+ $connect->save();
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testInvalidDomain()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => 1234,
+ 'recipient_type' => \App\Domain::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => 'not.someone@that.exists',
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ public function testInvalidUser()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => 1234,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => 'not.someone@that.exists',
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ public function testUserDisabled()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $setting = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testUserEnabled()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $setting = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'true'
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+
+ $connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
+ $connect->save();
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testMultipleUsersAllDisabled()
+ {
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ foreach ($this->domainUsers as $user) {
+ Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $user->email),
+ 'recipient_id' => $user->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ Greylist\Setting::create(
+ [
+ 'object_id' => $user->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ if ($user->email == $this->domainOwner->email) {
+ continue;
+ }
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $user->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+ }
+
+ public function testMultipleUsersAnyEnabled()
+ {
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ foreach ($this->domainUsers as $user) {
+ Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $user->email),
+ 'recipient_id' => $user->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ Greylist\Setting::create(
+ [
+ 'object_id' => $user->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => ($user->id == $this->jack->id) ? 'true' : 'false'
+ ]
+ );
+
+ if ($user->email == $this->domainOwner->email) {
+ continue;
+ }
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $user->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ if ($user->id == $this->jack->id) {
+ $this->assertTrue($request->shouldDefer());
+ } else {
+ $this->assertFalse($request->shouldDefer());
+ }
+ }
+ }
+
+ private function generateInstanceId()
+ {
+ $instance = [];
+
+ for ($x = 0; $x < 3; $x++) {
+ for ($y = 0; $y < 3; $y++) {
+ $instance[] = substr('01234567889', rand(0, 9), 1);
+ }
+ }
+
+ return implode('.', $instance);
+ }
+}
diff --git a/src/tests/Feature/Stories/SenderPolicyFrameworkTest.php b/src/tests/Feature/Stories/SenderPolicyFrameworkTest.php
new file mode 100644
index 00000000..9f6c1f40
--- /dev/null
+++ b/src/tests/Feature/Stories/SenderPolicyFrameworkTest.php
@@ -0,0 +1,313 @@
+<?php
+
+namespace Tests\Feature\Stories;
+
+use Tests\TestCase;
+
+/**
+ * @group slow
+ * @group data
+ * @group spf
+ */
+class SenderPolicyFrameworkTest extends TestCase
+{
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->setUpTest();
+ $this->useServicesUrl();
+ }
+
+ public function testSenderFailv4()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-fail.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderFailv6()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-fail.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ // actually IN AAAA gmail.com.
+ 'client_address' => '2a00:1450:400a:801::2005',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $this->assertFalse(strpos(':', $data['client_address']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderNone()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-none.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+ }
+
+ public function testSenderNoNet()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-none.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '256.0.0.1',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderPass()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-pass.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+ }
+
+ public function testSenderPassAll()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-passall.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+ }
+
+ public function testSenderPermerror()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-permerror.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderSoftfail()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-fail.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+ }
+
+ public function testSenderTemperror()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-temperror.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderRelayPolicyHeloExactNegative()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['the.only.acceptable.helo']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+ public function testSenderRelayPolicyHeloExactPositive()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['helo.some.relayservice.domain']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+
+ public function testSenderRelayPolicyRegexpNegative()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['/a\.domain/']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+ public function testSenderRelayPolicyRegexpPositive()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['/relayservice\.domain/']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+ public function testSenderRelayPolicyWildcardSubdomainNegative()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['.helo.some.relayservice.domain']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+ public function testSenderRelayPolicyWildcardSubdomainPositive()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['.some.relayservice.domain']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+}
diff --git a/src/tests/Functional/HorizonTest.php b/src/tests/Functional/HorizonTest.php
index a42b8890..97738e71 100644
--- a/src/tests/Functional/HorizonTest.php
+++ b/src/tests/Functional/HorizonTest.php
@@ -1,28 +1,28 @@
<?php
namespace Tests\Functional;
use Tests\TestCase;
class HorizonTest extends TestCase
{
public function testAdminAccess()
{
$this->useAdminUrl();
$response = $this->get('horizon/dashboard');
$response->assertStatus(200);
}
/*
public function testRegularAccess()
{
$this->useRegularUrl();
$response = $this->get('horizon/dashboard');
- $response->assertStatus(404);
+ $response->assertStatus(200);
}
*/
}
diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php
index 31a179b7..45cdbbb0 100644
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -1,91 +1,78 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Routing\Middleware\ThrottleRequests;
abstract class TestCase extends BaseTestCase
{
use TestCaseTrait;
use TestCaseMeetTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// Disable throttling
$this->withoutMiddleware(ThrottleRequests::class);
}
- protected function backdateEntitlements($entitlements, $targetDate)
- {
- $wallets = [];
- $ids = [];
-
- foreach ($entitlements as $entitlement) {
- $ids[] = $entitlement->id;
- $wallets[] = $entitlement->wallet_id;
- }
-
- \App\Entitlement::whereIn('id', $ids)->update([
- 'created_at' => $targetDate,
- 'updated_at' => $targetDate,
- ]);
-
- if (!empty($wallets)) {
- $wallets = array_unique($wallets);
- $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all();
-
- \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]);
- }
- }
-
/**
* Set baseURL to the regular UI location
*/
protected static function useRegularUrl(): void
{
// This will set base URL for all tests in a 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.', '//reseller.'],
['//', '//'],
\config('app.url')
)
]
);
url()->forceRootUrl(config('app.url'));
}
/**
* Set baseURL to the admin UI location
*/
protected static function useAdminUrl(): void
{
// This will set base URL for all tests in a 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'))]);
url()->forceRootUrl(config('app.url'));
}
/**
* Set baseURL to the reseller UI location
*/
protected static function useResellerUrl(): void
{
// This will set base URL for all tests in a 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('//', '//reseller.', \config('app.url'))]);
url()->forceRootUrl(config('app.url'));
}
+
+ /**
+ * Set baseURL to the services location
+ */
+ protected static function useServicesUrl(): void
+ {
+ // This will set base URL for all tests in a file.
+ \config(['app.url' => str_replace('//', '//services.', \config('app.url'))]);
+ url()->forceRootUrl(config('app.url'));
+ }
}
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
index d88cbe72..badac263 100644
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -1,248 +1,470 @@
<?php
namespace Tests;
use App\Domain;
use App\Group;
use App\Transaction;
use App\User;
use Carbon\Carbon;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Assert;
trait TestCaseTrait
{
/**
- * Assert user entitlements state
+ * A domain that is hosted.
+ *
+ * @var ?\App\Domain
+ */
+ protected $domainHosted;
+
+ /**
+ * The hosted domain owner.
+ *
+ * @var ?\App\User
+ */
+ protected $domainOwner;
+
+ /**
+ * Some profile details for an owner of a domain
+ *
+ * @var array
+ */
+ protected $domainOwnerSettings = [
+ 'first_name' => 'John',
+ 'last_name' => 'Doe',
+ 'organization' => 'Test Domain Owner',
+ ];
+
+ /**
+ * Some users for the hosted domain, ultimately including the owner.
+ *
+ * @var \App\User[]
+ */
+ protected $domainUsers = [];
+
+ /**
+ * A specific user that is a regular user in the hosted domain.
+ *
+ * @var ?\App\User
+ */
+ protected $jack;
+
+ /**
+ * A specific user that is a controller on the wallet to which the hosted domain is charged.
+ *
+ * @var ?\App\User
+ */
+ protected $jane;
+
+ /**
+ * A specific user that has a second factor configured.
+ *
+ * @var ?\App\User
+ */
+ protected $joe;
+
+ /**
+ * One of the domains that is available for public registration.
+ *
+ * @var ?\App\Domain
+ */
+ protected $publicDomain;
+
+ /**
+ * A newly generated user in a public domain.
+ *
+ * @var ?\App\User
+ */
+ protected $publicDomainUser;
+
+ /**
+ * A placeholder for a password that can be generated.
+ *
+ * Should be generated with `\App\Utils::generatePassphrase()`.
+ *
+ * @var ?string
+ */
+ protected $userPassword;
+
+ /**
+ * Assert that the entitlements for the user match the expected list of entitlements.
+ *
+ * @param \App\User $user The user for which the entitlements need to be pulled.
+ * @param array $expected An array of expected \App\SKU titles.
*/
protected function assertUserEntitlements($user, $expected)
{
// Assert the user entitlements
$skus = $user->entitlements()->get()
->map(function ($ent) {
return $ent->sku->title;
})
->toArray();
sort($skus);
Assert::assertSame($expected, $skus);
}
+ protected function backdateEntitlements($entitlements, $targetDate)
+ {
+ $wallets = [];
+ $ids = [];
+
+ foreach ($entitlements as $entitlement) {
+ $ids[] = $entitlement->id;
+ $wallets[] = $entitlement->wallet_id;
+ }
+
+ \App\Entitlement::whereIn('id', $ids)->update([
+ 'created_at' => $targetDate,
+ 'updated_at' => $targetDate,
+ ]);
+
+ if (!empty($wallets)) {
+ $wallets = array_unique($wallets);
+ $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all();
+
+ \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]);
+ }
+ }
+
/**
* Removes all beta entitlements from the database
*/
protected function clearBetaEntitlements(): void
{
$beta_handlers = [
'App\Handlers\Beta',
'App\Handlers\Distlist',
];
$betas = \App\Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all();
\App\Entitlement::whereIn('sku_id', $betas)->delete();
}
/**
* Creates the application.
*
* @return \Illuminate\Foundation\Application
*/
public function createApplication()
{
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
/**
* Create a set of transaction log entries for a wallet
*/
protected function createTestTransactions($wallet)
{
$result = [];
$date = Carbon::now();
$debit = 0;
$entitlementTransactions = [];
+
foreach ($wallet->entitlements as $entitlement) {
if ($entitlement->cost) {
$debit += $entitlement->cost;
$entitlementTransactions[] = $entitlement->createTransaction(
Transaction::ENTITLEMENT_BILLED,
$entitlement->cost
);
}
}
- $transaction = Transaction::create([
+ $transaction = Transaction::create(
+ [
'user_email' => 'jeroen@jeroen.jeroen',
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => Transaction::WALLET_DEBIT,
'amount' => $debit * -1,
'description' => 'Payment',
- ]);
+ ]
+ );
+
$result[] = $transaction;
Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]);
- $transaction = Transaction::create([
+ $transaction = Transaction::create(
+ [
'user_email' => null,
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => Transaction::WALLET_CREDIT,
'amount' => 2000,
'description' => 'Payment',
- ]);
+ ]
+ );
+
$transaction->created_at = $date->next(Carbon::MONDAY);
$transaction->save();
+
$result[] = $transaction;
$types = [
Transaction::WALLET_AWARD,
Transaction::WALLET_PENALTY,
];
// The page size is 10, so we generate so many to have at least two pages
$loops = 10;
while ($loops-- > 0) {
$type = $types[count($result) % count($types)];
$transaction = Transaction::create([
'user_email' => 'jeroen.@jeroen.jeroen',
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => $type,
'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1),
'description' => 'TRANS' . $loops,
]);
+
$transaction->created_at = $date->next(Carbon::MONDAY);
$transaction->save();
+
$result[] = $transaction;
}
return $result;
}
+ /**
+ * Delete a test domain whatever it takes.
+ *
+ * @coversNothing
+ */
protected function deleteTestDomain($name)
{
Queue::fake();
$domain = Domain::withTrashed()->where('namespace', $name)->first();
if (!$domain) {
return;
}
$job = new \App\Jobs\Domain\DeleteJob($domain->id);
$job->handle();
$domain->forceDelete();
}
+ /**
+ * Delete a test group whatever it takes.
+ *
+ * @coversNothing
+ */
protected function deleteTestGroup($email)
{
Queue::fake();
$group = Group::withTrashed()->where('email', $email)->first();
if (!$group) {
return;
}
$job = new \App\Jobs\Group\DeleteJob($group->id);
$job->handle();
$group->forceDelete();
}
+ /**
+ * Delete a test user whatever it takes.
+ *
+ * @coversNothing
+ */
protected function deleteTestUser($email)
{
Queue::fake();
$user = User::withTrashed()->where('email', $email)->first();
if (!$user) {
return;
}
$job = new \App\Jobs\User\DeleteJob($user->id);
$job->handle();
$user->forceDelete();
}
+ /**
+ * Helper to access protected property of an object
+ */
+ protected static function getObjectProperty($object, $property_name)
+ {
+ $reflection = new \ReflectionClass($object);
+ $property = $reflection->getProperty($property_name);
+ $property->setAccessible(true);
+
+ return $property->getValue($object);
+ }
+
/**
* Get Domain object by namespace, create it if needed.
* Skip LDAP jobs.
+ *
+ * @coversNothing
*/
protected function getTestDomain($name, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
return Domain::firstOrCreate(['namespace' => $name], $attrib);
}
/**
* Get Group object by email, create it if needed.
* Skip LDAP jobs.
*/
protected function getTestGroup($email, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
return Group::firstOrCreate(['email' => $email], $attrib);
}
/**
* Get User object by email, create it if needed.
* Skip LDAP jobs.
+ *
+ * @coversNothing
*/
protected function getTestUser($email, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
$user = User::firstOrCreate(['email' => $email], $attrib);
if ($user->trashed()) {
// Note: we do not want to use user restore here
User::where('id', $user->id)->forceDelete();
$user = User::create(['email' => $email] + $attrib);
}
return $user;
}
- /**
- * Helper to access protected property of an object
- */
- protected static function getObjectProperty($object, $property_name)
- {
- $reflection = new \ReflectionClass($object);
- $property = $reflection->getProperty($property_name);
- $property->setAccessible(true);
-
- return $property->getValue($object);
- }
-
/**
* Call protected/private method of a class.
*
* @param object $object Instantiated object that we will run method on.
* @param string $methodName Method name to call
* @param array $parameters Array of parameters to pass into method.
*
* @return mixed Method return.
*/
protected function invokeMethod($object, $methodName, array $parameters = array())
{
$reflection = new \ReflectionClass(get_class($object));
$method = $reflection->getMethod($methodName);
$method->setAccessible(true);
return $method->invokeArgs($object, $parameters);
}
+
+ protected function setUpTest()
+ {
+ $this->userPassword = \App\Utils::generatePassphrase();
+
+ $this->domainHosted = $this->getTestDomain(
+ 'test.domain',
+ [
+ 'type' => \App\Domain::TYPE_EXTERNAL,
+ 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED
+ ]
+ );
+
+ $packageKolab = \App\Package::where('title', 'kolab')->first();
+
+ $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]);
+ $this->domainOwner->assignPackage($packageKolab);
+ $this->domainOwner->setSettings($this->domainOwnerSettings);
+
+ // separate for regular user
+ $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]);
+
+ // separate for wallet controller
+ $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]);
+
+ $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]);
+
+ $this->domainUsers[] = $this->jack;
+ $this->domainUsers[] = $this->jane;
+ $this->domainUsers[] = $this->joe;
+ $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]);
+
+ foreach ($this->domainUsers as $user) {
+ $this->domainOwner->assignPackage($packageKolab, $user);
+ }
+
+ $this->domainUsers[] = $this->domainOwner;
+
+ // assign second factor to joe
+ $this->joe->assignSku(\App\Sku::where('title', '2fa')->first());
+ \App\Auth\SecondFactor::seed($this->joe->email);
+
+ usort(
+ $this->domainUsers,
+ function ($a, $b) {
+ return $a->email > $b->email;
+ }
+ );
+
+ $this->domainHosted->assignPackage(
+ \App\Package::where('title', 'domain-hosting')->first(),
+ $this->domainOwner
+ );
+
+ $wallet = $this->domainOwner->wallets()->first();
+
+ $wallet->addController($this->jane);
+
+ $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first();
+ $this->publicDomainUser = $this->getTestUser(
+ 'john@' . $this->publicDomain->namespace,
+ ['password' => $this->userPassword]
+ );
+
+ $this->publicDomainUser->assignPackage($packageKolab);
+ }
+
+ public function tearDown(): void
+ {
+ foreach ($this->domainUsers as $user) {
+ if ($user == $this->domainOwner) {
+ continue;
+ }
+
+ $this->deleteTestUser($user->email);
+ }
+
+ if ($this->domainOwner) {
+ $this->deleteTestUser($this->domainOwner->email);
+ }
+
+ if ($this->domainHosted) {
+ $this->deleteTestDomain($this->domainHosted->namespace);
+ }
+
+ if ($this->publicDomainUser) {
+ $this->deleteTestUser($this->publicDomainUser->email);
+ }
+
+ parent::tearDown();
+ }
}

File Metadata

Mime Type
text/x-diff
Expires
Fri, Jan 30, 10:10 PM (8 h, 51 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426052
Default Alt Text
(580 KB)

Event Timeline