Page MenuHomePhorge

D2572.1774880539.diff
No OneTemporary

Authored By
Unknown
Size
454 KB
Referenced Files
None
Subscribers
None

D2572.1774880539.diff

This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/bin/phpstan b/bin/phpstan
--- a/bin/phpstan
+++ b/bin/phpstan
@@ -4,7 +4,7 @@
pushd ${cwd}/../src/
-php -dmemory_limit=400M \
+php -dmemory_limit=500M \
vendor/bin/phpstan \
analyse
diff --git a/docker-compose.yml b/docker-compose.yml
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -59,6 +59,7 @@
container_name: kolab-mariadb
environment:
MYSQL_ROOT_PASSWORD: Welcome2KolabSystems
+ TZ: "+02:00"
healthcheck:
interval: 10s
test: test -e /var/run/mysqld/mysqld.sock
diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -6,6 +6,7 @@
APP_PUBLIC_URL=
APP_DOMAIN=kolabnow.com
APP_THEME=default
+APP_TENANT_ID=1
APP_LOCALE=en
APP_LOCALES=en,de
diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php
--- a/src/app/Console/Command.php
+++ b/src/app/Console/Command.php
@@ -2,18 +2,37 @@
namespace App\Console;
-class Command extends \Illuminate\Console\Command
+use Illuminate\Support\Facades\DB;
+
+abstract class Command extends \Illuminate\Console\Command
{
/**
+ * Annotate this command as being dangerous for any potential unintended consequences.
+ *
+ * Commands are considered dangerous if;
+ *
+ * * observers are deliberately not triggered, meaning that the deletion of an object model that requires the
+ * associated observer to clean some things up, or charge a wallet or something, are deliberately not triggered,
+ *
+ * * deletion of objects and their relations rely on database foreign keys with obscure cascading,
+ *
+ * * a command will result in the permanent, irrecoverable loss of data.
+ *
+ * @var boolean
+ */
+ protected $dangerous = false;
+
+ /**
* Find the domain.
*
- * @param string $domain Domain ID or namespace
+ * @param string $domain Domain ID or namespace
+ * @param bool $withDeleted Include deleted
*
* @return \App\Domain|null
*/
- public function getDomain($domain)
+ public function getDomain($domain, $withDeleted = false)
{
- return $this->getObject(\App\Domain::class, $domain, 'namespace');
+ return $this->getObject(\App\Domain::class, $domain, 'namespace', $withDeleted);
}
/**
@@ -22,38 +41,86 @@
* @param string $objectClass The name of the class
* @param string $objectIdOrTitle The name of a database field to match.
* @param string|null $objectTitle An additional database field to match.
+ * @param bool $withDeleted Act as if --with-deleted was used
*
* @return mixed
*/
- public function getObject($objectClass, $objectIdOrTitle, $objectTitle)
+ public function getObject($objectClass, $objectIdOrTitle, $objectTitle = null, $withDeleted = false)
{
- if ($this->hasOption('with-deleted') && $this->option('with-deleted')) {
- $object = $objectClass::withTrashed()->find($objectIdOrTitle);
- } else {
- $object = $objectClass::find($objectIdOrTitle);
+ if (!$withDeleted) {
+ $withDeleted = $this->hasOption('with-deleted') && $this->option('with-deleted');
}
+ $object = $this->getObjectModel($objectClass, $withDeleted)->find($objectIdOrTitle);
+
if (!$object && !empty($objectTitle)) {
- if ($this->hasOption('with-deleted') && $this->option('with-deleted')) {
- $object = $objectClass::withTrashed()->where($objectTitle, $objectIdOrTitle)->first();
- } else {
- $object = $objectClass::where($objectTitle, $objectIdOrTitle)->first();
- }
+ $object = $this->getObjectModel($objectClass, $withDeleted)
+ ->where($objectTitle, $objectIdOrTitle)->first();
}
return $object;
}
/**
+ * Returns a preconfigured Model object for a specified class.
+ *
+ * @param string $objectClass The name of the class
+ * @param bool $withDeleted Include withTrashed() query
+ *
+ * @return mixed
+ */
+ protected function getObjectModel($objectClass, $withDeleted = false)
+ {
+ if ($withDeleted) {
+ $model = $objectClass::withTrashed();
+ } else {
+ $model = new $objectClass();
+ }
+
+ $modelsWithTenant = [
+ \App\Discount::class,
+ \App\Domain::class,
+ \App\Group::class,
+ \App\Package::class,
+ \App\Plan::class,
+ \App\Sku::class,
+ \App\User::class,
+ ];
+
+ $modelsWithOwner = [
+ \App\Wallet::class,
+ ];
+
+ $tenant_id = \config('app.tenant_id');
+
+ // Add tenant filter
+ if (in_array($objectClass, $modelsWithTenant)) {
+ $model = $model->withEnvTenant();
+ } elseif (in_array($objectClass, $modelsWithOwner)) {
+ $model = $model->whereExists(function ($query) use ($tenant_id) {
+ $query->select(DB::raw(1))
+ ->from('users')
+ ->whereRaw('wallets.user_id = users.id')
+ ->whereRaw('users.tenant_id ' . ($tenant_id ? "= $tenant_id" : 'is null'));
+ });
+ }
+
+ // TODO: tenant check for Entitlement, Transaction, etc.
+
+ return $model;
+ }
+
+ /**
* Find the user.
*
- * @param string $user User ID or email
+ * @param string $user User ID or email
+ * @param bool $withDeleted Include deleted
*
* @return \App\User|null
*/
- public function getUser($user)
+ public function getUser($user, $withDeleted = false)
{
- return $this->getObject(\App\User::class, $user, 'email');
+ return $this->getObject(\App\User::class, $user, 'email', $withDeleted);
}
/**
@@ -68,6 +135,26 @@
return $this->getObject(\App\Wallet::class, $wallet, null);
}
+ public function handle()
+ {
+ if ($this->dangerous) {
+ $this->warn(
+ "This command is a dangerous scalpel command with potentially significant unintended consequences"
+ );
+
+ $confirmation = $this->confirm("Are you sure you understand what's about to happen?");
+
+ if (!$confirmation) {
+ $this->info("Better safe than sorry.");
+ return false;
+ }
+
+ $this->info("Vámonos!");
+ }
+
+ return true;
+ }
+
/**
* Return a string for output, with any additional attributes specified as well.
*
diff --git a/src/app/Console/Commands/DBPing.php b/src/app/Console/Commands/DB/PingCommand.php
rename from src/app/Console/Commands/DBPing.php
rename to src/app/Console/Commands/DB/PingCommand.php
--- a/src/app/Console/Commands/DBPing.php
+++ b/src/app/Console/Commands/DB/PingCommand.php
@@ -1,11 +1,11 @@
<?php
-namespace App\Console\Commands;
+namespace App\Console\Commands\DB;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
-class DBPing extends Command
+class PingCommand extends Command
{
/**
* The name and signature of the console command.
diff --git a/src/app/Console/Commands/DB/VerifyTimezoneCommand.php b/src/app/Console/Commands/DB/VerifyTimezoneCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/DB/VerifyTimezoneCommand.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Console\Commands\DB;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+class VerifyTimezoneCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'db:verify-timezone';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = "Verify the application's timezone compared to the DB timezone";
+
+ /**
+ * Create a new command instance.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ parent::__construct();
+ }
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $result = \Illuminate\Support\Facades\DB::select("SHOW VARIABLES WHERE Variable_name = 'time_zone'");
+
+ $appTimezone = \config('app.timezone');
+
+ if ($appTimezone != "UTC") {
+ $this->error("The application timezone is not configured to be UTC");
+ return 1;
+ }
+
+ if ($result[0]->{'Value'} != '+00:00' && $result[0]->{'Value'} != 'UTC') {
+ $this->error("The database timezone is neither configured as '+00:00' nor 'UTC'");
+ return 1;
+ }
+
+ return 0;
+ }
+}
diff --git a/src/app/Console/Commands/Discount/MergeCommand.php b/src/app/Console/Commands/Discount/MergeCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Discount/MergeCommand.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Console\Commands\Discount;
+
+use App\Console\Command;
+
+/**
+ * Merge one discount (source) with another discount (target), and delete the source discount.
+ *
+ * This command re-associates the wallets that are discounted with the source discount to become discounted with the
+ * target discount.
+ *
+ * Optionally, update the description of the target discount.
+ *
+ * You are not allowed to merge discounts that have different discount rates.
+ *
+ * This command makes it feasible to merge existing discounts like the ones that are 100% and described as
+ * "It's us..", "it's us", "This is us", etc.
+ *
+ * Example usage:
+ *
+ * ```
+ * $ ./artisan scalpel:discount:merge \
+ * > 158f660b-e992-4fb9-ac12-5173b5f33807 \
+ * > 62af659f-17d8-4527-87c1-c69eaa26653c \
+ * > --description="Employee discount"
+ * ```
+ */
+class MergeCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'discount:merge {source} {target} {--description*}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Merge one discount in to another discount, ' .
+ 'optionally set the description, and delete the source discount';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $source = $this->getObject(\App\Discount::class, $this->argument('source'));
+
+ if (!$source) {
+ $this->error("No such source discount: {$source}");
+ return 1;
+ }
+
+ $target = $this->getObject(\App\Discount::class, $this->argument('target'));
+
+ if (!$target) {
+ $this->error("No such target discount: {$target}");
+ return 1;
+ }
+
+ if ($source->discount !== $target->discount) {
+ $this->error("Can't merge two discounts that have different rates");
+ return 1;
+ }
+
+ foreach ($source->wallets as $wallet) {
+ $wallet->discount_id = $target->id;
+ $wallet->timestamps = false;
+ $wallet->save();
+ }
+
+ if ($this->option('description')) {
+ $target->description = $this->option('description');
+ $target->save();
+ }
+
+ $source->delete();
+ }
+}
diff --git a/src/app/Console/Commands/DiscountList.php b/src/app/Console/Commands/DiscountList.php
deleted file mode 100644
--- a/src/app/Console/Commands/DiscountList.php
+++ /dev/null
@@ -1,61 +0,0 @@
-<?php
-
-namespace App\Console\Commands;
-
-use App\Discount;
-use Illuminate\Console\Command;
-use Illuminate\Support\Facades\DB;
-
-class DiscountList extends Command
-{
- /**
- * The name and signature of the console command.
- *
- * @var string
- */
- protected $signature = 'discount:list';
-
- /**
- * The console command description.
- *
- * @var string
- */
- protected $description = 'List available (active) discounts';
-
- /**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
- * Execute the console command.
- *
- * @return mixed
- */
- public function handle()
- {
- Discount::where('active', true)->orderBy('discount')->get()->each(
- function ($discount) {
- $name = $discount->description;
-
- if ($discount->code) {
- $name .= " [{$discount->code}]";
- }
-
- $this->info(
- sprintf(
- "%s %3d%% %s",
- $discount->id,
- $discount->discount,
- $name
- )
- );
- }
- );
- }
-}
diff --git a/src/app/Console/Commands/DiscountsCommand.php b/src/app/Console/Commands/DiscountsCommand.php
--- a/src/app/Console/Commands/DiscountsCommand.php
+++ b/src/app/Console/Commands/DiscountsCommand.php
@@ -4,6 +4,29 @@
use App\Console\ObjectListCommand;
+/**
+ * List discounts.
+ *
+ * Example usage:
+ *
+ * ```
+ * $ ./artisan discounts
+ * 003f18e5-cbd2-4de8-9485-b0c966e4757d
+ * 00603496-5c91-4347-b341-cd5022566210
+ * 0076b174-f122-458a-8466-bd05c3cac35d
+ * (...)
+ * ```
+ *
+ * To include specific attributes, use `--attr` (allowed multiple times):
+ *
+ * ```
+ * $ ./artisan discounts --attr=discount --attr=description
+ * 003f18e5-cbd2-4de8-9485-b0c966e4757d 54 Custom volume discount
+ * 00603496-5c91-4347-b341-cd5022566210 30 Developer Discount
+ * 0076b174-f122-458a-8466-bd05c3cac35d 100 it's me
+ * (...)
+ * ```
+ */
class DiscountsCommand extends ObjectListCommand
{
protected $objectClass = \App\Discount::class;
diff --git a/src/app/Console/Commands/DomainAdd.php b/src/app/Console/Commands/Domain/CreateCommand.php
rename from src/app/Console/Commands/DomainAdd.php
rename to src/app/Console/Commands/Domain/CreateCommand.php
--- a/src/app/Console/Commands/DomainAdd.php
+++ b/src/app/Console/Commands/Domain/CreateCommand.php
@@ -1,20 +1,18 @@
<?php
-namespace App\Console\Commands;
+namespace App\Console\Commands\Domain;
-use App\Domain;
-use App\Entitlement;
-use Illuminate\Console\Command;
+use App\Console\Command;
use Illuminate\Support\Facades\Queue;
-class DomainAdd extends Command
+class CreateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
- protected $signature = 'domain:add {domain} {--force}';
+ protected $signature = 'domain:create {domain} {--force}';
/**
* The console command description.
@@ -33,24 +31,21 @@
$namespace = \strtolower($this->argument('domain'));
// must use withTrashed(), because unique constraint
- $domain = Domain::withTrashed()->where('namespace', $namespace)->first();
+ $domain = \App\Domain::withTrashed()->where('namespace', $namespace)->first();
if ($domain && !$this->option('force')) {
$this->error("Domain {$namespace} already exists.");
return 1;
}
- Queue::fake(); // ignore LDAP for now
-
if ($domain) {
if ($domain->deleted_at) {
- // revive domain
- $domain->deleted_at = null;
- $domain->status = 0;
+ // set the status back to new
+ $domain->status = \App\Domain::STATUS_NEW;
$domain->save();
// remove existing entitlement
- $entitlement = Entitlement::withTrashed()->where(
+ $entitlement = \App\Entitlement::withTrashed()->where(
[
'entitleable_id' => $domain->id,
'entitleable_type' => \App\Domain::class
@@ -60,17 +55,36 @@
if ($entitlement) {
$entitlement->forceDelete();
}
+
+ // restore the domain to allow for the observer to handle the create job
+ $domain->restore();
+
+ $this->info(
+ sprintf(
+ "Domain %s with ID %d revived. Remember to assign it to a wallet with 'domain:set-wallet'",
+ $domain->namespace,
+ $domain->id
+ )
+ );
} else {
$this->error("Domain {$namespace} not marked as deleted... examine more closely");
return 1;
}
} else {
- $domain = Domain::create([
+ $domain = \App\Domain::create(
+ [
'namespace' => $namespace,
- 'type' => Domain::TYPE_EXTERNAL,
- ]);
- }
+ 'type' => \App\Domain::TYPE_EXTERNAL,
+ ]
+ );
- $this->info($domain->id);
+ $this->info(
+ sprintf(
+ "Domain %s created with ID %d. Remember to assign it to a wallet with 'domain:set-wallet'",
+ $domain->namespace,
+ $domain->id
+ )
+ );
+ }
}
}
diff --git a/src/app/Console/Commands/DomainDelete.php b/src/app/Console/Commands/DomainDelete.php
--- a/src/app/Console/Commands/DomainDelete.php
+++ b/src/app/Console/Commands/DomainDelete.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class DomainDelete extends Command
{
@@ -21,23 +21,13 @@
protected $description = 'Delete a domain';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $domain = \App\Domain::where('id', $this->argument('domain'))->first();
+ $domain = $this->getDomain($this->argument('domain'));
if (!$domain) {
return 1;
diff --git a/src/app/Console/Commands/DomainList.php b/src/app/Console/Commands/DomainList.php
--- a/src/app/Console/Commands/DomainList.php
+++ b/src/app/Console/Commands/DomainList.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\Domain;
-use Illuminate\Console\Command;
class DomainList extends Command
{
@@ -22,16 +22,6 @@
protected $description = 'List domains';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
@@ -44,7 +34,7 @@
$domains = Domain::orderBy('namespace');
}
- $domains->each(
+ $domains->withEnvTenant()->each(
function ($domain) {
$msg = $domain->namespace;
diff --git a/src/app/Console/Commands/DomainListUsers.php b/src/app/Console/Commands/DomainListUsers.php
--- a/src/app/Console/Commands/DomainListUsers.php
+++ b/src/app/Console/Commands/DomainListUsers.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class DomainListUsers extends Command
{
@@ -37,7 +37,7 @@
*/
public function handle()
{
- $domain = \App\Domain::where('namespace', $this->argument('domain'))->first();
+ $domain = $this->getDomain($this->argument('domain'));
if (!$domain) {
return 1;
diff --git a/src/app/Console/Commands/DomainRestore.php b/src/app/Console/Commands/DomainRestore.php
--- a/src/app/Console/Commands/DomainRestore.php
+++ b/src/app/Console/Commands/DomainRestore.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
use Illuminate\Support\Facades\DB;
class DomainRestore extends Command
@@ -28,7 +28,7 @@
*/
public function handle()
{
- $domain = \App\Domain::withTrashed()->where('namespace', $this->argument('domain'))->first();
+ $domain = $this->getDomain($this->argument('domain'), true);
if (!$domain) {
$this->error("Domain not found.");
diff --git a/src/app/Console/Commands/DomainSetStatus.php b/src/app/Console/Commands/DomainSetStatus.php
--- a/src/app/Console/Commands/DomainSetStatus.php
+++ b/src/app/Console/Commands/DomainSetStatus.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\Domain;
-use Illuminate\Console\Command;
use Illuminate\Support\Facades\Queue;
class DomainSetStatus extends Command
@@ -29,7 +29,7 @@
*/
public function handle()
{
- $domain = Domain::where('namespace', $this->argument('domain'))->first();
+ $domain = $this->getDomain($this->argument('domain'));
if (!$domain) {
return 1;
diff --git a/src/app/Console/Commands/DomainSetWallet.php b/src/app/Console/Commands/DomainSetWallet.php
--- a/src/app/Console/Commands/DomainSetWallet.php
+++ b/src/app/Console/Commands/DomainSetWallet.php
@@ -2,11 +2,11 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\Entitlement;
use App\Domain;
use App\Sku;
use App\Wallet;
-use Illuminate\Console\Command;
use Illuminate\Support\Facades\Queue;
class DomainSetWallet extends Command
@@ -32,14 +32,14 @@
*/
public function handle()
{
- $domain = Domain::where('namespace', $this->argument('domain'))->first();
+ $domain = $this->getDomain($this->argument('domain'));
if (!$domain) {
$this->error("Domain not found.");
return 1;
}
- $wallet = Wallet::find($this->argument('wallet'));
+ $wallet = $this->getWallet($this->argument('wallet'));
if (!$wallet) {
$this->error("Wallet not found.");
@@ -60,6 +60,7 @@
'wallet_id' => $wallet->id,
'sku_id' => $sku->id,
'cost' => 0,
+ 'fee' => 0,
'entitleable_id' => $domain->id,
'entitleable_type' => Domain::class,
]
diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/DomainStatus.php
--- a/src/app/Console/Commands/DomainStatus.php
+++ b/src/app/Console/Commands/DomainStatus.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\Domain;
-use Illuminate\Console\Command;
class DomainStatus extends Command
{
@@ -22,23 +22,13 @@
protected $description = 'Display the status of a domain';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $domain = Domain::where('namespace', $this->argument('domain'))->first();
+ $domain = $this->getDomain($this->argument('domain'));
if (!$domain) {
return 1;
diff --git a/src/app/Console/Commands/DomainSuspend.php b/src/app/Console/Commands/DomainSuspend.php
--- a/src/app/Console/Commands/DomainSuspend.php
+++ b/src/app/Console/Commands/DomainSuspend.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\Domain;
-use Illuminate\Console\Command;
class DomainSuspend extends Command
{
@@ -22,23 +22,13 @@
protected $description = 'Suspend a domain';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $domain = Domain::where('namespace', $this->argument('domain'))->first();
+ $domain = $this->getDomain($this->argument('domain'));
if (!$domain) {
return 1;
diff --git a/src/app/Console/Commands/DomainUnsuspend.php b/src/app/Console/Commands/DomainUnsuspend.php
--- a/src/app/Console/Commands/DomainUnsuspend.php
+++ b/src/app/Console/Commands/DomainUnsuspend.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\Domain;
-use Illuminate\Console\Command;
class DomainUnsuspend extends Command
{
@@ -22,23 +22,13 @@
protected $description = 'Remove a domain suspension';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $domain = Domain::where('namespace', $this->argument('domain'))->first();
+ $domain = $this->getDomain($this->argument('domain'));
if (!$domain) {
return 1;
diff --git a/src/app/Console/Commands/Group/CreateCommand.php b/src/app/Console/Commands/Group/CreateCommand.php
--- a/src/app/Console/Commands/Group/CreateCommand.php
+++ b/src/app/Console/Commands/Group/CreateCommand.php
@@ -7,6 +7,11 @@
use App\Http\Controllers\API\V4\GroupsController;
use Illuminate\Support\Facades\DB;
+/**
+ * Create a (mail-enabled) distribution group.
+ *
+ * @see \App\Console\Commands\Scalpel\Group\CreateCommand
+ */
class CreateCommand extends Command
{
/**
diff --git a/src/app/Console/Commands/Job/DomainCreate.php b/src/app/Console/Commands/Job/DomainCreate.php
--- a/src/app/Console/Commands/Job/DomainCreate.php
+++ b/src/app/Console/Commands/Job/DomainCreate.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands\Job;
+use App\Console\Command;
use App\Domain;
-use Illuminate\Console\Command;
class DomainCreate extends Command
{
@@ -28,7 +28,7 @@
*/
public function handle()
{
- $domain = Domain::where('namespace', $this->argument('domain'))->first();
+ $domain = $this->getDomain($this->argument('domain'));
if (!$domain) {
return 1;
diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/DomainUpdate.php
--- a/src/app/Console/Commands/Job/DomainUpdate.php
+++ b/src/app/Console/Commands/Job/DomainUpdate.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands\Job;
+use App\Console\Command;
use App\Domain;
-use Illuminate\Console\Command;
class DomainUpdate extends Command
{
@@ -28,7 +28,7 @@
*/
public function handle()
{
- $domain = Domain::where('namespace', $this->argument('domain'))->first();
+ $domain = $this->getDomain($this->argument('domain'));
if (!$domain) {
return 1;
diff --git a/src/app/Console/Commands/Job/UserCreate.php b/src/app/Console/Commands/Job/UserCreate.php
--- a/src/app/Console/Commands/Job/UserCreate.php
+++ b/src/app/Console/Commands/Job/UserCreate.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands\Job;
+use App\Console\Command;
use App\User;
-use Illuminate\Console\Command;
class UserCreate extends Command
{
@@ -28,7 +28,7 @@
*/
public function handle()
{
- $user = User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/Job/UserUpdate.php b/src/app/Console/Commands/Job/UserUpdate.php
--- a/src/app/Console/Commands/Job/UserUpdate.php
+++ b/src/app/Console/Commands/Job/UserUpdate.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands\Job;
+use App\Console\Command;
use App\User;
-use Illuminate\Console\Command;
class UserUpdate extends Command
{
@@ -28,7 +28,7 @@
*/
public function handle()
{
- $user = User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/Job/WalletCheck.php b/src/app/Console/Commands/Job/WalletCheck.php
--- a/src/app/Console/Commands/Job/WalletCheck.php
+++ b/src/app/Console/Commands/Job/WalletCheck.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands\Job;
+use App\Console\Command;
use App\Wallet;
-use Illuminate\Console\Command;
class WalletCheck extends Command
{
@@ -28,7 +28,7 @@
*/
public function handle()
{
- $wallet = Wallet::find($this->argument('wallet'));
+ $wallet = $this->getWallet($this->argument('wallet'));
if (!$wallet) {
return 1;
diff --git a/src/app/Console/Commands/MollieInfo.php b/src/app/Console/Commands/MollieInfo.php
--- a/src/app/Console/Commands/MollieInfo.php
+++ b/src/app/Console/Commands/MollieInfo.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\User;
-use Illuminate\Console\Command;
class MollieInfo extends Command
{
@@ -29,7 +29,7 @@
public function handle()
{
if ($this->argument('user')) {
- $user = User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/OpenVidu/RoomCreate.php b/src/app/Console/Commands/OpenVidu/RoomCreate.php
--- a/src/app/Console/Commands/OpenVidu/RoomCreate.php
+++ b/src/app/Console/Commands/OpenVidu/RoomCreate.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands\OpenVidu;
-use Illuminate\Console\Command;
+use App\Console\Command;
class RoomCreate extends Command
{
@@ -21,23 +21,13 @@
protected $description = 'Create a room for a user';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $user = \App\User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/OpenVidu/Rooms.php b/src/app/Console/Commands/OpenVidu/Rooms.php
--- a/src/app/Console/Commands/OpenVidu/Rooms.php
+++ b/src/app/Console/Commands/OpenVidu/Rooms.php
@@ -21,16 +21,6 @@
protected $description = 'List OpenVidu rooms';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
diff --git a/src/app/Console/Commands/OpenVidu/Sessions.php b/src/app/Console/Commands/OpenVidu/Sessions.php
--- a/src/app/Console/Commands/OpenVidu/Sessions.php
+++ b/src/app/Console/Commands/OpenVidu/Sessions.php
@@ -21,16 +21,6 @@
protected $description = 'List OpenVidu sessions';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
diff --git a/src/app/Console/Commands/PackageSkus.php b/src/app/Console/Commands/PackageSkus.php
--- a/src/app/Console/Commands/PackageSkus.php
+++ b/src/app/Console/Commands/PackageSkus.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\Package;
-use Illuminate\Console\Command;
class PackageSkus extends Command
{
@@ -22,23 +22,13 @@
protected $description = "List SKUs for packages.";
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $packages = Package::all();
+ $packages = Package::withEnvTenant()->get();
foreach ($packages as $package) {
$this->info(sprintf("Package: %s", $package->title));
diff --git a/src/app/Console/Commands/PlanPackages.php b/src/app/Console/Commands/PlanPackages.php
--- a/src/app/Console/Commands/PlanPackages.php
+++ b/src/app/Console/Commands/PlanPackages.php
@@ -38,7 +38,7 @@
*/
public function handle()
{
- $plans = Plan::all();
+ $plans = Plan::withEnvTenant()->get();
foreach ($plans as $plan) {
$this->info(sprintf("Plan: %s", $plan->title));
diff --git a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php b/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php
--- a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php
+++ b/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php
@@ -6,6 +6,8 @@
class CreateCommand extends ObjectCreateCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Discount::class;
protected $objectName = 'discount';
diff --git a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php b/src/app/Console/Commands/Scalpel/Discount/DeleteCommand.php
copy from src/app/Console/Commands/Scalpel/Discount/CreateCommand.php
copy to src/app/Console/Commands/Scalpel/Discount/DeleteCommand.php
--- a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php
+++ b/src/app/Console/Commands/Scalpel/Discount/DeleteCommand.php
@@ -2,10 +2,13 @@
namespace App\Console\Commands\Scalpel\Discount;
-use App\Console\ObjectCreateCommand;
+use App\Console\ObjectDeleteCommand;
-class CreateCommand extends ObjectCreateCommand
+class DeleteCommand extends ObjectDeleteCommand
{
+ protected $dangerous = true;
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Discount::class;
protected $objectName = 'discount';
diff --git a/src/app/Console/Commands/Scalpel/Discount/ReadCommand.php b/src/app/Console/Commands/Scalpel/Discount/ReadCommand.php
--- a/src/app/Console/Commands/Scalpel/Discount/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/Discount/ReadCommand.php
@@ -6,6 +6,8 @@
class ReadCommand extends ObjectReadCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Discount::class;
protected $objectName = 'discount';
diff --git a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php b/src/app/Console/Commands/Scalpel/Discount/UpdateCommand.php
copy from src/app/Console/Commands/Scalpel/Discount/CreateCommand.php
copy to src/app/Console/Commands/Scalpel/Discount/UpdateCommand.php
--- a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php
+++ b/src/app/Console/Commands/Scalpel/Discount/UpdateCommand.php
@@ -2,10 +2,12 @@
namespace App\Console\Commands\Scalpel\Discount;
-use App\Console\ObjectCreateCommand;
+use App\Console\ObjectUpdateCommand;
-class CreateCommand extends ObjectCreateCommand
+class UpdateCommand extends ObjectUpdateCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Discount::class;
protected $objectName = 'discount';
diff --git a/src/app/Console/Commands/Scalpel/Domain/CreateCommand.php b/src/app/Console/Commands/Scalpel/Domain/CreateCommand.php
--- a/src/app/Console/Commands/Scalpel/Domain/CreateCommand.php
+++ b/src/app/Console/Commands/Scalpel/Domain/CreateCommand.php
@@ -6,6 +6,8 @@
class CreateCommand extends ObjectCreateCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Domain::class;
protected $objectName = 'domain';
diff --git a/src/app/Console/Commands/Scalpel/Domain/ReadCommand.php b/src/app/Console/Commands/Scalpel/Domain/ReadCommand.php
--- a/src/app/Console/Commands/Scalpel/Domain/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/Domain/ReadCommand.php
@@ -6,6 +6,8 @@
class ReadCommand extends ObjectReadCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Domain::class;
protected $objectName = 'domain';
diff --git a/src/app/Console/Commands/Scalpel/Domain/UpdateCommand.php b/src/app/Console/Commands/Scalpel/Domain/UpdateCommand.php
--- a/src/app/Console/Commands/Scalpel/Domain/UpdateCommand.php
+++ b/src/app/Console/Commands/Scalpel/Domain/UpdateCommand.php
@@ -6,6 +6,8 @@
class UpdateCommand extends ObjectUpdateCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Domain::class;
protected $objectName = 'domain';
diff --git a/src/app/Console/Commands/Scalpel/Entitlement/CreateCommand.php b/src/app/Console/Commands/Scalpel/Entitlement/CreateCommand.php
--- a/src/app/Console/Commands/Scalpel/Entitlement/CreateCommand.php
+++ b/src/app/Console/Commands/Scalpel/Entitlement/CreateCommand.php
@@ -6,6 +6,8 @@
class CreateCommand extends ObjectCreateCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Entitlement::class;
protected $objectName = 'entitlement';
diff --git a/src/app/Console/Commands/Scalpel/Entitlement/ReadCommand.php b/src/app/Console/Commands/Scalpel/Entitlement/ReadCommand.php
--- a/src/app/Console/Commands/Scalpel/Entitlement/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/Entitlement/ReadCommand.php
@@ -6,6 +6,8 @@
class ReadCommand extends ObjectReadCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Entitlement::class;
protected $objectName = 'entitlement';
diff --git a/src/app/Console/Commands/Scalpel/Entitlement/UpdateCommand.php b/src/app/Console/Commands/Scalpel/Entitlement/UpdateCommand.php
--- a/src/app/Console/Commands/Scalpel/Entitlement/UpdateCommand.php
+++ b/src/app/Console/Commands/Scalpel/Entitlement/UpdateCommand.php
@@ -6,6 +6,8 @@
class UpdateCommand extends ObjectUpdateCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Entitlement::class;
protected $objectName = 'entitlement';
diff --git a/src/app/Console/Commands/Scalpel/Group/CreateCommand.php b/src/app/Console/Commands/Scalpel/Group/CreateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/Group/CreateCommand.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\Group;
+
+use App\Console\ObjectCreateCommand;
+
+class CreateCommand extends ObjectCreateCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\Group::class;
+ protected $objectName = 'group';
+ protected $objectTitle = 'email';
+}
diff --git a/src/app/Console/Commands/Scalpel/User/ReadCommand.php b/src/app/Console/Commands/Scalpel/Group/ReadCommand.php
copy from src/app/Console/Commands/Scalpel/User/ReadCommand.php
copy to src/app/Console/Commands/Scalpel/Group/ReadCommand.php
--- a/src/app/Console/Commands/Scalpel/User/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/Group/ReadCommand.php
@@ -1,13 +1,15 @@
<?php
-namespace App\Console\Commands\Scalpel\User;
+namespace App\Console\Commands\Scalpel\Group;
use App\Console\ObjectReadCommand;
class ReadCommand extends ObjectReadCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
- protected $objectClass = \App\User::class;
- protected $objectName = 'user';
+ protected $objectClass = \App\Group::class;
+ protected $objectName = 'group';
protected $objectTitle = 'email';
}
diff --git a/src/app/Console/Commands/Scalpel/Group/UpdateCommand.php b/src/app/Console/Commands/Scalpel/Group/UpdateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/Group/UpdateCommand.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\Group;
+
+use App\Console\ObjectUpdateCommand;
+
+class UpdateCommand extends ObjectUpdateCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\Group::class;
+ protected $objectName = 'group';
+ protected $objectTitle = 'email';
+}
diff --git a/src/app/Console/Commands/Scalpel/Package/ReadCommand.php b/src/app/Console/Commands/Scalpel/Package/ReadCommand.php
--- a/src/app/Console/Commands/Scalpel/Package/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/Package/ReadCommand.php
@@ -6,6 +6,8 @@
class ReadCommand extends ObjectReadCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Package::class;
protected $objectName = 'package';
diff --git a/src/app/Console/Commands/Scalpel/Plan/ReadCommand.php b/src/app/Console/Commands/Scalpel/Plan/ReadCommand.php
--- a/src/app/Console/Commands/Scalpel/Plan/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/Plan/ReadCommand.php
@@ -6,6 +6,8 @@
class ReadCommand extends ObjectReadCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Plan::class;
protected $objectName = 'plan';
diff --git a/src/app/Console/Commands/Scalpel/Sku/ReadCommand.php b/src/app/Console/Commands/Scalpel/Sku/ReadCommand.php
--- a/src/app/Console/Commands/Scalpel/Sku/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/Sku/ReadCommand.php
@@ -6,6 +6,8 @@
class ReadCommand extends ObjectReadCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Sku::class;
protected $objectName = 'sku';
diff --git a/src/app/Console/Commands/Scalpel/Transaction/ReadCommand.php b/src/app/Console/Commands/Scalpel/Transaction/ReadCommand.php
--- a/src/app/Console/Commands/Scalpel/Transaction/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/Transaction/ReadCommand.php
@@ -6,6 +6,8 @@
class ReadCommand extends ObjectReadCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Transaction::class;
protected $objectName = 'transaction';
diff --git a/src/app/Console/Commands/Scalpel/User/CreateCommand.php b/src/app/Console/Commands/Scalpel/User/CreateCommand.php
--- a/src/app/Console/Commands/Scalpel/User/CreateCommand.php
+++ b/src/app/Console/Commands/Scalpel/User/CreateCommand.php
@@ -19,6 +19,8 @@
*/
class CreateCommand extends ObjectCreateCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\User::class;
protected $objectName = 'user';
diff --git a/src/app/Console/Commands/Scalpel/User/ReadCommand.php b/src/app/Console/Commands/Scalpel/User/ReadCommand.php
--- a/src/app/Console/Commands/Scalpel/User/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/User/ReadCommand.php
@@ -6,6 +6,8 @@
class ReadCommand extends ObjectReadCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\User::class;
protected $objectName = 'user';
diff --git a/src/app/Console/Commands/Scalpel/User/ReadCommand.php b/src/app/Console/Commands/Scalpel/User/UpdateCommand.php
copy from src/app/Console/Commands/Scalpel/User/ReadCommand.php
copy to src/app/Console/Commands/Scalpel/User/UpdateCommand.php
--- a/src/app/Console/Commands/Scalpel/User/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/User/UpdateCommand.php
@@ -2,10 +2,12 @@
namespace App\Console\Commands\Scalpel\User;
-use App\Console\ObjectReadCommand;
+use App\Console\ObjectUpdateCommand;
-class ReadCommand extends ObjectReadCommand
+class UpdateCommand extends ObjectUpdateCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\User::class;
protected $objectName = 'user';
diff --git a/src/app/Console/Commands/Scalpel/UserSetting/ReadCommand.php b/src/app/Console/Commands/Scalpel/UserSetting/ReadCommand.php
--- a/src/app/Console/Commands/Scalpel/UserSetting/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/UserSetting/ReadCommand.php
@@ -6,6 +6,8 @@
class ReadCommand extends ObjectReadCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\UserSetting::class;
protected $objectName = 'user-setting';
diff --git a/src/app/Console/Commands/Scalpel/Wallet/ReadCommand.php b/src/app/Console/Commands/Scalpel/Wallet/ReadCommand.php
--- a/src/app/Console/Commands/Scalpel/Wallet/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/Wallet/ReadCommand.php
@@ -6,6 +6,8 @@
class ReadCommand extends ObjectReadCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\Wallet::class;
protected $objectName = 'wallet';
diff --git a/src/app/Console/Commands/Scalpel/WalletSetting/ReadCommand.php b/src/app/Console/Commands/Scalpel/WalletSetting/ReadCommand.php
--- a/src/app/Console/Commands/Scalpel/WalletSetting/ReadCommand.php
+++ b/src/app/Console/Commands/Scalpel/WalletSetting/ReadCommand.php
@@ -6,6 +6,8 @@
class ReadCommand extends ObjectReadCommand
{
+ protected $hidden = true;
+
protected $commandPrefix = 'scalpel';
protected $objectClass = \App\WalletSetting::class;
protected $objectName = 'wallet-setting';
diff --git a/src/app/Console/Commands/Sku/ListUsers.php b/src/app/Console/Commands/Sku/ListUsers.php
--- a/src/app/Console/Commands/Sku/ListUsers.php
+++ b/src/app/Console/Commands/Sku/ListUsers.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands\Sku;
-use Illuminate\Console\Command;
+use App\Console\Command;
class ListUsers extends Command
{
@@ -27,11 +27,7 @@
*/
public function handle()
{
- $sku = \App\Sku::find($this->argument('sku'));
-
- if (!$sku) {
- $sku = \App\Sku::where('title', $this->argument('sku'))->first();
- }
+ $sku = $this->getObject(\App\Sku::class, $this->argument('sku'), 'title');
if (!$sku) {
$this->error("Unable to find the SKU.");
diff --git a/src/app/Console/Commands/StripeInfo.php b/src/app/Console/Commands/StripeInfo.php
--- a/src/app/Console/Commands/StripeInfo.php
+++ b/src/app/Console/Commands/StripeInfo.php
@@ -2,9 +2,9 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\Providers\PaymentProvider;
use App\User;
-use Illuminate\Console\Command;
use Stripe as StripeAPI;
class StripeInfo extends Command
@@ -31,7 +31,7 @@
public function handle()
{
if ($this->argument('user')) {
- $user = User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/UserAddAlias.php b/src/app/Console/Commands/UserAddAlias.php
--- a/src/app/Console/Commands/UserAddAlias.php
+++ b/src/app/Console/Commands/UserAddAlias.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\Http\Controllers\API\V4\UsersController;
-use Illuminate\Console\Command;
class UserAddAlias extends Command
{
@@ -22,23 +22,13 @@
protected $description = 'Add an email alias to a user (forcefully)';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $user = \App\User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/UserAssignSku.php b/src/app/Console/Commands/UserAssignSku.php
--- a/src/app/Console/Commands/UserAssignSku.php
+++ b/src/app/Console/Commands/UserAssignSku.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class UserAssignSku extends Command
{
@@ -27,18 +27,14 @@
*/
public function handle()
{
- $user = \App\User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
$this->error("Unable to find the user {$this->argument('user')}.");
return 1;
}
- $sku = \App\Sku::find($this->argument('sku'));
-
- if (!$sku) {
- $sku = \App\Sku::where('title', $this->argument('sku'))->first();
- }
+ $sku = $this->getObject(\App\Sku::class, $this->argument('sku'), 'title');
if (!$sku) {
$this->error("Unable to find the SKU {$this->argument('sku')}.");
diff --git a/src/app/Console/Commands/UserDelete.php b/src/app/Console/Commands/UserDelete.php
--- a/src/app/Console/Commands/UserDelete.php
+++ b/src/app/Console/Commands/UserDelete.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class UserDelete extends Command
{
@@ -21,23 +21,13 @@
protected $description = 'Delete a user';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $user = \App\User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/UserDiscount.php b/src/app/Console/Commands/UserDiscount.php
--- a/src/app/Console/Commands/UserDiscount.php
+++ b/src/app/Console/Commands/UserDiscount.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class UserDiscount extends Command
{
@@ -21,23 +21,13 @@
protected $description = "Apply a discount to all of the user's wallets";
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $user = \App\User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
@@ -48,7 +38,7 @@
if ($this->argument('discount') === '0') {
$discount = null;
} else {
- $discount = \App\Discount::find($this->argument('discount'));
+ $discount = $this->getObject(\App\Discount::class, $this->argument('discount'));
if (!$discount) {
return 1;
diff --git a/src/app/Console/Commands/UserDomains.php b/src/app/Console/Commands/UserDomains.php
--- a/src/app/Console/Commands/UserDomains.php
+++ b/src/app/Console/Commands/UserDomains.php
@@ -2,10 +2,7 @@
namespace App\Console\Commands;
-use App\Domain;
-use App\User;
-use Illuminate\Console\Command;
-use Illuminate\Support\Facades\DB;
+use App\Console\Command;
class UserDomains extends Command
{
@@ -24,23 +21,13 @@
protected $description = 'Command description';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $user = User::where('email', $this->argument('userid'))->first();
+ $user = $this->getUser($this->argument('userid'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/UserEntitlements.php b/src/app/Console/Commands/UserEntitlements.php
--- a/src/app/Console/Commands/UserEntitlements.php
+++ b/src/app/Console/Commands/UserEntitlements.php
@@ -2,9 +2,7 @@
namespace App\Console\Commands;
-use App\Sku;
-use App\User;
-use Illuminate\Console\Command;
+use App\Console\Command;
class UserEntitlements extends Command
{
@@ -23,23 +21,13 @@
protected $description = "List a user's entitlements.";
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $user = User::where('email', $this->argument('userid'))->first();
+ $user = $this->getUser($this->argument('userid'));
if (!$user) {
return 1;
@@ -58,7 +46,7 @@
}
foreach ($skus_counted as $id => $qty) {
- $sku = Sku::find($id);
+ $sku = \App\Sku::find($id);
$this->info("SKU: {$sku->title} ({$qty})");
}
}
diff --git a/src/app/Console/Commands/UserForceDelete.php b/src/app/Console/Commands/UserForceDelete.php
--- a/src/app/Console/Commands/UserForceDelete.php
+++ b/src/app/Console/Commands/UserForceDelete.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
use Illuminate\Support\Facades\DB;
class UserForceDelete extends Command
@@ -28,7 +28,7 @@
*/
public function handle()
{
- $user = \App\User::withTrashed()->where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'), true);
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/UserRestore.php b/src/app/Console/Commands/UserRestore.php
--- a/src/app/Console/Commands/UserRestore.php
+++ b/src/app/Console/Commands/UserRestore.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
use Illuminate\Support\Facades\DB;
class UserRestore extends Command
@@ -28,7 +28,7 @@
*/
public function handle()
{
- $user = \App\User::withTrashed()->where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'), true);
if (!$user) {
$this->error('User not found.');
diff --git a/src/app/Console/Commands/UserStatus.php b/src/app/Console/Commands/UserStatus.php
--- a/src/app/Console/Commands/UserStatus.php
+++ b/src/app/Console/Commands/UserStatus.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\User;
-use Illuminate\Console\Command;
class UserStatus extends Command
{
@@ -22,23 +22,13 @@
protected $description = 'Display the status of a user';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $user = User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/UserSuspend.php b/src/app/Console/Commands/UserSuspend.php
--- a/src/app/Console/Commands/UserSuspend.php
+++ b/src/app/Console/Commands/UserSuspend.php
@@ -3,7 +3,7 @@
namespace App\Console\Commands;
use App\User;
-use Illuminate\Console\Command;
+use App\Console\Command;
class UserSuspend extends Command
{
@@ -22,23 +22,13 @@
protected $description = 'Suspend a user';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $user = User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/UserUnsuspend.php b/src/app/Console/Commands/UserUnsuspend.php
--- a/src/app/Console/Commands/UserUnsuspend.php
+++ b/src/app/Console/Commands/UserUnsuspend.php
@@ -2,8 +2,7 @@
namespace App\Console\Commands;
-use App\User;
-use Illuminate\Console\Command;
+use App\Console\Command;
class UserUnsuspend extends Command
{
@@ -22,23 +21,13 @@
protected $description = 'Remove a user suspension';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $user = User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/UserVerify.php b/src/app/Console/Commands/UserVerify.php
--- a/src/app/Console/Commands/UserVerify.php
+++ b/src/app/Console/Commands/UserVerify.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class UserVerify extends Command
{
@@ -21,23 +21,13 @@
protected $description = 'Verify the state of a user account';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $user = \App\User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/UserWallets.php b/src/app/Console/Commands/UserWallets.php
--- a/src/app/Console/Commands/UserWallets.php
+++ b/src/app/Console/Commands/UserWallets.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class UserWallets extends Command
{
@@ -21,23 +21,13 @@
protected $description = 'List wallets for a user';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $user = \App\User::where('email', $this->argument('user'))->first();
+ $user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
diff --git a/src/app/Console/Commands/WalletAddTransaction.php b/src/app/Console/Commands/WalletAddTransaction.php
--- a/src/app/Console/Commands/WalletAddTransaction.php
+++ b/src/app/Console/Commands/WalletAddTransaction.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class WalletAddTransaction extends Command
{
@@ -21,23 +21,13 @@
protected $description = 'Add a transaction to a wallet';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $wallet = \App\Wallet::find($this->argument('wallet'));
+ $wallet = $this->getWallet($this->argument('wallet'));
if (!$wallet) {
return 1;
diff --git a/src/app/Console/Commands/WalletBalances.php b/src/app/Console/Commands/WalletBalances.php
--- a/src/app/Console/Commands/WalletBalances.php
+++ b/src/app/Console/Commands/WalletBalances.php
@@ -21,29 +21,24 @@
protected $description = 'Show the balance on wallets';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- \App\Wallet::all()->each(
+ $wallets = \App\Wallet::select('wallets.*')
+ ->join('users', 'users.id', '=', 'wallets.user_id')
+ ->withEnvTenant('users')
+ ->all();
+
+ $wallets->each(
function ($wallet) {
if ($wallet->balance == 0) {
return;
}
- $user = \App\User::where('id', $wallet->user_id)->first();
+ $user = $wallet->owner;
if (!$user) {
return;
diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php
--- a/src/app/Console/Commands/WalletCharge.php
+++ b/src/app/Console/Commands/WalletCharge.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\Wallet;
-use Illuminate\Console\Command;
class WalletCharge extends Command
{
@@ -22,16 +22,6 @@
protected $description = 'Charge wallets';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
@@ -40,7 +30,7 @@
{
if ($wallet = $this->argument('wallet')) {
// Find specified wallet by ID
- $wallet = Wallet::find($wallet);
+ $wallet = $this->getWallet($wallet);
if (!$wallet || !$wallet->owner) {
return 1;
@@ -51,6 +41,7 @@
// Get all wallets, excluding deleted accounts
$wallets = Wallet::select('wallets.*')
->join('users', 'users.id', '=', 'wallets.user_id')
+ ->withEnvTenant('users')
->whereNull('users.deleted_at')
->cursor();
}
diff --git a/src/app/Console/Commands/WalletDiscount.php b/src/app/Console/Commands/WalletDiscount.php
--- a/src/app/Console/Commands/WalletDiscount.php
+++ b/src/app/Console/Commands/WalletDiscount.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class WalletDiscount extends Command
{
@@ -21,23 +21,13 @@
protected $description = 'Apply a discount to a wallet';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $wallet = \App\Wallet::where('id', $this->argument('wallet'))->first();
+ $wallet = $this->getWallet($this->argument('wallet'));
if (!$wallet) {
return 1;
@@ -48,7 +38,7 @@
if ($this->argument('discount') === '0') {
$wallet->discount()->dissociate();
} else {
- $discount = \App\Discount::find($this->argument('discount'));
+ $discount = $this->getObject(\App\Discount::class, $this->argument('discount'));
if (!$discount) {
return 1;
diff --git a/src/app/Console/Commands/WalletExpected.php b/src/app/Console/Commands/WalletExpected.php
--- a/src/app/Console/Commands/WalletExpected.php
+++ b/src/app/Console/Commands/WalletExpected.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class WalletExpected extends Command
{
@@ -21,16 +21,6 @@
protected $description = 'Show expected charges to wallets (for user)';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
@@ -38,8 +28,7 @@
public function handle()
{
if ($this->option('user')) {
- $user = \App\User::where('email', $this->option('user'))
- ->orWhere('id', $this->option('user'))->first();
+ $user = $this->getUser($this->option('user'));
if (!$user) {
return 1;
@@ -47,7 +36,10 @@
$wallets = $user->wallets;
} else {
- $wallets = \App\Wallet::all();
+ $wallets = \App\Wallet::select('wallets.*')
+ ->join('users', 'users.id', '=', 'wallets.user_id')
+ ->withEnvTenant('users')
+ ->all();
}
foreach ($wallets as $wallet) {
diff --git a/src/app/Console/Commands/WalletGetBalance.php b/src/app/Console/Commands/WalletGetBalance.php
--- a/src/app/Console/Commands/WalletGetBalance.php
+++ b/src/app/Console/Commands/WalletGetBalance.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class WalletGetBalance extends Command
{
@@ -21,23 +21,13 @@
protected $description = 'Display the balance of a wallet';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $wallet = \App\Wallet::find($this->argument('wallet'));
+ $wallet = $this->getWallet($this->argument('wallet'));
if (!$wallet) {
return 1;
diff --git a/src/app/Console/Commands/WalletGetDiscount.php b/src/app/Console/Commands/WalletGetDiscount.php
--- a/src/app/Console/Commands/WalletGetDiscount.php
+++ b/src/app/Console/Commands/WalletGetDiscount.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class WalletGetDiscount extends Command
{
@@ -21,23 +21,13 @@
protected $description = 'Display the existing discount to a wallet, if any.';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $wallet = \App\Wallet::find($this->argument('wallet'));
+ $wallet = $this->getWallet($this->argument('wallet'));
if (!$wallet) {
return 1;
diff --git a/src/app/Console/Commands/WalletMandate.php b/src/app/Console/Commands/WalletMandate.php
--- a/src/app/Console/Commands/WalletMandate.php
+++ b/src/app/Console/Commands/WalletMandate.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands;
+use App\Console\Command;
use App\Http\Controllers\API\V4\PaymentsController;
-use Illuminate\Console\Command;
class WalletMandate extends Command
{
@@ -22,23 +22,13 @@
protected $description = 'Show expected charges to wallets';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $wallet = \App\Wallet::find($this->argument('wallet'));
+ $wallet = $this->getWallet($this->argument('wallet'));
if (!$wallet) {
return 1;
diff --git a/src/app/Console/Commands/WalletSetBalance.php b/src/app/Console/Commands/WalletSetBalance.php
--- a/src/app/Console/Commands/WalletSetBalance.php
+++ b/src/app/Console/Commands/WalletSetBalance.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class WalletSetBalance extends Command
{
@@ -21,29 +21,19 @@
protected $description = 'Set the balance of a wallet';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $wallet = \App\Wallet::find($this->argument('wallet'));
+ $wallet = $this->getWallet($this->argument('wallet'));
if (!$wallet) {
return 1;
}
- $wallet->balance = (int)($this->argument('balance'));
+ $wallet->balance = (int) $this->argument('balance');
$wallet->save();
}
}
diff --git a/src/app/Console/Commands/WalletSetDiscount.php b/src/app/Console/Commands/WalletSetDiscount.php
--- a/src/app/Console/Commands/WalletSetDiscount.php
+++ b/src/app/Console/Commands/WalletSetDiscount.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class WalletSetDiscount extends Command
{
@@ -21,23 +21,13 @@
protected $description = 'Apply a discount to a wallet';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $wallet = \App\Wallet::where('id', $this->argument('wallet'))->first();
+ $wallet = $this->getWallet($this->argument('wallet'));
if (!$wallet) {
return 1;
@@ -48,7 +38,7 @@
if ($this->argument('discount') === '0') {
$wallet->discount()->dissociate();
} else {
- $discount = \App\Discount::find($this->argument('discount'));
+ $discount = $this->getObject(\App\Discount::class, $this->argument('discount'));
if (!$discount) {
return 1;
diff --git a/src/app/Console/Commands/WalletTransactions.php b/src/app/Console/Commands/WalletTransactions.php
--- a/src/app/Console/Commands/WalletTransactions.php
+++ b/src/app/Console/Commands/WalletTransactions.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class WalletTransactions extends Command
{
@@ -21,29 +21,19 @@
protected $description = 'List the transactions against a wallet.';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $wallet = \App\Wallet::where('id', $this->argument('wallet'))->first();
+ $wallet = $this->getWallet($this->argument('wallet'));
if (!$wallet) {
return 1;
}
- foreach ($wallet->transactions()->orderBy('created_at')->get() as $transaction) {
+ $wallet->transactions()->orderBy('created_at')->each(function ($transaction) {
$this->info(
sprintf(
"%s: %s %s",
@@ -67,6 +57,6 @@
);
}
}
- }
+ });
}
}
diff --git a/src/app/Console/Commands/WalletUntil.php b/src/app/Console/Commands/WalletUntil.php
--- a/src/app/Console/Commands/WalletUntil.php
+++ b/src/app/Console/Commands/WalletUntil.php
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class WalletUntil extends Command
{
@@ -21,23 +21,13 @@
protected $description = 'Show until when the balance on a wallet lasts.';
/**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
- /**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $wallet = \App\Wallet::find($this->argument('wallet'));
+ $wallet = $this->getWallet($this->argument('wallet'));
if (!$wallet) {
return 1;
diff --git a/src/app/Console/ObjectUpdateCommand.php b/src/app/Console/ObjectDeleteCommand.php
copy from src/app/Console/ObjectUpdateCommand.php
copy to src/app/Console/ObjectDeleteCommand.php
--- a/src/app/Console/ObjectUpdateCommand.php
+++ b/src/app/Console/ObjectDeleteCommand.php
@@ -8,13 +8,13 @@
/**
* This abstract class provides a means to treat objects in our model using CRUD.
*/
-abstract class ObjectUpdateCommand extends ObjectCommand
+abstract class ObjectDeleteCommand extends ObjectCommand
{
public function __construct()
{
- $this->description = "Update a {$this->objectName}";
+ $this->description = "Delete a {$this->objectName}";
$this->signature = sprintf(
- "%s%s:update {%s}",
+ "%s%s:delete {%s}",
$this->commandPrefix ? $this->commandPrefix . ":" : "",
$this->objectName,
$this->objectName
@@ -36,10 +36,6 @@
$classes = class_uses_recursive($this->objectClass);
- if (in_array(SoftDeletes::class, $classes)) {
- $this->signature .= " {--with-deleted : Include deleted {$this->objectName}s}";
- }
-
parent::__construct();
}
@@ -73,6 +69,12 @@
*/
public function handle()
{
+ $result = parent::handle();
+
+ if (!$result) {
+ return 1;
+ }
+
$argument = $this->argument($this->objectName);
$object = $this->getObject($this->objectClass, $argument, $this->objectTitle);
@@ -82,18 +84,12 @@
return 1;
}
- foreach ($this->getProperties() as $property => $value) {
- if ($property == "deleted_at" && $value == "null") {
- $value = null;
- }
-
- $object->{$property} = $value;
+ if ($this->commandPrefix == 'scalpel') {
+ $this->objectClass::withoutEvents(
+ function () use ($object) {
+ $object->delete();
+ }
+ );
}
-
- $object->timestamps = false;
-
- $object->save(['timestamps' => false]);
-
- $this->cacheRefresh($object);
}
}
diff --git a/src/app/Console/ObjectRelationListCommand.php b/src/app/Console/ObjectRelationListCommand.php
--- a/src/app/Console/ObjectRelationListCommand.php
+++ b/src/app/Console/ObjectRelationListCommand.php
@@ -67,19 +67,16 @@
return 1;
}
- if ($result instanceof \Illuminate\Database\Eloquent\Collection) {
- $result->each(
- function ($entry) {
- $this->info($this->toString($entry));
- }
- );
- } elseif ($result instanceof \Illuminate\Database\Eloquent\Relations\Relation) {
- $result->each(
- function ($entry) {
- $this->info($this->toString($entry));
- }
- );
- } elseif (is_array($result)) {
+ // Convert query builder into a collection
+ if ($result instanceof \Illuminate\Database\Eloquent\Relations\Relation) {
+ $result = $result->get();
+ }
+
+ // Print the result
+ if (
+ ($result instanceof \Illuminate\Database\Eloquent\Collection)
+ || is_array($result)
+ ) {
foreach ($result as $entry) {
$this->info($this->toString($entry));
}
diff --git a/src/app/Console/ObjectUpdateCommand.php b/src/app/Console/ObjectUpdateCommand.php
--- a/src/app/Console/ObjectUpdateCommand.php
+++ b/src/app/Console/ObjectUpdateCommand.php
@@ -92,7 +92,15 @@
$object->timestamps = false;
- $object->save(['timestamps' => false]);
+ if ($this->commandPrefix == 'scalpel') {
+ $this->objectClass::withoutEvents(
+ function () use ($object) {
+ $object->save();
+ }
+ );
+ } else {
+ $object->save();
+ }
$this->cacheRefresh($object);
}
diff --git a/src/app/Discount.php b/src/app/Discount.php
--- a/src/app/Discount.php
+++ b/src/app/Discount.php
@@ -7,6 +7,12 @@
/**
* The eloquent definition of a Discount.
+ *
+ * @property bool $active
+ * @property string $code
+ * @property string $description
+ * @property int $discount
+ * @property int $tenant_id
*/
class Discount extends Model
{
@@ -54,6 +60,16 @@
}
/**
+ * The tenant for this discount.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function tenant()
+ {
+ return $this->belongsTo('App\Tenant', 'tenant_id', 'id');
+ }
+
+ /**
* List of wallets with this discount assigned.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
diff --git a/src/app/Domain.php b/src/app/Domain.php
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -10,6 +10,9 @@
* The eloquent definition of a Domain.
*
* @property string $namespace
+ * @property int $status
+ * @property int $tenant_id
+ * @property int $type
*/
class Domain extends Model
{
@@ -86,6 +89,7 @@
'wallet_id' => $wallet_id,
'sku_id' => $sku->id,
'cost' => $sku->pivot->cost(),
+ 'fee' => $sku->pivot->fee(),
'entitleable_id' => $this->id,
'entitleable_type' => Domain::class
]
@@ -107,13 +111,13 @@
}
/**
- * Return list of public+active domain names
+ * Return list of public+active domain names (for current tenant)
*/
public static function getPublicDomains(): array
{
- $where = sprintf('(type & %s)', Domain::TYPE_PUBLIC);
-
- return self::whereRaw($where)->get(['namespace'])->pluck('namespace')->toArray();
+ return self::withEnvTenant()
+ ->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC))
+ ->get(['namespace'])->pluck('namespace')->toArray();
}
/**
@@ -377,6 +381,16 @@
}
/**
+ * 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;
diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php
--- a/src/app/Entitlement.php
+++ b/src/app/Entitlement.php
@@ -11,10 +11,18 @@
*
* Owned by a {@link \App\User}, billed to a {@link \App\Wallet}.
*
- * @property \App\User $owner The owner of this entitlement (subject).
- * @property \App\Sku $sku The SKU to which this entitlement applies.
- * @property \App\Wallet $wallet The wallet to which this entitlement is charged.
- * @property \App\Domain|\App\User $entitleable The entitled object (receiver of the entitlement).
+ * @property int $cost
+ * @property ?string $description
+ * @property \App\Domain|\App\User $entitleable The entitled object (receiver of the entitlement).
+ * @property int $entitleable_id
+ * @property string $entitleable_type
+ * @property int $fee
+ * @property string $id
+ * @property \App\User $owner The owner of this entitlement (subject).
+ * @property \App\Sku $sku The SKU to which this entitlement applies.
+ * @property string $sku_id
+ * @property \App\Wallet $wallet The wallet to which this entitlement is charged.
+ * @property string $wallet_id
*/
class Entitlement extends Model
{
@@ -45,11 +53,13 @@
'entitleable_id',
'entitleable_type',
'cost',
- 'description'
+ 'description',
+ 'fee',
];
protected $casts = [
'cost' => 'integer',
+ 'fee' => 'integer'
];
/**
diff --git a/src/app/Group.php b/src/app/Group.php
--- a/src/app/Group.php
+++ b/src/app/Group.php
@@ -9,10 +9,11 @@
/**
* The eloquent definition of a Group.
*
- * @property int $id The group identifier
- * @property string $email An email address
- * @property string $members A comma-separated list of email addresses
- * @property int $status The group status
+ * @property int $id The group identifier
+ * @property string $email An email address
+ * @property string $members A comma-separated list of email addresses
+ * @property int $status The group status
+ * @property int $tenant_id Tenant identifier
*/
class Group extends Model
{
@@ -64,6 +65,7 @@
'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' => Group::class
]);
@@ -251,6 +253,16 @@
}
/**
+ * The tenant for this group.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function tenant()
+ {
+ return $this->belongsTo('App\Tenant', 'tenant_id', 'id');
+ }
+
+ /**
* Unsuspend this group.
*
* @return void
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -12,6 +12,7 @@
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\SignupCode;
+use App\SignupInvitation;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -112,6 +113,33 @@
}
/**
+ * Returns signup invitation information.
+ *
+ * @param string $id Signup invitation identifier
+ *
+ * @return \Illuminate\Http\JsonResponse|void
+ */
+ public function invitation($id)
+ {
+ $invitation = SignupInvitation::withEnvTenant()->find($id);
+
+ if (empty($invitation) || $invitation->isCompleted()) {
+ return $this->errorResponse(404);
+ }
+
+ $has_domain = $this->getPlan()->hasDomain();
+
+ $result = [
+ 'id' => $id,
+ 'is_domain' => $has_domain,
+ 'domains' => $has_domain ? [] : Domain::getPublicDomains(),
+ ];
+
+ return response()->json($result);
+ }
+
+
+ /**
* Validation of the verification code.
*
* @param \Illuminate\Http\Request $request HTTP request
@@ -188,10 +216,50 @@
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
- // Validate verification codes (again)
- $v = $this->verify($request);
- if ($v->status() !== 200) {
- return $v;
+ // Signup via invitation
+ if ($request->invitation) {
+ $invitation = SignupInvitation::withEnvTenant()->find($request->invitation);
+
+ if (empty($invitation) || $invitation->isCompleted()) {
+ return $this->errorResponse(404);
+ }
+
+ // Check required fields
+ $v = Validator::make(
+ $request->all(),
+ [
+ 'first_name' => 'max:128',
+ 'last_name' => 'max:128',
+ 'voucher' => 'max:32',
+ ]
+ );
+
+ $errors = $v->fails() ? $v->errors()->toArray() : [];
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $settings = [
+ 'external_email' => $invitation->email,
+ 'first_name' => $request->first_name,
+ 'last_name' => $request->last_name,
+ ];
+ } else {
+ // Validate verification codes (again)
+ $v = $this->verify($request);
+ if ($v->status() !== 200) {
+ return $v;
+ }
+
+ // Get user name/email from the verification code database
+ $code_data = $v->getData();
+
+ $settings = [
+ 'external_email' => $code_data->email,
+ 'first_name' => $code_data->first_name,
+ 'last_name' => $code_data->last_name,
+ ];
}
// Find the voucher discount
@@ -217,10 +285,6 @@
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
- // Get user name/email from the verification code database
- $code_data = $v->getData();
- $user_email = $code_data->email;
-
// We allow only ASCII, so we can safely lower-case the email address
$login = Str::lower($login);
$domain_name = Str::lower($domain_name);
@@ -252,14 +316,19 @@
$user->assignPlan($plan, $domain);
// Save the external email and plan in user settings
- $user->setSettings([
- 'external_email' => $user_email,
- 'first_name' => $code_data->first_name,
- 'last_name' => $code_data->last_name,
- ]);
+ $user->setSettings($settings);
+
+ // Update the invitation
+ if (!empty($invitation)) {
+ $invitation->status = SignupInvitation::STATUS_COMPLETED;
+ $invitation->user_id = $user->id;
+ $invitation->save();
+ }
// Remove the verification code
- $this->code->delete();
+ if ($this->code) {
+ $this->code->delete();
+ }
DB::commit();
diff --git a/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php b/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php
--- a/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php
@@ -16,7 +16,10 @@
{
$discounts = [];
- Discount::where('active', true)->orderBy('discount')->get()
+ Discount::withEnvTenant()
+ ->where('active', true)
+ ->orderBy('discount')
+ ->get()
->map(function ($discount) use (&$discounts) {
$label = $discount->discount . '% - ' . $discount->description;
diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php
--- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php
@@ -20,7 +20,7 @@
$result = collect([]);
if ($owner) {
- if ($owner = User::find($owner)) {
+ if ($owner = User::withEnvTenant()->find($owner)) {
foreach ($owner->wallets as $wallet) {
$entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get();
@@ -33,7 +33,7 @@
$result = $result->sortBy('namespace')->values();
}
} elseif (!empty($search)) {
- if ($domain = Domain::where('namespace', $search)->first()) {
+ if ($domain = Domain::withEnvTenant()->where('namespace', $search)->first()) {
$result->push($domain);
}
}
@@ -64,7 +64,7 @@
*/
public function suspend(Request $request, $id)
{
- $domain = Domain::find($id);
+ $domain = Domain::withEnvTenant()->find($id);
if (empty($domain) || $domain->isPublic()) {
return $this->errorResponse(404);
@@ -88,7 +88,7 @@
*/
public function unsuspend(Request $request, $id)
{
- $domain = Domain::find($id);
+ $domain = Domain::withEnvTenant()->find($id);
if (empty($domain) || $domain->isPublic()) {
return $this->errorResponse(404);
diff --git a/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php b/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php
deleted file mode 100644
--- a/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php
+++ /dev/null
@@ -1,7 +0,0 @@
-<?php
-
-namespace App\Http\Controllers\API\V4\Admin;
-
-class EntitlementsController extends \App\Http\Controllers\API\V4\EntitlementsController
-{
-}
diff --git a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php
--- a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php
@@ -20,7 +20,7 @@
$result = collect([]);
if ($owner) {
- if ($owner = User::find($owner)) {
+ if ($owner = User::withEnvTenant()->find($owner)) {
foreach ($owner->wallets as $wallet) {
$wallet->entitlements()->where('entitleable_type', Group::class)->get()
->each(function ($entitlement) use ($result) {
@@ -31,7 +31,7 @@
$result = $result->sortBy('namespace')->values();
}
} elseif (!empty($search)) {
- if ($group = Group::where('email', $search)->first()) {
+ if ($group = Group::withEnvTenant()->where('email', $search)->first()) {
$result->push($group);
}
}
@@ -78,7 +78,7 @@
*/
public function suspend(Request $request, $id)
{
- $group = Group::find($id);
+ $group = Group::withEnvTenant()->find($id);
if (empty($group)) {
return $this->errorResponse(404);
@@ -102,7 +102,7 @@
*/
public function unsuspend(Request $request, $id)
{
- $group = Group::find($id);
+ $group = Group::withEnvTenant()->find($id);
if (empty($group)) {
return $this->errorResponse(404);
diff --git a/src/app/Http/Controllers/API/V4/Admin/StatsController.php b/src/app/Http/Controllers/API/V4/Admin/StatsController.php
--- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php
@@ -6,6 +6,7 @@
use App\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class StatsController extends \App\Http\Controllers\Controller
@@ -18,6 +19,14 @@
public const COLOR_BLUE_DARK = '#0056b3';
public const COLOR_ORANGE = '#f1a539';
+ /** @var array List of enabled charts */
+ protected $charts = [
+ 'discounts',
+ 'income',
+ 'users',
+ 'users-all',
+ ];
+
/**
* Fetch chart data
*
@@ -33,7 +42,7 @@
$method = 'chart' . implode('', array_map('ucfirst', explode('-', $chart)));
- if (!method_exists($this, $method)) {
+ if (!in_array($chart, $this->charts) || !method_exists($this, $method)) {
return $this->errorResponse(404);
}
@@ -53,9 +62,14 @@
->join('users', 'users.id', '=', 'wallets.user_id')
->where('discount', '>', 0)
->whereNull('users.deleted_at')
- ->groupBy('discounts.discount')
- ->pluck('cnt', 'discount')
- ->all();
+ ->groupBy('discounts.discount');
+
+ $addTenantScope = function ($builder, $tenantId) {
+ return $builder->where('users.tenant_id', $tenantId);
+ };
+
+ $discounts = $this->applyTenantScope($discounts, $addTenantScope)
+ ->pluck('cnt', 'discount')->all();
$labels = array_keys($discounts);
$discounts = array_values($discounts);
@@ -119,7 +133,20 @@
->where('updated_at', '>=', $start->toDateString())
->where('status', PaymentProvider::STATUS_PAID)
->whereIn('type', [PaymentProvider::TYPE_ONEOFF, PaymentProvider::TYPE_RECURRING])
- ->groupByRaw('1')
+ ->groupByRaw('1');
+
+ $addTenantScope = function ($builder, $tenantId) {
+ $where = '`wallet_id` IN ('
+ . 'select `id` from `wallets` '
+ . 'join `users` on (`wallets`.`user_id` = `users`.`id`) '
+ . 'where `payments`.`wallet_id` = `wallets`.`id` '
+ . 'and `users`.`tenant_id` = ' . intval($tenantId)
+ . ')';
+
+ return $builder->whereRaw($where);
+ };
+
+ $payments = $this->applyTenantScope($payments, $addTenantScope)
->pluck('amount', 'period')
->map(function ($amount) {
return $amount / 100;
@@ -185,14 +212,15 @@
$created = DB::table('users')
->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt")
->where('created_at', '>=', $start->toDateString())
- ->groupByRaw('1')
- ->get();
+ ->groupByRaw('1');
$deleted = DB::table('users')
->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt")
->where('deleted_at', '>=', $start->toDateString())
- ->groupByRaw('1')
- ->get();
+ ->groupByRaw('1');
+
+ $created = $this->applyTenantScope($created)->get();
+ $deleted = $this->applyTenantScope($deleted)->get();
$empty = array_fill_keys($labels, 0);
$created = array_values(array_merge($empty, $created->pluck('cnt', 'period')->all()));
@@ -260,16 +288,16 @@
$created = DB::table('users')
->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt")
->where('created_at', '>=', $start->toDateString())
- ->groupByRaw('1')
- ->get();
+ ->groupByRaw('1');
$deleted = DB::table('users')
->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt")
->where('deleted_at', '>=', $start->toDateString())
- ->groupByRaw('1')
- ->get();
+ ->groupByRaw('1');
- $count = DB::table('users')->whereNull('deleted_at')->count();
+ $created = $this->applyTenantScope($created)->get();
+ $deleted = $this->applyTenantScope($deleted)->get();
+ $count = $this->applyTenantScope(DB::table('users')->whereNull('deleted_at'))->count();
$empty = array_fill_keys($labels, 0);
$created = array_merge($empty, $created->pluck('cnt', 'period')->all());
@@ -313,4 +341,29 @@
]
];
}
+
+ /**
+ * Add tenant scope to the queries when needed
+ *
+ * @param \Illuminate\Database\Query\Builder $query The query
+ * @param callable $addQuery Additional tenant-scope query-modifier
+ *
+ * @return \Illuminate\Database\Query\Builder
+ */
+ protected function applyTenantScope($query, $addQuery = null)
+ {
+ $user = Auth::guard()->user();
+
+ if ($user->role == 'reseller') {
+ if ($addQuery) {
+ $query = $addQuery($query, \config('app.tenant_id'));
+ } else {
+ $query = $query->withEnvTenant();
+ }
+ }
+
+ // TODO: Tenant selector for admins
+
+ return $query;
+ }
}
diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
--- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
@@ -14,6 +14,18 @@
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
@@ -25,13 +37,21 @@
$result = collect([]);
if ($owner) {
- if ($owner = User::find($owner)) {
- $result = $owner->users(false)->orderBy('email')->get();
+ $owner = User::where('id', $owner)
+ ->withEnvTenant()
+ ->whereNull('role')
+ ->first();
+
+ if ($owner) {
+ $result = $owner->users(false)->whereNull('role')->orderBy('email')->get();
}
} elseif (strpos($search, '@')) {
// Search by email
$result = User::withTrashed()->where('email', $search)
- ->orderBy('email')->get();
+ ->withEnvTenant()
+ ->whereNull('role')
+ ->orderBy('email')
+ ->get();
if ($result->isEmpty()) {
// Search by an alias
@@ -39,7 +59,9 @@
// Search by an external email
$ext_user_ids = UserSetting::where('key', 'external_email')
- ->where('value', $search)->get()->pluck('user_id');
+ ->where('value', $search)
+ ->get()
+ ->pluck('user_id');
$user_ids = $user_ids->merge($ext_user_ids)->unique();
@@ -50,19 +72,35 @@
if (!$user_ids->isEmpty()) {
$result = User::withTrashed()->whereIn('id', $user_ids)
- ->orderBy('email')->get();
+ ->withEnvTenant()
+ ->whereNull('role')
+ ->orderBy('email')
+ ->get();
}
}
} elseif (is_numeric($search)) {
// Search by user ID
- if ($user = User::withTrashed()->find($search)) {
+ $user = User::withTrashed()->where('id', $search)
+ ->withEnvTenant()
+ ->whereNull('role')
+ ->first();
+
+ if ($user) {
$result->push($user);
}
} elseif (!empty($search)) {
// Search by domain
- if ($domain = Domain::withTrashed()->where('namespace', $search)->first()) {
- if ($wallet = $domain->wallet()) {
- $result->push($wallet->owner()->withTrashed()->first());
+ $domain = Domain::withTrashed()->where('namespace', $search)
+ ->withEnvTenant()
+ ->first();
+
+ if ($domain) {
+ if (
+ ($wallet = $domain->wallet())
+ && ($owner = $wallet->owner()->withTrashed()->withEnvTenant()->first())
+ && empty($owner->role)
+ ) {
+ $result->push($owner);
}
}
}
@@ -93,9 +131,9 @@
*/
public function reset2FA(Request $request, $id)
{
- $user = User::find($id);
+ $user = User::withEnvTenant()->find($id);
- if (empty($user)) {
+ if (empty($user) || !$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(404);
}
@@ -114,6 +152,18 @@
}
/**
+ * 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.
@@ -123,9 +173,9 @@
*/
public function suspend(Request $request, $id)
{
- $user = User::find($id);
+ $user = User::withEnvTenant()->find($id);
- if (empty($user)) {
+ if (empty($user) || !$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(404);
}
@@ -147,9 +197,9 @@
*/
public function unsuspend(Request $request, $id)
{
- $user = User::find($id);
+ $user = User::withEnvTenant()->find($id);
- if (empty($user)) {
+ if (empty($user) || !$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(404);
}
@@ -171,9 +221,9 @@
*/
public function update(Request $request, $id)
{
- $user = User::find($id);
+ $user = User::withEnvTenant()->find($id);
- if (empty($user)) {
+ if (empty($user) || !$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(404);
}
diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
--- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
@@ -8,6 +8,7 @@
use App\Transaction;
use App\Wallet;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
@@ -24,7 +25,7 @@
{
$wallet = Wallet::find($id);
- if (empty($wallet)) {
+ if (empty($wallet) || !Auth::guard()->user()->canRead($wallet)) {
return $this->errorResponse(404);
}
@@ -44,6 +45,7 @@
$result['provider'] = $provider->name();
$result['providerLink'] = $provider->customerLink($wallet);
+ $result['notice'] = $this->getWalletNotice($wallet); // for resellers
return response()->json($result);
}
@@ -59,8 +61,9 @@
public function oneOff(Request $request, $id)
{
$wallet = Wallet::find($id);
+ $user = Auth::guard()->user();
- if (empty($wallet)) {
+ if (empty($wallet) || !$user->canRead($wallet)) {
return $this->errorResponse(404);
}
@@ -96,6 +99,14 @@
]
);
+ if ($user->role == 'reseller') {
+ if ($user->tenant && ($tenant_wallet = $user->tenant->wallet())) {
+ $desc = ($amount > 0 ? 'Awarded' : 'Penalized') . " user {$wallet->owner->email}";
+ $method = $amount > 0 ? 'debit' : 'credit';
+ $tenant_wallet->{$method}(abs($amount), $desc);
+ }
+ }
+
DB::commit();
$response = [
@@ -119,7 +130,7 @@
{
$wallet = Wallet::find($id);
- if (empty($wallet)) {
+ if (empty($wallet) || !Auth::guard()->user()->canRead($wallet)) {
return $this->errorResponse(404);
}
@@ -127,7 +138,7 @@
if (empty($request->discount)) {
$wallet->discount()->dissociate();
$wallet->save();
- } elseif ($discount = Discount::find($request->discount)) {
+ } elseif ($discount = Discount::withEnvTenant()->find($request->discount)) {
$wallet->discount()->associate($discount);
$wallet->save();
}
diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php
--- a/src/app/Http/Controllers/API/V4/DomainsController.php
+++ b/src/app/Http/Controllers/API/V4/DomainsController.php
@@ -117,7 +117,7 @@
*/
public function show($id)
{
- $domain = Domain::findOrFail($id);
+ $domain = Domain::withEnvTenant()->findOrFail($id);
// Only owner (or admin) has access to the domain
if (!Auth::guard()->user()->canRead($domain)) {
@@ -152,7 +152,7 @@
*/
public function status($id)
{
- $domain = Domain::find($id);
+ $domain = Domain::withEnvTenant()->findOrFail($id);
// Only owner (or admin) has access to the domain
if (!Auth::guard()->user()->canRead($domain)) {
diff --git a/src/app/Http/Controllers/API/V4/EntitlementsController.php b/src/app/Http/Controllers/API/V4/EntitlementsController.php
deleted file mode 100644
--- a/src/app/Http/Controllers/API/V4/EntitlementsController.php
+++ /dev/null
@@ -1,97 +0,0 @@
-<?php
-
-namespace App\Http\Controllers\API\V4;
-
-use App\Http\Controllers\Controller;
-use Illuminate\Http\Request;
-
-class EntitlementsController extends Controller
-{
- /**
- * Show the form for creating a new resource.
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function create()
- {
- // TODO
- return $this->errorResponse(404);
- }
-
- /**
- * Remove the specified resource from storage.
- *
- * @param int $id
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function destroy($id)
- {
- // TODO
- return $this->errorResponse(404);
- }
-
- /**
- * Show the form for editing the specified resource.
- *
- * @param int $id
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function edit($id)
- {
- // TODO
- return $this->errorResponse(404);
- }
-
- /**
- * Display a listing of the resource.
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function index()
- {
- // TODO
- return $this->errorResponse(404);
- }
-
- /**
- * Store a newly created resource in storage.
- *
- * @param \Illuminate\Http\Request $request
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function store(Request $request)
- {
- // TODO
- return $this->errorResponse(404);
- }
-
- /**
- * Display the specified resource.
- *
- * @param int $id
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function show($id)
- {
- // TODO
- return $this->errorResponse(404);
- }
-
- /**
- * Update the specified resource in storage.
- *
- * @param \Illuminate\Http\Request $request
- * @param int $id
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function update(Request $request, $id)
- {
- // TODO
- return $this->errorResponse(404);
- }
-}
diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php
--- a/src/app/Http/Controllers/API/V4/GroupsController.php
+++ b/src/app/Http/Controllers/API/V4/GroupsController.php
@@ -32,7 +32,7 @@
*/
public function destroy($id)
{
- $group = Group::find($id);
+ $group = Group::withEnvTenant()->find($id);
if (empty($group)) {
return $this->errorResponse(404);
@@ -96,7 +96,7 @@
*/
public function show($id)
{
- $group = Group::find($id);
+ $group = Group::withEnvTenant()->find($id);
if (empty($group)) {
return $this->errorResponse(404);
@@ -123,7 +123,7 @@
*/
public function status($id)
{
- $group = Group::find($id);
+ $group = Group::withEnvTenant()->find($id);
if (empty($group)) {
return $this->errorResponse(404);
@@ -308,7 +308,7 @@
*/
public function update(Request $request, $id)
{
- $group = Group::find($id);
+ $group = Group::withEnvTenant()->find($id);
if (empty($group)) {
return $this->errorResponse(404);
diff --git a/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php b/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+class DiscountsController extends \App\Http\Controllers\API\V4\Admin\DiscountsController
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/DomainsController.php b/src/app/Http/Controllers/API/V4/Reseller/DomainsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/DomainsController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+class DomainsController extends \App\Http\Controllers\API\V4\Admin\DomainsController
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+class GroupsController extends \App\Http\Controllers\API\V4\Admin\GroupsController
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/InvitationsController.php b/src/app/Http/Controllers/API/V4/Reseller/InvitationsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/InvitationsController.php
@@ -0,0 +1,252 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+use App\Http\Controllers\Controller;
+use App\SignupInvitation;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Validator;
+
+class InvitationsController extends Controller
+{
+ /**
+ * Show the form for creating a new resource.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function create()
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Remove the specified invitation.
+ *
+ * @param int $id Invitation identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function destroy($id)
+ {
+ $invitation = SignupInvitation::withUserTenant()->find($id);
+
+ if (empty($invitation)) {
+ return $this->errorResponse(404);
+ }
+
+ $invitation->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => trans('app.signup-invitation-delete-success'),
+ ]);
+ }
+
+ /**
+ * Show the form for editing the specified resource.
+ *
+ * @param int $id Invitation identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function edit($id)
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Display a listing of the resource.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $pageSize = 10;
+ $search = request()->input('search');
+ $page = intval(request()->input('page')) ?: 1;
+ $hasMore = false;
+
+ $result = SignupInvitation::withUserTenant()
+ ->latest()
+ ->limit($pageSize + 1)
+ ->offset($pageSize * ($page - 1));
+
+ if ($search) {
+ if (strpos($search, '@')) {
+ $result->where('email', $search);
+ } else {
+ $result->whereLike('email', $search);
+ }
+ }
+
+ $result = $result->get();
+
+ if (count($result) > $pageSize) {
+ $result->pop();
+ $hasMore = true;
+ }
+
+ $result = $result->map(function ($invitation) {
+ return $this->invitationToArray($invitation);
+ });
+
+ return response()->json([
+ 'status' => 'success',
+ 'list' => $result,
+ 'count' => count($result),
+ 'hasMore' => $hasMore,
+ 'page' => $page,
+ ]);
+ }
+
+ /**
+ * Resend the specified invitation.
+ *
+ * @param int $id Invitation identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function resend($id)
+ {
+ $invitation = SignupInvitation::withUserTenant()->find($id);
+
+ if (empty($invitation)) {
+ return $this->errorResponse(404);
+ }
+
+ if ($invitation->isFailed() || $invitation->isSent()) {
+ // Note: The email sending job will be dispatched by the observer
+ $invitation->status = SignupInvitation::STATUS_NEW;
+ $invitation->save();
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => trans('app.signup-invitation-resend-success'),
+ 'invitation' => $this->invitationToArray($invitation),
+ ]);
+ }
+
+ /**
+ * Store a newly created resource in storage.
+ *
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function store(Request $request)
+ {
+ $errors = [];
+ $invitations = [];
+
+ if (!empty($request->file) && is_object($request->file)) {
+ // Expected a text/csv file with multiple email addresses
+ if (!$request->file->isValid()) {
+ $errors = ['file' => [$request->file->getErrorMessage()]];
+ } else {
+ $fh = fopen($request->file->getPathname(), 'r');
+ $line_number = 0;
+ $error = null;
+
+ while ($line = fgetcsv($fh)) {
+ $line_number++;
+
+ // @phpstan-ignore-next-line
+ if (count($line) >= 1 && $line[0]) {
+ $email = trim($line[0]);
+
+ if (strpos($email, '@')) {
+ $v = Validator::make(['email' => $email], ['email' => 'email:filter|required']);
+
+ if ($v->fails()) {
+ $args = ['email' => $email, 'line' => $line_number];
+ $error = trans('app.signup-invitations-csv-invalid-email', $args);
+ break;
+ }
+
+ $invitations[] = ['email' => $email];
+ }
+ }
+ }
+
+ fclose($fh);
+
+ if ($error) {
+ $errors = ['file' => $error];
+ } elseif (empty($invitations)) {
+ $errors = ['file' => trans('app.signup-invitations-csv-empty')];
+ }
+ }
+ } else {
+ // Expected 'email' field with an email address
+ $v = Validator::make($request->all(), ['email' => 'email|required']);
+
+ if ($v->fails()) {
+ $errors = $v->errors()->toArray();
+ } else {
+ $invitations[] = ['email' => $request->email];
+ }
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $count = 0;
+ foreach ($invitations as $idx => $invitation) {
+ SignupInvitation::create($invitation);
+ $count++;
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans_choice('app.signup-invitations-created', $count, ['count' => $count]),
+ 'count' => $count,
+ ]);
+ }
+
+ /**
+ * Display the specified resource.
+ *
+ * @param int $id Invitation identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function show($id)
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Update the specified resource in storage.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @param int $id
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function update(Request $request, $id)
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Convert an invitation object to an array for output
+ *
+ * @param \App\SignupInvitation $invitation The signup invitation object
+ *
+ * @return array
+ */
+ protected static function invitationToArray(SignupInvitation $invitation): array
+ {
+ return [
+ 'id' => $invitation->id,
+ 'email' => $invitation->email,
+ 'isNew' => $invitation->isNew(),
+ 'isSent' => $invitation->isSent(),
+ 'isFailed' => $invitation->isFailed(),
+ 'isCompleted' => $invitation->isCompleted(),
+ 'created' => $invitation->created_at->toDateTimeString(),
+ ];
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/PackagesController.php b/src/app/Http/Controllers/API/V4/Reseller/PackagesController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/PackagesController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+class PackagesController extends \App\Http\Controllers\API\V4\PackagesController
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/PaymentsController.php b/src/app/Http/Controllers/API/V4/Reseller/PaymentsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/PaymentsController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+class PaymentsController extends \App\Http\Controllers\API\V4\PaymentsController
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/SkusController.php b/src/app/Http/Controllers/API/V4/Reseller/SkusController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/SkusController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+class SkusController extends \App\Http\Controllers\API\V4\SkusController
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/StatsController.php b/src/app/Http/Controllers/API/V4/Reseller/StatsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/StatsController.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+class StatsController extends \App\Http\Controllers\API\V4\Admin\StatsController
+{
+ /** @var array List of enabled charts */
+ protected $charts = [
+ 'discounts',
+ // 'income',
+ 'users',
+ 'users-all',
+ ];
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+class UsersController extends \App\Http\Controllers\API\V4\Admin\UsersController
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/WalletsController.php b/src/app/Http/Controllers/API/V4/Reseller/WalletsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/WalletsController.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+use App\Discount;
+use App\Wallet;
+use Illuminate\Http\Request;
+
+class WalletsController extends \App\Http\Controllers\API\V4\Admin\WalletsController
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php
--- a/src/app/Http/Controllers/API/V4/SkusController.php
+++ b/src/app/Http/Controllers/API/V4/SkusController.php
@@ -120,7 +120,7 @@
*/
public function userSkus($id)
{
- $user = \App\User::find($id);
+ $user = \App\User::withEnvTenant()->find($id);
if (empty($user)) {
return $this->errorResponse(404);
@@ -186,7 +186,7 @@
$data['name'] = $sku->name;
$data['description'] = $sku->description;
- unset($data['handler_class'], $data['created_at'], $data['updated_at']);
+ unset($data['handler_class'], $data['created_at'], $data['updated_at'], $data['fee'], $data['tenant_id']);
return $data;
}
diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php
--- a/src/app/Http/Controllers/Controller.php
+++ b/src/app/Http/Controllers/Controller.php
@@ -24,7 +24,7 @@
*
* @return \Illuminate\Http\JsonResponse
*/
- protected function errorResponse(int $code, string $message = null, array $data = [])
+ public static function errorResponse(int $code, string $message = null, array $data = [])
{
$errors = [
400 => "Bad request",
diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -64,6 +64,7 @@
'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,
@@ -80,11 +81,11 @@
\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,
- \App\Http\Middleware\AuthenticateAdmin::class,
];
/**
diff --git a/src/app/Http/Middleware/AuthenticateAdmin.php b/src/app/Http/Middleware/AuthenticateAdmin.php
--- a/src/app/Http/Middleware/AuthenticateAdmin.php
+++ b/src/app/Http/Middleware/AuthenticateAdmin.php
@@ -18,7 +18,7 @@
$user = auth()->user();
if (!$user) {
- abort(403, "Unauthorized");
+ abort(401, "Unauthorized");
}
if ($user->role !== "admin") {
diff --git a/src/app/Http/Middleware/AuthenticateAdmin.php b/src/app/Http/Middleware/AuthenticateReseller.php
copy from src/app/Http/Middleware/AuthenticateAdmin.php
copy to src/app/Http/Middleware/AuthenticateReseller.php
--- a/src/app/Http/Middleware/AuthenticateAdmin.php
+++ b/src/app/Http/Middleware/AuthenticateReseller.php
@@ -4,7 +4,7 @@
use Closure;
-class AuthenticateAdmin
+class AuthenticateReseller
{
/**
* Handle an incoming request.
@@ -18,10 +18,14 @@
$user = auth()->user();
if (!$user) {
+ abort(401, "Unauthorized");
+ }
+
+ if ($user->role !== "reseller") {
abort(403, "Unauthorized");
}
- if ($user->role !== "admin") {
+ if ($user->tenant_id != \config('app.tenant_id')) {
abort(403, "Unauthorized");
}
diff --git a/src/app/Jobs/SignupInvitationEmail.php b/src/app/Jobs/SignupInvitationEmail.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SignupInvitationEmail.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Jobs;
+
+use App\SignupInvitation;
+use App\Mail\SignupInvitation as SignupInvitationMail;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+
+class SignupInvitationEmail implements ShouldQueue
+{
+ use Dispatchable;
+ use InteractsWithQueue;
+ use Queueable;
+ use SerializesModels;
+
+ /** @var int The number of times the job may be attempted. */
+ public $tries = 3;
+
+ /** @var bool Delete the job if its models no longer exist. */
+ public $deleteWhenMissingModels = true;
+
+ /** @var int The number of seconds to wait before retrying the job. */
+ public $retryAfter = 10;
+
+ /** @var SignupInvitation Signup invitation object */
+ protected $invitation;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param SignupInvitation $invitation Invitation object
+ *
+ * @return void
+ */
+ public function __construct(SignupInvitation $invitation)
+ {
+ $this->invitation = $invitation;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ Mail::to($this->invitation->email)->send(new SignupInvitationMail($this->invitation));
+
+ // Update invitation status
+ $this->invitation->status = SignupInvitation::STATUS_SENT;
+ $this->invitation->save();
+ }
+
+ /**
+ * The job failed to process.
+ *
+ * @param \Exception $exception
+ *
+ * @return void
+ */
+ public function failed(\Exception $exception)
+ {
+ if ($this->attempts() >= $this->tries) {
+ // Update invitation status
+ $this->invitation->status = SignupInvitation::STATUS_FAILED;
+ $this->invitation->save();
+ }
+ }
+}
diff --git a/src/app/Mail/SignupInvitation.php b/src/app/Mail/SignupInvitation.php
new file mode 100644
--- /dev/null
+++ b/src/app/Mail/SignupInvitation.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace App\Mail;
+
+use App\SignupInvitation as SI;
+use App\Utils;
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Str;
+
+class SignupInvitation extends Mailable
+{
+ use Queueable;
+ use SerializesModels;
+
+ /** @var \App\SignupInvitation A signup invitation object */
+ protected $invitation;
+
+
+ /**
+ * Create a new message instance.
+ *
+ * @param \App\SignupInvitation $invitation A signup invitation object
+ *
+ * @return void
+ */
+ public function __construct(SI $invitation)
+ {
+ $this->invitation = $invitation;
+ }
+
+ /**
+ * Build the message.
+ *
+ * @return $this
+ */
+ public function build()
+ {
+ $href = Utils::serviceUrl('/signup/invite/' . $this->invitation->id);
+
+ $this->view('emails.html.signup_invitation')
+ ->text('emails.plain.signup_invitation')
+ ->subject(__('mail.signupinvitation-subject', ['site' => \config('app.name')]))
+ ->with([
+ 'site' => \config('app.name'),
+ 'href' => $href,
+ ]);
+
+ return $this;
+ }
+
+ /**
+ * Render the mail template with fake data
+ *
+ * @param string $type Output format ('html' or 'text')
+ *
+ * @return string HTML or Plain Text output
+ */
+ public static function fakeRender(string $type = 'html'): string
+ {
+ $invitation = new SI([
+ 'email' => 'test@external.org',
+ ]);
+
+ $invitation->id = Utils::uuidStr();
+
+ $mail = new self($invitation);
+
+ return Helper::render($mail, $type);
+ }
+}
diff --git a/src/app/Observers/DiscountObserver.php b/src/app/Observers/DiscountObserver.php
--- a/src/app/Observers/DiscountObserver.php
+++ b/src/app/Observers/DiscountObserver.php
@@ -25,5 +25,7 @@
break;
}
}
+
+ $discount->tenant_id = \config('app.tenant_id');
}
}
diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php
--- a/src/app/Observers/DomainObserver.php
+++ b/src/app/Observers/DomainObserver.php
@@ -27,6 +27,8 @@
$domain->namespace = \strtolower($domain->namespace);
$domain->status |= Domain::STATUS_NEW;
+
+ $domain->tenant_id = \config('app.tenant_id');
}
/**
diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php
--- a/src/app/Observers/EntitlementObserver.php
+++ b/src/app/Observers/EntitlementObserver.php
@@ -123,7 +123,6 @@
return;
}
- $cost = 0;
$now = Carbon::now();
// get the discount rate applied to the wallet.
@@ -131,7 +130,8 @@
// just in case this had not been billed yet, ever
$diffInMonths = $entitlement->updated_at->diffInMonths($now);
- $cost += (int) ($entitlement->cost * $discount * $diffInMonths);
+ $cost = (int) ($entitlement->cost * $discount * $diffInMonths);
+ $fee = (int) ($entitlement->fee * $diffInMonths);
// this moves the hypothetical updated at forward to however many months past the original
$updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths);
@@ -153,8 +153,18 @@
}
$pricePerDay = $entitlement->cost / $daysInMonth;
+ $feePerDay = $entitlement->fee / $daysInMonth;
$cost += (int) (round($pricePerDay * $discount * $diffInDays, 0));
+ $fee += (int) (round($feePerDay * $diffInDays, 0));
+
+ $profit = $cost - $fee;
+
+ if ($profit != 0 && $owner->tenant && ($wallet = $owner->tenant->wallet())) {
+ $desc = "Charged user {$owner->email}";
+ $method = $profit > 0 ? 'credit' : 'debit';
+ $wallet->{$method}(abs($profit), $desc);
+ }
if ($cost == 0) {
return;
diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php
--- a/src/app/Observers/GroupObserver.php
+++ b/src/app/Observers/GroupObserver.php
@@ -25,6 +25,8 @@
}
$group->status |= Group::STATUS_NEW | Group::STATUS_ACTIVE;
+
+ $group->tenant_id = \config('app.tenant_id');
}
/**
diff --git a/src/app/Observers/PackageObserver.php b/src/app/Observers/PackageObserver.php
--- a/src/app/Observers/PackageObserver.php
+++ b/src/app/Observers/PackageObserver.php
@@ -27,5 +27,7 @@
break;
}
}
+
+ $package->tenant_id = \config('app.tenant_id');
}
}
diff --git a/src/app/Observers/PackageSkuObserver.php b/src/app/Observers/PackageSkuObserver.php
--- a/src/app/Observers/PackageSkuObserver.php
+++ b/src/app/Observers/PackageSkuObserver.php
@@ -7,6 +7,25 @@
class PackageSkuObserver
{
/**
+ * Handle the "creating" event on an PackageSku relation.
+ *
+ * Ensures that the entries belong to the same tenant.
+ *
+ * @param \App\PackageSku $packageSku The package-sku relation
+ *
+ * @return void
+ */
+ public function creating(PackageSku $packageSku)
+ {
+ $package = $packageSku->package;
+ $sku = $packageSku->sku;
+
+ if ($package->tenant_id != $sku->tenant_id) {
+ throw new \Exception("Package and SKU owned by different tenants");
+ }
+ }
+
+ /**
* Handle the "created" event on an PackageSku relation
*
* @param \App\PackageSku $packageSku The package-sku relation
diff --git a/src/app/Observers/PlanObserver.php b/src/app/Observers/PlanObserver.php
--- a/src/app/Observers/PlanObserver.php
+++ b/src/app/Observers/PlanObserver.php
@@ -27,5 +27,7 @@
break;
}
}
+
+ $plan->tenant_id = \config('app.tenant_id');
}
}
diff --git a/src/app/Observers/PlanPackageObserver.php b/src/app/Observers/PlanPackageObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/PlanPackageObserver.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Observers;
+
+use App\PlanPackage;
+
+class PlanPackageObserver
+{
+ /**
+ * Handle the "creating" event on an PlanPackage relation.
+ *
+ * Ensures that the entries belong to the same tenant.
+ *
+ * @param \App\PlanPackage $planPackage The plan-package relation
+ *
+ * @return void
+ */
+ public function creating(PlanPackage $planPackage)
+ {
+ $package = $planPackage->package;
+ $plan = $planPackage->plan;
+
+ if ($package->tenant_id != $plan->tenant_id) {
+ throw new \Exception("Package and Plan owned by different tenants");
+ }
+ }
+}
diff --git a/src/app/Observers/SignupInvitationObserver.php b/src/app/Observers/SignupInvitationObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/SignupInvitationObserver.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace App\Observers;
+
+use App\SignupInvitation as SI;
+
+/**
+ * This is an observer for the SignupInvitation model definition.
+ */
+class SignupInvitationObserver
+{
+ /**
+ * Ensure the invitation ID is a custom ID (uuid).
+ *
+ * @param \App\SignupInvitation $invitation The invitation object
+ *
+ * @return void
+ */
+ public function creating(SI $invitation)
+ {
+ while (true) {
+ $allegedly_unique = \App\Utils::uuidStr();
+ if (!SI::find($allegedly_unique)) {
+ $invitation->{$invitation->getKeyName()} = $allegedly_unique;
+ break;
+ }
+ }
+
+ $invitation->status = SI::STATUS_NEW;
+
+ $invitation->tenant_id = \config('app.tenant_id');
+ }
+
+ /**
+ * Handle the invitation "created" event.
+ *
+ * @param \App\SignupInvitation $invitation The invitation object
+ *
+ * @return void
+ */
+ public function created(SI $invitation)
+ {
+ \App\Jobs\SignupInvitationEmail::dispatch($invitation);
+ }
+
+ /**
+ * Handle the invitation "updated" event.
+ *
+ * @param \App\SignupInvitation $invitation The invitation object
+ *
+ * @return void
+ */
+ public function updated(SI $invitation)
+ {
+ $oldStatus = $invitation->getOriginal('status');
+
+ // Resend the invitation
+ if (
+ $invitation->status == SI::STATUS_NEW
+ && ($oldStatus == SI::STATUS_FAILED || $oldStatus == SI::STATUS_SENT)
+ ) {
+ \App\Jobs\SignupInvitationEmail::dispatch($invitation);
+ }
+ }
+}
diff --git a/src/app/Observers/SkuObserver.php b/src/app/Observers/SkuObserver.php
--- a/src/app/Observers/SkuObserver.php
+++ b/src/app/Observers/SkuObserver.php
@@ -22,5 +22,9 @@
break;
}
}
+
+ $sku->tenant_id = \config('app.tenant_id');
+
+ // TODO: We should make sure that tenant_id + title is unique
}
}
diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php
--- a/src/app/Observers/UserObserver.php
+++ b/src/app/Observers/UserObserver.php
@@ -38,7 +38,7 @@
// only users that are not imported get the benefit of the doubt.
$user->status |= User::STATUS_NEW | User::STATUS_ACTIVE;
- // can't dispatch job here because it'll fail serialization
+ $user->tenant_id = \config('app.tenant_id');
}
/**
@@ -108,6 +108,18 @@
}
});
}
+
+ // Debit the reseller's wallet with the user negative balance
+ $balance = 0;
+ foreach ($user->wallets as $wallet) {
+ // Note: here we assume all user wallets are using the same currency.
+ // It might get changed in the future
+ $balance += $wallet->balance;
+ }
+
+ if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) {
+ $wallet->debit($balance * -1, "Deleted user {$user->email}");
+ }
}
/**
diff --git a/src/app/Package.php b/src/app/Package.php
--- a/src/app/Package.php
+++ b/src/app/Package.php
@@ -21,6 +21,13 @@
* * Free package: mailbox + quota.
*
* Selecting a package will therefore create a set of entitlments from SKUs.
+ *
+ * @property string $description
+ * @property int $discount_rate
+ * @property string $id
+ * @property string $name
+ * @property ?int $tenant_id
+ * @property string $title
*/
class Package extends Model
{
@@ -69,7 +76,10 @@
return $costs;
}
- public function isDomain()
+ /**
+ * Checks whether the package contains a domain SKU.
+ */
+ public function isDomain(): bool
{
foreach ($this->skus as $sku) {
if ($sku->handler_class::entitleableClass() == \App\Domain::class) {
@@ -94,4 +104,14 @@
['qty']
);
}
+
+ /**
+ * The tenant for this package.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function tenant()
+ {
+ return $this->belongsTo('App\Tenant', 'tenant_id', 'id');
+ }
}
diff --git a/src/app/PackageSku.php b/src/app/PackageSku.php
--- a/src/app/PackageSku.php
+++ b/src/app/PackageSku.php
@@ -35,30 +35,50 @@
*/
public function cost()
{
- $costs = 0;
-
$units = $this->qty - $this->sku->units_free;
if ($units < 0) {
- \Log::debug(
- "Package {$this->package_id} is misconfigured for more free units than qty."
- );
-
$units = 0;
}
+ // FIXME: Why package_skus.cost value is not used anywhere?
+
$ppu = $this->sku->cost * ((100 - $this->package->discount_rate) / 100);
- $costs += $units * $ppu;
+ return $units * $ppu;
+ }
+
+ /**
+ * Under this package, what fee this SKU has?
+ *
+ * @return int The fee for this SKU under this package in cents.
+ */
+ public function fee()
+ {
+ $units = $this->qty - $this->sku->units_free;
+
+ if ($units < 0) {
+ $units = 0;
+ }
- return $costs;
+ return $this->sku->fee * $units;
}
+ /**
+ * The package for this relation.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
public function package()
{
return $this->belongsTo('App\Package');
}
+ /**
+ * The SKU for this relation.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
public function sku()
{
return $this->belongsTo('App\Sku');
diff --git a/src/app/Plan.php b/src/app/Plan.php
--- a/src/app/Plan.php
+++ b/src/app/Plan.php
@@ -13,7 +13,16 @@
* A "Family Plan" as such may exist of "2 or more Kolab packages",
* and apply a discount for the third and further Kolab packages.
*
+ * @property string $description
+ * @property int $discount_qty
+ * @property int $discount_rate
+ * @property string $id
+ * @property string $name
* @property \App\Package[] $packages
+ * @property datetime $promo_from
+ * @property datetime $promo_to
+ * @property ?int $tenant_id
+ * @property string $title
*/
class Plan extends Model
{
@@ -105,4 +114,14 @@
return false;
}
+
+ /**
+ * The tenant for this plan.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function tenant()
+ {
+ return $this->belongsTo('App\Tenant', 'tenant_id', 'id');
+ }
}
diff --git a/src/app/PlanPackage.php b/src/app/PlanPackage.php
--- a/src/app/PlanPackage.php
+++ b/src/app/PlanPackage.php
@@ -15,6 +15,7 @@
* @property int $qty_max
* @property int $qty_min
* @property \App\Package $package
+ * @property \App\Plan $plan
*/
class PlanPackage extends Pivot
{
@@ -54,8 +55,23 @@
return $costs;
}
+ /**
+ * The package in this relation.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
public function package()
{
return $this->belongsTo('App\Package');
}
+
+ /**
+ * The plan in this relation.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function plan()
+ {
+ return $this->belongsTo('App\Plan');
+ }
}
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -2,6 +2,7 @@
namespace App\Providers;
+use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -34,7 +35,9 @@
\App\Package::observe(\App\Observers\PackageObserver::class);
\App\PackageSku::observe(\App\Observers\PackageSkuObserver::class);
\App\Plan::observe(\App\Observers\PlanObserver::class);
+ \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class);
\App\SignupCode::observe(\App\Observers\SignupCodeObserver::class);
+ \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class);
\App\Sku::observe(\App\Observers\SkuObserver::class);
\App\Transaction::observe(\App\Observers\TransactionObserver::class);
\App\User::observe(\App\Observers\UserObserver::class);
@@ -57,5 +60,50 @@
$path = trim($path, '/\'"');
return "<?php echo secure_asset('themes/' . \$env['app.theme'] . '/' . '$path'); ?>";
});
+
+ // Query builder 'withEnvTenant' macro
+ Builder::macro('withEnvTenant', function (string $table = null) {
+ $tenant_id = \config('app.tenant_id');
+
+ if ($tenant_id) {
+ /** @var Builder $this */
+ return $this->where(($table ? "$table." : '') . 'tenant_id', $tenant_id);
+ }
+
+ /** @var Builder $this */
+ return $this->whereNull(($table ? "$table." : '') . 'tenant_id');
+ });
+
+ // Query builder 'withUserTenant' macro
+ Builder::macro('withUserTenant', function (string $table = null) {
+ $tenant_id = auth()->user()->tenant_id;
+
+ if ($tenant_id) {
+ /** @var Builder $this */
+ return $this->where(($table ? "$table." : '') . 'tenant_id', $tenant_id);
+ }
+
+ /** @var Builder $this */
+ return $this->whereNull(($table ? "$table." : '') . 'tenant_id');
+ });
+
+ // Query builder 'whereLike' mocro
+ Builder::macro('whereLike', function (string $column, string $search, int $mode = 0) {
+ $search = addcslashes($search, '%_');
+
+ switch ($mode) {
+ case 2:
+ $search .= '%';
+ break;
+ case 1:
+ $search = '%' . $search;
+ break;
+ default:
+ $search = '%' . $search . '%';
+ }
+
+ /** @var Builder $this */
+ return $this->where($column, 'like', $search);
+ });
}
}
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -68,7 +68,7 @@
'sequenceType' => 'first',
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
- 'redirectUrl' => Utils::serviceUrl('/wallet'),
+ 'redirectUrl' => self::redirectUrl(),
'locale' => 'en_US',
'method' => $payment['methodId']
];
@@ -198,7 +198,7 @@
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
'method' => $payment['methodId'],
- 'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments
+ 'redirectUrl' => self::redirectUrl() // required for non-recurring payments
];
// TODO: Additional payment parameters for better fraud protection:
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -70,8 +70,8 @@
$request = [
'customer' => $customer_id,
- 'cancel_url' => Utils::serviceUrl('/wallet'), // required
- 'success_url' => Utils::serviceUrl('/wallet'), // required
+ 'cancel_url' => self::redirectUrl(), // required
+ 'success_url' => self::redirectUrl(), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'mode' => 'setup',
@@ -188,8 +188,8 @@
$request = [
'customer' => $customer_id,
- 'cancel_url' => Utils::serviceUrl('/wallet'), // required
- 'success_url' => Utils::serviceUrl('/wallet'), // required
+ 'cancel_url' => self::redirectUrl(), // required
+ 'success_url' => self::redirectUrl(), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'line_items' => [
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -261,6 +261,8 @@
$refund['status'] = self::STATUS_PAID;
$refund['amount'] = -1 * $amount;
+ // FIXME: Refunds/chargebacks are out of the reseller comissioning for now
+
$this->storePayment($refund, $wallet->id);
}
@@ -385,4 +387,22 @@
return $methods;
}
+
+ /**
+ * Returns the full URL for the wallet page, used when returning from an external payment page.
+ * Depending on the request origin it will return a URL for the User or Reseller UI.
+ *
+ * @return string The redirect URL
+ */
+ public static function redirectUrl(): string
+ {
+ $url = \App\Utils::serviceUrl('/wallet');
+ $domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
+
+ if (strpos($domain, 'reseller') === 0) {
+ $url = preg_replace('|^(https?://)([^/]+)|', '\\1' . $domain, $url);
+ }
+
+ return $url;
+ }
}
diff --git a/src/app/SignupInvitation.php b/src/app/SignupInvitation.php
new file mode 100644
--- /dev/null
+++ b/src/app/SignupInvitation.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace App;
+
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a signup invitation.
+ *
+ * @property string $email
+ * @property string $id
+ * @property ?int $tenant_id
+ * @property ?\App\Tenant $tenant
+ * @property ?\App\User $user
+ */
+class SignupInvitation extends Model
+{
+ // just created
+ public const STATUS_NEW = 1 << 0;
+ // it's been sent successfully
+ public const STATUS_SENT = 1 << 1;
+ // sending failed
+ public const STATUS_FAILED = 1 << 2;
+ // the user signed up
+ public const STATUS_COMPLETED = 1 << 3;
+
+
+ /**
+ * Indicates if the IDs are auto-incrementing.
+ *
+ * @var bool
+ */
+ public $incrementing = false;
+
+ /**
+ * The "type" of the auto-incrementing ID.
+ *
+ * @var string
+ */
+ protected $keyType = 'string';
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var array
+ */
+ protected $fillable = ['email'];
+
+ /**
+ * Returns whether this invitation process completed (user signed up)
+ *
+ * @return bool
+ */
+ public function isCompleted(): bool
+ {
+ return ($this->status & self::STATUS_COMPLETED) > 0;
+ }
+
+ /**
+ * Returns whether this invitation sending failed.
+ *
+ * @return bool
+ */
+ public function isFailed(): bool
+ {
+ return ($this->status & self::STATUS_FAILED) > 0;
+ }
+
+ /**
+ * Returns whether this invitation is new.
+ *
+ * @return bool
+ */
+ public function isNew(): bool
+ {
+ return ($this->status & self::STATUS_NEW) > 0;
+ }
+
+ /**
+ * Returns whether this invitation has been sent.
+ *
+ * @return bool
+ */
+ public function isSent(): bool
+ {
+ return ($this->status & self::STATUS_SENT) > 0;
+ }
+
+ /**
+ * The tenant for this invitation.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function tenant()
+ {
+ return $this->belongsTo('App\Tenant', 'tenant_id', 'id');
+ }
+
+ /**
+ * The account to which the invitation was used for.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function user()
+ {
+ return $this->belongsTo('App\User', 'user_id', 'id');
+ }
+}
diff --git a/src/app/Sku.php b/src/app/Sku.php
--- a/src/app/Sku.php
+++ b/src/app/Sku.php
@@ -7,6 +7,18 @@
/**
* The eloquent definition of a Stock Keeping Unit (SKU).
+ *
+ * @property bool $active
+ * @property int $cost
+ * @property string $description
+ * @property int $fee The fee that the tenant pays to us
+ * @property string $handler_class
+ * @property string $id
+ * @property string $name
+ * @property string $period
+ * @property ?int $tenant_id
+ * @property string $title
+ * @property int $units_free
*/
class Sku extends Model
{
@@ -23,6 +35,7 @@
'active',
'cost',
'description',
+ 'fee',
'handler_class',
'name',
// persist for annual domain registration
@@ -59,4 +72,14 @@
'package_skus'
)->using('App\PackageSku')->withPivot(['cost', 'qty']);
}
+
+ /**
+ * The tenant for this SKU.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function tenant()
+ {
+ return $this->belongsTo('App\Tenant', 'tenant_id', 'id');
+ }
}
diff --git a/src/app/Tenant.php b/src/app/Tenant.php
new file mode 100644
--- /dev/null
+++ b/src/app/Tenant.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a Tenant.
+ *
+ * @property int $id
+ * @property string $title
+ */
+class Tenant extends Model
+{
+ protected $fillable = [
+ 'title',
+ ];
+
+ protected $keyType = 'bigint';
+
+ /**
+ * Discounts assigned to this tenant.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function discounts()
+ {
+ return $this->hasMany('App\Discount');
+ }
+
+ /**
+ * SignupInvitations assigned to this tenant.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function signupInvitations()
+ {
+ return $this->hasMany('App\SignupInvitation');
+ }
+
+ /*
+ * Returns the wallet of the tanant (reseller's wallet).
+ *
+ * @return ?\App\Wallet A wallet object
+ */
+ public function wallet(): ?Wallet
+ {
+ $user = \App\User::where('role', 'reseller')->where('tenant_id', $this->id)->first();
+
+ return $user ? $user->wallets->first() : null;
+ }
+}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -20,6 +20,7 @@
* @property int $id
* @property string $password
* @property int $status
+ * @property int $tenant_id
*/
class User extends Authenticatable implements JWTSubject
{
@@ -56,7 +57,7 @@
'email',
'password',
'password_ldap',
- 'status'
+ 'status',
];
/**
@@ -125,6 +126,7 @@
'wallet_id' => $wallet_id,
'sku_id' => $sku->id,
'cost' => $sku->pivot->cost(),
+ 'fee' => $sku->pivot->fee(),
'entitleable_id' => $user->id,
'entitleable_type' => User::class
]
@@ -178,6 +180,7 @@
'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
]);
@@ -192,7 +195,7 @@
/**
* Check if current user can delete another object.
*
- * @param \App\User|\App\Domain $object A user|domain object
+ * @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
@@ -213,13 +216,13 @@
/**
* Check if current user can read data of another object.
*
- * @param \App\User|\App\Domain|\App\Wallet $object A user|domain|wallet 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") {
+ if ($this->role == 'admin') {
return true;
}
@@ -227,6 +230,18 @@
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);
}
@@ -237,26 +252,38 @@
$wallet = $object->wallet();
- return $this->wallets->contains($wallet) || $this->accounts->contains($wallet);
+ return $wallet && ($this->wallets->contains($wallet) || $this->accounts->contains($wallet));
}
/**
* Check if current user can update data of another object.
*
- * @param \App\User|\App\Domain $object A user|domain object
+ * @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canUpdate($object): bool
{
- if (!method_exists($object, 'wallet')) {
- return false;
+ if ($object instanceof User && $this->id == $object->id) {
+ return true;
}
- if ($object instanceof User && $this->id == $object->id) {
+ 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);
}
@@ -276,12 +303,19 @@
/**
* List the domains to which this user is entitled.
+ * Note: Active public domains are also returned (for the user tenant).
*
- * @return Domain[]
+ * @return Domain[] List of Domain objects
*/
- public function domains()
+ public function domains(): array
{
- $domains = Domain::whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC))
+ if ($this->tenant_id) {
+ $domains = Domain::where('tenant_id', $this->tenant_id);
+ } else {
+ $domains = Domain::withEnvTenant();
+ }
+
+ $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC))
->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE))
->get()
->all();
@@ -580,6 +614,16 @@
}
/**
+ * 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
diff --git a/src/app/Utils.php b/src/app/Utils.php
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -363,7 +363,6 @@
$countries = include resource_path('countries.php');
$req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
$sys_domain = \config('app.domain');
- $path = request()->path();
$opts = [
'app.name',
'app.url',
@@ -382,6 +381,8 @@
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');
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -76,6 +76,7 @@
return 0;
}
+ $profit = 0;
$charges = 0;
$discount = $this->getDiscountRate();
@@ -101,8 +102,10 @@
$diff = $entitlement->updated_at->diffInMonths(Carbon::now());
$cost = (int) ($entitlement->cost * $discount * $diff);
+ $fee = (int) ($entitlement->fee * $diff);
$charges += $cost;
+ $profit += $cost - $fee;
// if we're in dry-run, you know...
if (!$apply) {
@@ -126,7 +129,17 @@
}
if ($apply) {
- $this->debit($charges, $entitlementTransactions);
+ $this->debit($charges, '', $entitlementTransactions);
+
+ // Credit/debit the reseller
+ if ($profit != 0 && $this->owner->tenant) {
+ // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s)
+ if ($wallet = $this->owner->tenant->wallet()) {
+ $desc = "Charged user {$this->owner->email}";
+ $method = $profit > 0 ? 'credit' : 'debit';
+ $wallet->{$method}(abs($profit), $desc);
+ }
+ }
}
DB::commit();
@@ -234,12 +247,13 @@
/**
* Deduct an amount of pecunia from this wallet's balance.
*
- * @param int $amount The amount of pecunia to deduct (in cents).
- * @param array $eTIDs List of transaction IDs for the individual entitlements that make up
- * this debit record, if any.
+ * @param int $amount The amount of pecunia to deduct (in cents).
+ * @param string $description The transaction description
+ * @param array $eTIDs List of transaction IDs for the individual entitlements
+ * that make up this debit record, if any.
* @return Wallet Self
*/
- public function debit(int $amount, array $eTIDs = []): Wallet
+ public function debit(int $amount, string $description = '', array $eTIDs = []): Wallet
{
if ($amount == 0) {
return $this;
@@ -254,11 +268,14 @@
'object_id' => $this->id,
'object_type' => \App\Wallet::class,
'type' => \App\Transaction::WALLET_DEBIT,
- 'amount' => $amount * -1
+ 'amount' => $amount * -1,
+ 'description' => $description
]
);
- \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]);
+ if (!empty($eTIDs)) {
+ \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]);
+ }
return $this;
}
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -65,6 +65,8 @@
'theme' => env('APP_THEME', 'default'),
+ 'tenant_id' => env('APP_TENANT_ID', null),
+
/*
|--------------------------------------------------------------------------
| Application Domain
diff --git a/src/config/database.php b/src/config/database.php
--- a/src/config/database.php
+++ b/src/config/database.php
@@ -62,6 +62,7 @@
'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'),
diff --git a/src/database/migrations/2019_12_10_100355_create_package_skus_table.php b/src/database/migrations/2019_12_10_100355_create_package_skus_table.php
--- a/src/database/migrations/2019_12_10_100355_create_package_skus_table.php
+++ b/src/database/migrations/2019_12_10_100355_create_package_skus_table.php
@@ -21,7 +21,6 @@
$table->string('package_id', 36);
$table->string('sku_id', 36);
$table->integer('qty')->default(1);
-
$table->integer('cost')->default(0)->nullable();
$table->foreign('package_id')->references('id')->on('packages')
diff --git a/src/database/migrations/2020_05_05_095212_create_tenants_table.php b/src/database/migrations/2020_05_05_095212_create_tenants_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2020_05_05_095212_create_tenants_table.php
@@ -0,0 +1,84 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class CreateTenantsTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'tenants',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('title', 32);
+ $table->timestamps();
+ }
+ );
+
+ \App\Tenant::create(['title' => 'Kolab Now']);
+
+ foreach (['users', 'discounts', 'domains', 'plans', 'packages', 'skus'] as $table_name) {
+ Schema::table(
+ $table_name,
+ function (Blueprint $table) {
+ $table->bigInteger('tenant_id')->unsigned()->nullable();
+ $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null');
+ }
+ );
+
+ if ($tenant_id = \config('app.tenant_id')) {
+ DB::statement("UPDATE `{$table_name}` SET `tenant_id` = {$tenant_id}");
+ }
+ }
+
+ // Add fee column
+ foreach (['entitlements', 'skus'] as $table) {
+ Schema::table(
+ $table,
+ function (Blueprint $table) {
+ $table->integer('fee')->nullable();
+ }
+ );
+ }
+
+ // FIXME: Should we also have package_skus.fee ?
+ // We have package_skus.cost, but I think it is not used anywhere.
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ foreach (['users', 'discounts', 'domains', 'plans', 'packages', 'skus'] as $table_name) {
+ Schema::table(
+ $table_name,
+ function (Blueprint $table) {
+ $table->dropForeign(['tenant_id']);
+ $table->dropColumn('tenant_id');
+ }
+ );
+ }
+
+ foreach (['entitlements', 'skus'] as $table) {
+ Schema::table(
+ $table,
+ function (Blueprint $table) {
+ $table->dropColumn('fee');
+ }
+ );
+ }
+
+ Schema::dropIfExists('tenants');
+ }
+}
diff --git a/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php b/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php
--- a/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php
+++ b/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php
@@ -15,14 +15,20 @@
public function up()
{
$beta_sku = \App\Sku::where('title', 'beta')->first();
- $beta_sku->name = 'Private Beta (invitation only)';
- $beta_sku->description = 'Access to the private beta program subscriptions';
- $beta_sku->save();
+
+ if ($beta_sku) {
+ $beta_sku->name = 'Private Beta (invitation only)';
+ $beta_sku->description = 'Access to the private beta program subscriptions';
+ $beta_sku->save();
+ }
$meet_sku = \App\Sku::where('title', 'meet')->first();
- $meet_sku->name = 'Voice & Video Conferencing (public beta)';
- $meet_sku->handler_class = 'App\Handlers\Meet';
- $meet_sku->save();
+
+ if ($meet_sku) {
+ $meet_sku->name = 'Voice & Video Conferencing (public beta)';
+ $meet_sku->handler_class = 'App\Handlers\Meet';
+ $meet_sku->save();
+ }
}
/**
diff --git a/src/database/migrations/2021_03_26_080000_create_signup_invitations_table.php b/src/database/migrations/2021_03_26_080000_create_signup_invitations_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2021_03_26_080000_create_signup_invitations_table.php
@@ -0,0 +1,49 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class CreateSignupInvitationsTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'signup_invitations',
+ function (Blueprint $table) {
+ $table->string('id', 36);
+ $table->string('email');
+ $table->smallInteger('status');
+ $table->bigInteger('user_id')->nullable();
+ $table->bigInteger('tenant_id')->unsigned()->nullable();
+ $table->timestamps();
+
+ $table->primary('id');
+
+ $table->index('email');
+ $table->index('created_at');
+
+ $table->foreign('tenant_id')->references('id')->on('tenants')
+ ->onUpdate('cascade')->onDelete('set null');
+ $table->foreign('user_id')->references('id')->on('users')
+ ->onUpdate('cascade')->onDelete('set null');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('signup_invitations');
+ }
+}
diff --git a/src/database/migrations/2021_05_12_150000_groups_add_tenant_id.php b/src/database/migrations/2021_05_12_150000_groups_add_tenant_id.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2021_05_12_150000_groups_add_tenant_id.php
@@ -0,0 +1,46 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class GroupsAddTenantId extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'groups',
+ function (Blueprint $table) {
+ $table->bigInteger('tenant_id')->unsigned()->nullable();
+ $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null');
+ }
+ );
+
+ if ($tenant_id = \config('app.tenant_id')) {
+ DB::statement("UPDATE `groups` SET `tenant_id` = {$tenant_id}");
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'groups',
+ function (Blueprint $table) {
+ $table->dropForeign(['tenant_id']);
+ $table->dropColumn('tenant_id');
+ }
+ );
+ }
+}
diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php
--- a/src/database/seeds/DatabaseSeeder.php
+++ b/src/database/seeds/DatabaseSeeder.php
@@ -14,6 +14,7 @@
{
// Define seeders order
$seeders = [
+ 'TenantSeeder',
'DiscountSeeder',
'DomainSeeder',
'SkuSeeder',
diff --git a/src/database/seeds/local/DomainSeeder.php b/src/database/seeds/local/DomainSeeder.php
--- a/src/database/seeds/local/DomainSeeder.php
+++ b/src/database/seeds/local/DomainSeeder.php
@@ -63,5 +63,19 @@
]
);
}
+
+ // example tenant domain, note that 'tenant_id' is not a fillable.
+ $domain = Domain::create(
+ [
+ 'namespace' => 'example-tenant.dev-local',
+ 'status' => Domain::STATUS_CONFIRMED + Domain::STATUS_ACTIVE,
+ 'type' => Domain::TYPE_PUBLIC
+ ]
+ );
+
+ $tenant = \App\Tenant::where('title', 'Sample Tenant')->first();
+
+ $domain->tenant_id = $tenant->id;
+ $domain->save();
}
}
diff --git a/src/database/seeds/local/TenantSeeder.php b/src/database/seeds/local/TenantSeeder.php
new file mode 100644
--- /dev/null
+++ b/src/database/seeds/local/TenantSeeder.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Database\Seeds\Local;
+
+use App\Tenant;
+use Illuminate\Database\Seeder;
+
+class TenantSeeder extends Seeder
+{
+ /**
+ * Run the database seeds.
+ *
+ * @return void
+ */
+ public function run()
+ {
+ if (!Tenant::find(1)) {
+ Tenant::create([
+ 'title' => 'Kolab Now'
+ ]);
+ }
+
+ if (!Tenant::find(2)) {
+ Tenant::create([
+ 'title' => 'Sample Tenant'
+ ]);
+ }
+ }
+}
diff --git a/src/database/seeds/local/UserSeeder.php b/src/database/seeds/local/UserSeeder.php
--- a/src/database/seeds/local/UserSeeder.php
+++ b/src/database/seeds/local/UserSeeder.php
@@ -145,5 +145,30 @@
$jeroen->role = 'admin';
$jeroen->save();
+
+ $tenant1 = \App\Tenant::where('title', 'Kolab Now')->first();
+ $tenant2 = \App\Tenant::where('title', 'Sample Tenant')->first();
+
+ $reseller1 = User::create(
+ [
+ 'email' => 'reseller@kolabnow.com',
+ 'password' => 'reseller',
+ ]
+ );
+
+ $reseller1->tenant_id = $tenant1->id;
+ $reseller1->role = 'reseller';
+ $reseller1->save();
+
+ $reseller2 = User::create(
+ [
+ 'email' => 'reseller@reseller.com',
+ 'password' => 'reseller',
+ ]
+ );
+
+ $reseller2->tenant_id = $tenant2->id;
+ $reseller2->role = 'reseller';
+ $reseller2->save();
}
}
diff --git a/src/phpstan.neon b/src/phpstan.neon
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -4,8 +4,12 @@
ignoreErrors:
- '#Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable#'
- '#Access to an undefined property [a-zA-Z\\]+::\$pivot#'
+ - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenant\(\)#'
+ - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withUserTenant\(\)#'
- '#Call to an undefined method Tests\\Browser::#'
level: 4
+ parallel:
+ processTimeout: 300.0
paths:
- app/
- tests/
diff --git a/src/resources/js/admin.js b/src/resources/js/admin/app.js
rename from src/resources/js/admin.js
rename to src/resources/js/admin/app.js
--- a/src/resources/js/admin.js
+++ b/src/resources/js/admin/app.js
@@ -2,9 +2,9 @@
* Application code for the admin UI
*/
-import routes from './routes-admin.js'
+import routes from './routes.js'
window.routes = routes
window.isAdmin = true
-require('./app')
+require('../app')
diff --git a/src/resources/js/routes-admin.js b/src/resources/js/admin/routes.js
rename from src/resources/js/routes-admin.js
rename to src/resources/js/admin/routes.js
--- a/src/resources/js/routes-admin.js
+++ b/src/resources/js/admin/routes.js
@@ -1,11 +1,11 @@
-import DashboardComponent from '../vue/Admin/Dashboard'
-import DistlistComponent from '../vue/Admin/Distlist'
-import DomainComponent from '../vue/Admin/Domain'
-import LoginComponent from '../vue/Login'
-import LogoutComponent from '../vue/Logout'
-import PageComponent from '../vue/Page'
-import StatsComponent from '../vue/Admin/Stats'
-import UserComponent from '../vue/Admin/User'
+import DashboardComponent from '../../vue/Admin/Dashboard'
+import DistlistComponent from '../../vue/Admin/Distlist'
+import DomainComponent from '../../vue/Admin/Domain'
+import LoginComponent from '../../vue/Login'
+import LogoutComponent from '../../vue/Logout'
+import PageComponent from '../../vue/Page'
+import StatsComponent from '../../vue/Admin/Stats'
+import UserComponent from '../../vue/Admin/User'
const routes = [
{
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -79,7 +79,7 @@
router: window.router,
data() {
return {
- isAdmin: window.isAdmin,
+ isUser: !window.isAdmin && !window.isReseller,
appName: window.config['app.name'],
appUrl: window.config['app.url'],
themeDir: '/themes/' + window.config['app.theme']
@@ -462,7 +462,7 @@
}
if (input.length) {
- // Create an error message\
+ // Create an error message
// API responses can use a string, array or object
let msg_text = ''
if ($.type(msg) !== 'string') {
diff --git a/src/resources/js/reseller/app.js b/src/resources/js/reseller/app.js
new file mode 100644
--- /dev/null
+++ b/src/resources/js/reseller/app.js
@@ -0,0 +1,10 @@
+/**
+ * Application code for the reseller UI
+ */
+
+import routes from './routes.js'
+
+window.routes = routes
+window.isReseller = true
+
+require('../app')
diff --git a/src/resources/js/routes-admin.js b/src/resources/js/reseller/routes.js
rename from src/resources/js/routes-admin.js
rename to src/resources/js/reseller/routes.js
--- a/src/resources/js/routes-admin.js
+++ b/src/resources/js/reseller/routes.js
@@ -1,11 +1,13 @@
-import DashboardComponent from '../vue/Admin/Dashboard'
-import DistlistComponent from '../vue/Admin/Distlist'
-import DomainComponent from '../vue/Admin/Domain'
-import LoginComponent from '../vue/Login'
-import LogoutComponent from '../vue/Logout'
-import PageComponent from '../vue/Page'
-import StatsComponent from '../vue/Admin/Stats'
-import UserComponent from '../vue/Admin/User'
+import DashboardComponent from '../../vue/Reseller/Dashboard'
+import DistlistComponent from '../../vue/Admin/Distlist'
+import DomainComponent from '../../vue/Admin/Domain'
+import InvitationsComponent from '../../vue/Reseller/Invitations'
+import LoginComponent from '../../vue/Login'
+import LogoutComponent from '../../vue/Logout'
+import PageComponent from '../../vue/Page'
+import StatsComponent from '../../vue/Reseller/Stats'
+import UserComponent from '../../vue/Admin/User'
+import WalletComponent from '../../vue/Wallet'
const routes = [
{
@@ -41,6 +43,12 @@
component: LogoutComponent
},
{
+ path: '/invitations',
+ name: 'invitations',
+ component: InvitationsComponent,
+ meta: { requiresAuth: true }
+ },
+ {
path: '/stats',
name: 'stats',
component: StatsComponent,
@@ -53,6 +61,12 @@
meta: { requiresAuth: true }
},
{
+ path: '/wallet',
+ name: 'wallet',
+ component: WalletComponent,
+ meta: { requiresAuth: true }
+ },
+ {
name: '404',
path: '*',
component: PageComponent
diff --git a/src/resources/js/user.js b/src/resources/js/user/app.js
rename from src/resources/js/user.js
rename to src/resources/js/user/app.js
--- a/src/resources/js/user.js
+++ b/src/resources/js/user/app.js
@@ -2,9 +2,10 @@
* Application code for the user UI
*/
-import routes from './routes-user.js'
+import routes from './routes.js'
window.routes = routes
window.isAdmin = false
+window.isReseller = false
-require('./app')
+require('../app')
diff --git a/src/resources/js/routes-user.js b/src/resources/js/user/routes.js
rename from src/resources/js/routes-user.js
rename to src/resources/js/user/routes.js
--- a/src/resources/js/routes-user.js
+++ b/src/resources/js/user/routes.js
@@ -1,24 +1,24 @@
-import DashboardComponent from '../vue/Dashboard'
-import DistlistInfoComponent from '../vue/Distlist/Info'
-import DistlistListComponent from '../vue/Distlist/List'
-import DomainInfoComponent from '../vue/Domain/Info'
-import DomainListComponent from '../vue/Domain/List'
-import LoginComponent from '../vue/Login'
-import LogoutComponent from '../vue/Logout'
-import MeetComponent from '../vue/Rooms'
-import PageComponent from '../vue/Page'
-import PasswordResetComponent from '../vue/PasswordReset'
-import SignupComponent from '../vue/Signup'
-import UserInfoComponent from '../vue/User/Info'
-import UserListComponent from '../vue/User/List'
-import UserProfileComponent from '../vue/User/Profile'
-import UserProfileDeleteComponent from '../vue/User/ProfileDelete'
-import WalletComponent from '../vue/Wallet'
+import DashboardComponent from '../../vue/Dashboard'
+import DistlistInfoComponent from '../../vue/Distlist/Info'
+import DistlistListComponent from '../../vue/Distlist/List'
+import DomainInfoComponent from '../../vue/Domain/Info'
+import DomainListComponent from '../../vue/Domain/List'
+import LoginComponent from '../../vue/Login'
+import LogoutComponent from '../../vue/Logout'
+import MeetComponent from '../../vue/Rooms'
+import PageComponent from '../../vue/Page'
+import PasswordResetComponent from '../../vue/PasswordReset'
+import SignupComponent from '../../vue/Signup'
+import UserInfoComponent from '../../vue/User/Info'
+import UserListComponent from '../../vue/User/List'
+import UserProfileComponent from '../../vue/User/Profile'
+import UserProfileDeleteComponent from '../../vue/User/ProfileDelete'
+import WalletComponent from '../../vue/Wallet'
// Here's a list of lazy-loaded components
// Note: you can pack multiple components into the same chunk, webpackChunkName
// is also used to get a sensible file name instead of numbers
-const RoomComponent = () => import(/* webpackChunkName: "room" */ '../vue/Meet/Room.vue')
+const RoomComponent = () => import(/* webpackChunkName: "room" */ '../../vue/Meet/Room.vue')
const routes = [
{
@@ -91,6 +91,11 @@
meta: { requiresAuth: true }
},
{
+ path: '/signup/invite/:param',
+ name: 'signup-invite',
+ component: SignupComponent
+ },
+ {
path: '/signup/:param?',
alias: '/signup/voucher/:param',
name: 'signup',
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -57,6 +57,12 @@
'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.',
diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php
--- a/src/resources/lang/en/mail.php
+++ b/src/resources/lang/en/mail.php
@@ -75,6 +75,11 @@
'signupcode-body1' => "This is your verification code for the :site registration process:",
'signupcode-body2' => "You can also click the link below to continue the registration process:",
+ 'signupinvitation-subject' => ":site Invitation",
+ 'signupinvitation-header' => "Hi,",
+ 'signupinvitation-body1' => "You have been invited to join :site. Click the link below to sign up.",
+ 'signupinvitation-body2' => "",
+
'suspendeddebtor-subject' => ":site Account Suspended",
'suspendeddebtor-body' => "You have been behind on paying for your :site account "
."for over :days days. Your account has been suspended.",
diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -123,7 +123,10 @@
}
table {
- td.buttons,
+ th {
+ white-space: nowrap;
+ }
+
td.email,
td.price,
td.datetime,
@@ -132,6 +135,7 @@
white-space: nowrap;
}
+ td.buttons,
th.price,
td.price {
width: 1%;
@@ -287,6 +291,13 @@
opacity: 0.6;
}
+ // Some icons are too big, scale them down
+ &.link-invitations {
+ svg {
+ transform: scale(0.9);
+ }
+ }
+
.badge {
position: absolute;
top: 0.5rem;
diff --git a/src/resources/themes/default/theme.json b/src/resources/themes/default/theme.json
--- a/src/resources/themes/default/theme.json
+++ b/src/resources/themes/default/theme.json
@@ -3,23 +3,27 @@
{
"label": "explore",
"location": "https://kolabnow.com/",
- "admin": true
+ "admin": true,
+ "reseller": true
},
{
"label": "blog",
"location": "https://blogs.kolabnow.com/",
- "admin": true
+ "admin": true,
+ "reseller": true
},
{
"label": "support",
"location": "/support",
"page": "support",
- "admin": true
+ "admin": true,
+ "reseller": true
},
{
"label": "tos",
"location": "https://kolabnow.com/tos",
- "footer": true
+ "footer": true,
+ "reseller": true
}
],
"faq": {
diff --git a/src/resources/views/emails/html/signup_invitation.blade.php b/src/resources/views/emails/html/signup_invitation.blade.php
new file mode 100644
--- /dev/null
+++ b/src/resources/views/emails/html/signup_invitation.blade.php
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <p>{{ __('mail.signupinvitation-header') }}</p>
+
+ <p>{{ __('mail.signupinvitation-body1', ['site' => $site]) }}</p>
+
+ <p><a href="{!! $href !!}">{!! $href !!}</a></p>
+
+ <p>{{ __('mail.signupinvitation-body2') }}</p>
+
+ <p>{{ __('mail.footer1') }}</p>
+ <p>{{ __('mail.footer2', ['site' => $site]) }}</p>
+ </body>
+</html>
diff --git a/src/resources/views/emails/plain/signup_invitation.blade.php b/src/resources/views/emails/plain/signup_invitation.blade.php
new file mode 100644
--- /dev/null
+++ b/src/resources/views/emails/plain/signup_invitation.blade.php
@@ -0,0 +1,11 @@
+{!! __('mail.signupinvitation-header') !!}
+
+{!! __('mail.signupinvitation-body1', ['site' => $site]) !!}
+
+{!! $href !!}
+
+{!! __('mail.signupinvitation-body2') !!}
+
+--
+{!! __('mail.footer1') !!}
+{!! __('mail.footer2', ['site' => $site]) !!}
diff --git a/src/resources/vue/Admin/Dashboard.vue b/src/resources/vue/Admin/Dashboard.vue
--- a/src/resources/vue/Admin/Dashboard.vue
+++ b/src/resources/vue/Admin/Dashboard.vue
@@ -1,42 +1,6 @@
<template>
<div class="container" dusk="dashboard-component">
- <div id="search-box" class="card">
- <div class="card-body">
- <form @submit.prevent="searchUser" class="row justify-content-center">
- <div class="input-group col-sm-8">
- <input class="form-control" type="text" placeholder="User ID, email or domain" v-model="search">
- <div class="input-group-append">
- <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> Search</button>
- </div>
- </div>
- </form>
- <table v-if="users.length" class="table table-sm table-hover mt-4">
- <thead class="thead-light">
- <tr>
- <th scope="col">Primary Email</th>
- <th scope="col">ID</th>
- <th scope="col" class="d-none d-md-table-cell">Created</th>
- <th scope="col" class="d-none d-md-table-cell">Deleted</th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="user in users" :id="'user' + user.id" :key="user.id" :class="user.isDeleted ? 'text-secondary' : ''">
- <td class="text-nowrap">
- <svg-icon icon="user" :class="$root.userStatusClass(user)" :title="$root.userStatusText(user)"></svg-icon>
- <router-link v-if="!user.isDeleted" :to="{ path: 'user/' + user.id }">{{ user.email }}</router-link>
- <span v-if="user.isDeleted">{{ user.email }}</span>
- </td>
- <td>
- <router-link v-if="!user.isDeleted" :to="{ path: 'user/' + user.id }">{{ user.id }}</router-link>
- <span v-if="user.isDeleted">{{ user.id }}</span>
- </td>
- <td class="d-none d-md-table-cell">{{ toDate(user.created_at) }}</td>
- <td class="d-none d-md-table-cell">{{ toDate(user.deleted_at) }}</td>
- </tr>
- </tbody>
- </table>
- </div>
- </div>
+ <user-search></user-search>
<div id="dashboard-nav" class="mt-3">
<router-link class="card link-stats" :to="{ name: 'stats' }">
<svg-icon icon="chart-line"></svg-icon><span class="name">Stats</span>
@@ -46,44 +10,15 @@
</template>
<script>
+ import UserSearch from '../Widgets/UserSearch'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChartLine } from '@fortawesome/free-solid-svg-icons'
+
library.add(faChartLine)
export default {
- data() {
- return {
- search: '',
- users: []
- }
- },
- mounted() {
- $('#search-box input').focus()
- },
- methods: {
- searchUser() {
- this.users = []
-
- axios.get('/api/v4/users', { params: { search: this.search } })
- .then(response => {
- if (response.data.count == 1 && !response.data.list[0].isDeleted) {
- this.$router.push({ name: 'user', params: { user: response.data.list[0].id } })
- return
- }
-
- if (response.data.message) {
- this.$toast.info(response.data.message)
- }
-
- this.users = response.data.list
- })
- .catch(this.$root.errorHandler)
- },
- toDate(datetime) {
- if (datetime) {
- return datetime.split(' ')[0]
- }
- }
+ components: {
+ UserSearch
}
}
</script>
diff --git a/src/resources/vue/Admin/Stats.vue b/src/resources/vue/Admin/Stats.vue
--- a/src/resources/vue/Admin/Stats.vue
+++ b/src/resources/vue/Admin/Stats.vue
@@ -9,11 +9,12 @@
export default {
data() {
return {
- charts: {}
+ charts: {},
+ chartTypes: ['users', 'users-all', 'income', 'discounts']
}
},
mounted() {
- ['users', 'users-all', 'income', 'discounts'].forEach(chart => this.loadChart(chart))
+ this.chartTypes.forEach(chart => this.loadChart(chart))
},
methods: {
drawChart(name, data) {
diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue
--- a/src/resources/vue/Login.vue
+++ b/src/resources/vue/Login.vue
@@ -23,7 +23,7 @@
<input type="password" id="inputPassword" class="form-control" :placeholder="$t('form.password')" required v-model="password">
</div>
</div>
- <div class="form-group pt-3" v-if="!$root.isAdmin">
+ <div class="form-group pt-3" v-if="$root.isUser">
<label for="secondfactor" class="sr-only">{{ $t('login.2fa') }}</label>
<div class="input-group">
<span class="input-group-prepend">
@@ -43,8 +43,8 @@
</div>
</div>
<div id="logon-form-footer" class="mt-1">
- <router-link v-if="!$root.isAdmin && $root.hasRoute('password-reset')" :to="{ name: 'password-reset' }" id="forgot-password">{{ $t('login.forgot_password') }}</router-link>
- <a v-if="webmailURL && !$root.isAdmin" :href="webmailURL" id="webmail">{{ $t('login.webmail') }}</a>
+ <router-link v-if="$root.isUser && $root.hasRoute('password-reset')" :to="{ name: 'password-reset' }" id="forgot-password">{{ $t('login.forgot_password') }}</router-link>
+ <a v-if="webmailURL && $root.isUser" :href="webmailURL" id="webmail">{{ $t('login.webmail') }}</a>
</div>
</div>
</template>
diff --git a/src/resources/vue/Reseller/Dashboard.vue b/src/resources/vue/Reseller/Dashboard.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Reseller/Dashboard.vue
@@ -0,0 +1,51 @@
+<template>
+ <div class="container" dusk="dashboard-component">
+ <user-search></user-search>
+ <div id="dashboard-nav" class="mt-3">
+ <router-link v-if="status.enableWallets" class="card link-wallet" :to="{ name: 'wallet' }">
+ <svg-icon icon="wallet"></svg-icon><span class="name">Wallet</span>
+ <span :class="'badge badge-' + (balance < 0 ? 'danger' : 'success')">{{ $root.price(balance) }}</span>
+ </router-link>
+ <router-link class="card link-invitations" :to="{ name: 'invitations' }">
+ <svg-icon icon="envelope-open-text"></svg-icon><span class="name">Invitations</span>
+ </router-link>
+ <router-link class="card link-stats" :to="{ name: 'stats' }">
+ <svg-icon icon="chart-line"></svg-icon><span class="name">Stats</span>
+ </router-link>
+ </div>
+ </div>
+</template>
+
+<script>
+ import UserSearch from '../Widgets/UserSearch'
+ import { library } from '@fortawesome/fontawesome-svg-core'
+ import { faChartLine, faEnvelopeOpenText, faWallet } from '@fortawesome/free-solid-svg-icons'
+
+ library.add(faChartLine, faEnvelopeOpenText, faWallet)
+
+ export default {
+ components: {
+ UserSearch
+ },
+ data() {
+ return {
+ status: {},
+ balance: 0
+ }
+ },
+ mounted() {
+ const authInfo = this.$store.state.authInfo
+ this.status = authInfo.statusInfo
+ this.getBalance(authInfo)
+ },
+ methods: {
+ getBalance(authInfo) {
+ this.balance = 0;
+ // TODO: currencies, multi-wallets, accounts
+ authInfo.wallets.forEach(wallet => {
+ this.balance += wallet.balance
+ })
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Reseller/Invitations.vue
@@ -0,0 +1,283 @@
+<template>
+ <div class="container">
+ <div class="card" id="invitations">
+ <div class="card-body">
+ <div class="card-title">
+ Signup Invitations
+ </div>
+ <div class="card-text">
+ <div class="mb-2 d-flex">
+ <form @submit.prevent="searchInvitations" id="search-form" class="input-group" style="flex:1">
+ <input class="form-control" type="text" placeholder="Email address or domain" v-model="search">
+ <div class="input-group-append">
+ <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> Search</button>
+ </div>
+ </form>
+ <div>
+ <button class="btn btn-success create-invite ml-1" @click="inviteUserDialog">
+ <svg-icon icon="envelope-open-text"></svg-icon> Create invite(s)
+ </button>
+ </div>
+ </div>
+
+ <table id="invitations-list" class="table table-sm table-hover">
+ <thead class="thead-light">
+ <tr>
+ <th scope="col">External Email</th>
+ <th scope="col">Created</th>
+ <th scope="col"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="inv in invitations" :id="'i' + inv.id" :key="inv.id">
+ <td class="email">
+ <svg-icon icon="envelope-open-text" :class="statusClass(inv)" :title="statusText(inv)"></svg-icon>
+ <span>{{ inv.email }}</span>
+ </td>
+ <td class="datetime">
+ {{ inv.created }}
+ </td>
+ <td class="buttons">
+ <button class="btn text-danger button-delete p-0 ml-1" @click="deleteInvite(inv.id)">
+ <svg-icon icon="trash-alt"></svg-icon>
+ <span class="btn-label">Delete</span>
+ </button>
+ <button class="btn button-resend p-0 ml-1" :disabled="inv.isNew || inv.isCompleted" @click="resendInvite(inv.id)">
+ <svg-icon icon="redo"></svg-icon>
+ <span class="btn-label">Resend</span>
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="3">There are no invitations in the database.</td>
+ </tr>
+ </tfoot>
+ </table>
+ <div class="text-center p-3" id="more-loader" v-if="hasMore">
+ <button class="btn btn-secondary" @click="loadInvitations(true)">Load more</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div id="invite-create" class="modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">Invite for a signup</h5>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <form>
+ <p>Enter an email address of the person you want to invite.</p>
+ <div>
+ <input id="email" type="text" class="form-control" name="email">
+ </div>
+ <div class="form-separator"><hr><span>or</span></div>
+ <p>
+ To send multiple invitations at once, provide a CSV (comma separated) file,
+ or alternatively a plain-text file, containing one email address per line.
+ </p>
+ <div class="custom-file">
+ <input id="file" type="file" class="custom-file-input" name="csv" @change="fileChange">
+ <label class="custom-file-label" for="file">Choose file...</label>
+ </div>
+ </form>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-primary modal-action" @click="inviteUser()">
+ <svg-icon icon="paper-plane"></svg-icon> Send invite(s)
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import { library } from '@fortawesome/fontawesome-svg-core'
+ import { faEnvelopeOpenText, faPaperPlane, faRedo } from '@fortawesome/free-solid-svg-icons'
+
+ library.add(faEnvelopeOpenText, faPaperPlane, faRedo)
+
+ export default {
+ data() {
+ return {
+ invitations: [],
+ hasMore: false,
+ page: 1,
+ search: ''
+ }
+ },
+ mounted() {
+ this.$root.startLoading()
+ this.loadInvitations(null, () => this.$root.stopLoading())
+ },
+ methods: {
+ deleteInvite(id) {
+ axios.delete('/api/v4/invitations/' + id)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+
+ // Remove the invitation record from the list
+ const index = this.invitations.findIndex(item => item.id == id)
+ this.invitations.splice(index, 1)
+ }
+ })
+ },
+ fileChange(e) {
+ let label = 'Choose file...'
+ let files = e.target.files
+
+ if (files.length) {
+ label = files[0].name
+ if (files.length > 1) {
+ label += ', ...'
+ }
+ }
+
+ $(e.target).next().text(label)
+ },
+ inviteUser() {
+ let dialog = $('#invite-create')
+ let post = new FormData()
+ let params = { headers: { 'Content-Type': 'multipart/form-data' } }
+
+ post.append('email', dialog.find('#email').val())
+
+ this.$root.clearFormValidation(dialog.find('form'))
+
+ // Append the file to POST data
+ let files = dialog.find('#file').get(0).files
+ if (files.length) {
+ post.append('file', files[0])
+ }
+
+ axios.post('/api/v4/invitations', post, params)
+ .then(response => {
+ if (response.data.status == 'success') {
+ dialog.modal('hide')
+ this.$toast.success(response.data.message)
+ if (response.data.count) {
+ this.loadInvitations({ reset: true })
+ }
+ }
+ })
+ },
+ inviteUserDialog() {
+ let dialog = $('#invite-create')
+ let form = dialog.find('form')
+
+ form.get(0).reset()
+ this.fileChange({ target: form.find('#file')[0] }) // resets file input label
+ this.$root.clearFormValidation(form)
+
+ dialog.on('shown.bs.modal', () => {
+ dialog.find('input').get(0).focus()
+ }).modal()
+ },
+ loadInvitations(params, callback) {
+ let loader
+ let get = {}
+
+ if (params) {
+ if (params.reset) {
+ this.invitations = []
+ this.page = 0
+ }
+
+ get.page = params.page || (this.page + 1)
+
+ if (typeof params === 'object' && 'search' in params) {
+ get.search = params.search
+ this.currentSearch = params.search
+ } else {
+ get.search = this.currentSearch
+ }
+
+ loader = $(get.page > 1 ? '#more-loader' : '#invitations-list tfoot td')
+ } else {
+ this.currentSearch = null
+ }
+
+ this.$root.addLoader(loader)
+
+ axios.get('/api/v4/invitations', { params: get })
+ .then(response => {
+ this.$root.removeLoader(loader)
+
+ // Note: In Vue we can't just use .concat()
+ for (let i in response.data.list) {
+ this.$set(this.invitations, this.invitations.length, response.data.list[i])
+ }
+ this.hasMore = response.data.hasMore
+ this.page = response.data.page || 1
+
+ if (callback) {
+ callback()
+ }
+ })
+ .catch(error => {
+ this.$root.removeLoader(loader)
+
+ if (callback) {
+ callback()
+ }
+ })
+ },
+ resendInvite(id) {
+ axios.post('/api/v4/invitations/' + id + '/resend')
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+
+ // Update the invitation record
+ const index = this.invitations.findIndex(item => item.id == id)
+ this.invitations.splice(index, 1)
+ this.$set(this.invitations, index, response.data.invitation)
+ }
+ })
+ },
+ searchInvitations() {
+ this.loadInvitations({ reset: true, search: this.search })
+ },
+ statusClass(invitation) {
+ if (invitation.isCompleted) {
+ return 'text-success'
+ }
+
+ if (invitation.isFailed) {
+ return 'text-danger'
+ }
+
+ if (invitation.isSent) {
+ return 'text-primary'
+ }
+
+ return ''
+ },
+ statusText(invitation) {
+ if (invitation.isCompleted) {
+ return 'User signed up'
+ }
+
+ if (invitation.isFailed) {
+ return 'Sending failed'
+ }
+
+ if (invitation.isSent) {
+ return 'Sent'
+ }
+
+ return 'Not sent yet'
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Reseller/Stats.vue b/src/resources/vue/Reseller/Stats.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Reseller/Stats.vue
@@ -0,0 +1,18 @@
+<template>
+ <div id="stats-container" class="container">
+ </div>
+</template>
+
+<script>
+ import Stats from '../Admin/Stats'
+
+ export default {
+ mixins: [Stats],
+ data() {
+ return {
+// charts: {},
+ chartTypes: ['users', 'users-all', 'discounts']
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue
--- a/src/resources/vue/Signup.vue
+++ b/src/resources/vue/Signup.vue
@@ -1,6 +1,6 @@
<template>
<div class="container">
- <div id="step0">
+ <div id="step0" v-if="!invitation">
<div class="plan-selector card-deck">
<div v-for="item in plans" :key="item.id" :class="'card bg-light plan-' + item.title">
<div class="card-header plan-header">
@@ -16,7 +16,7 @@
</div>
</div>
- <div class="card d-none" id="step1">
+ <div class="card d-none" id="step1" v-if="!invitation">
<div class="card-body">
<h4 class="card-title">Sign Up - Step 1/3</h4>
<p class="card-text">
@@ -39,7 +39,7 @@
</div>
</div>
- <div class="card d-none" id="step2">
+ <div class="card d-none" id="step2" v-if="!invitation">
<div class="card-body">
<h4 class="card-title">Sign Up - Step 2/3</h4>
<p class="card-text">
@@ -60,20 +60,28 @@
<div class="card d-none" id="step3">
<div class="card-body">
- <h4 class="card-title">Sign Up - Step 3/3</h4>
+ <h4 v-if="!invitation" class="card-title">Sign Up - Step 3/3</h4>
<p class="card-text">
Create your Kolab identity (you can choose additional addresses later).
</p>
<form @submit.prevent="submitStep3" data-validation-prefix="signup_">
+ <div class="form-group" v-if="invitation">
+ <div class="input-group">
+ <input type="text" class="form-control" id="signup_first_name" placeholder="First Name" autofocus v-model="first_name">
+ <input type="text" class="form-control rounded-right" id="signup_last_name" placeholder="Surname" v-model="last_name">
+ </div>
+ </div>
<div class="form-group">
<label for="signup_login" class="sr-only"></label>
<div class="input-group">
<input type="text" class="form-control" id="signup_login" required v-model="login" placeholder="Login">
- <span class="input-group-append">
+ <span class="input-group-append input-group-prepend">
<span class="input-group-text">@</span>
</span>
<input v-if="is_domain" type="text" class="form-control rounded-right" id="signup_domain" required v-model="domain" placeholder="Domain">
- <select v-else class="custom-select rounded-right" id="signup_domain" required v-model="domain"></select>
+ <select v-else class="custom-select rounded-right" id="signup_domain" required v-model="domain">
+ <option v-for="domain in domains" :key="domain" :value="domain">{{ domain }}</option>
+ </select>
</div>
</div>
<div class="form-group">
@@ -88,8 +96,10 @@
<label for="signup_voucher" class="sr-only">Voucher code</label>
<input type="text" class="form-control" id="signup_voucher" placeholder="Voucher code" v-model="voucher">
</div>
- <button class="btn btn-secondary" type="button" @click="stepBack">Back</button>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ <button v-if="!invitation" class="btn btn-secondary" type="button" @click="stepBack">Back</button>
+ <button class="btn btn-primary" type="submit">
+ <svg-icon icon="check"></svg-icon> <span v-if="invitation">Sign Up</span><span v-else>Submit</span>
+ </button>
</form>
</div>
</div>
@@ -109,8 +119,10 @@
password: '',
password_confirmation: '',
domain: '',
- plan: null,
+ domains: [],
+ invitation: null,
is_domain: false,
+ plan: null,
plan_icons: {
individual: 'user',
group: 'users'
@@ -122,7 +134,25 @@
mounted() {
let param = this.$route.params.param;
- if (param) {
+ if (this.$route.name == 'signup-invite') {
+ this.$root.startLoading()
+ axios.get('/api/auth/signup/invitations/' + param)
+ .then(response => {
+ this.invitation = response.data
+ this.login = response.data.login
+ this.voucher = response.data.voucher
+ this.first_name = response.data.first_name
+ this.last_name = response.data.last_name
+ this.plan = response.data.plan
+ this.is_domain = response.data.is_domain
+ this.setDomain(response.data)
+ this.$root.stopLoading()
+ this.displayForm(3, true)
+ })
+ .catch(error => {
+ this.$root.errorHandler(error)
+ })
+ } else if (param) {
if (this.$route.path.indexOf('/signup/voucher/') === 0) {
// Voucher (discount) code
this.voucher = param
@@ -199,13 +229,7 @@
// Fill the domain selector with available domains
if (!this.is_domain) {
- let options = []
- $('select#signup_domain').html('')
- $.each(response.data.domains, (i, v) => {
- options.push($('<option>').text(v).attr('value', v))
- })
- $('select#signup_domain').append(options)
- this.domain = window.config['app.domain']
+ this.setDomain(response.data)
}
}).catch(error => {
if (bylink === true) {
@@ -219,15 +243,25 @@
submitStep3() {
this.$root.clearFormValidation($('#step3 form'))
- axios.post('/api/auth/signup', {
- code: this.code,
- short_code: this.short_code,
+ let post = {
login: this.login,
domain: this.domain,
password: this.password,
password_confirmation: this.password_confirmation,
voucher: this.voucher
- }).then(response => {
+ }
+
+ if (this.invitation) {
+ post.invitation = this.invitation.id
+ post.plan = this.plan
+ post.first_name = this.first_name
+ post.last_name = this.last_name
+ } else {
+ post.code = this.code
+ post.short_code = this.short_code
+ }
+
+ axios.post('/api/auth/signup', post).then(response => {
// auto-login and goto dashboard
this.$root.loginUser(response.data)
})
@@ -258,6 +292,13 @@
if (focus) {
$('#step' + step).find('input').first().focus()
}
+ },
+ setDomain(response) {
+ if (response.domains) {
+ this.domains = response.domains
+ }
+
+ this.domain = response.domain || window.config['app.domain']
}
}
}
diff --git a/src/resources/vue/Widgets/Menu.vue b/src/resources/vue/Widgets/Menu.vue
--- a/src/resources/vue/Widgets/Menu.vue
+++ b/src/resources/vue/Widgets/Menu.vue
@@ -21,7 +21,7 @@
{{ item.title }}
</router-link>
</li>
- <li class="nav-item" v-if="!loggedIn && !$root.isAdmin">
+ <li class="nav-item" v-if="!loggedIn && $root.isUser">
<router-link class="nav-link link-signup" active-class="active" :to="{name: 'signup'}">{{ $t('menu.signup') }}</router-link>
</li>
<li class="nav-item" v-if="loggedIn">
diff --git a/src/resources/vue/Widgets/UserSearch.vue b/src/resources/vue/Widgets/UserSearch.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Widgets/UserSearch.vue
@@ -0,0 +1,78 @@
+<template>
+ <div id="search-box" class="card">
+ <div class="card-body">
+ <form @submit.prevent="searchUser" class="row justify-content-center">
+ <div class="input-group col-sm-8">
+ <input class="form-control" type="text" placeholder="User ID, email or domain" v-model="search">
+ <div class="input-group-append">
+ <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> Search</button>
+ </div>
+ </div>
+ </form>
+ <table v-if="users.length" class="table table-sm table-hover mt-4">
+ <thead class="thead-light">
+ <tr>
+ <th scope="col">Primary Email</th>
+ <th scope="col">ID</th>
+ <th scope="col" class="d-none d-md-table-cell">Created</th>
+ <th scope="col" class="d-none d-md-table-cell">Deleted</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="user in users" :id="'user' + user.id" :key="user.id" :class="user.isDeleted ? 'text-secondary' : ''">
+ <td class="text-nowrap">
+ <svg-icon icon="user" :class="$root.userStatusClass(user)" :title="$root.userStatusText(user)"></svg-icon>
+ <router-link v-if="!user.isDeleted" :to="{ path: 'user/' + user.id }">{{ user.email }}</router-link>
+ <span v-if="user.isDeleted">{{ user.email }}</span>
+ </td>
+ <td>
+ <router-link v-if="!user.isDeleted" :to="{ path: 'user/' + user.id }">{{ user.id }}</router-link>
+ <span v-if="user.isDeleted">{{ user.id }}</span>
+ </td>
+ <td class="d-none d-md-table-cell">{{ toDate(user.created_at) }}</td>
+ <td class="d-none d-md-table-cell">{{ toDate(user.deleted_at) }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ search: '',
+ users: []
+ }
+ },
+ mounted() {
+ $('#search-box input').focus()
+ },
+ methods: {
+ searchUser() {
+ this.users = []
+
+ axios.get('/api/v4/users', { params: { search: this.search } })
+ .then(response => {
+ if (response.data.count == 1 && !response.data.list[0].isDeleted) {
+ this.$router.push({ name: 'user', params: { user: response.data.list[0].id } })
+ return
+ }
+
+ if (response.data.message) {
+ this.$toast.info(response.data.message)
+ }
+
+ this.users = response.data.list
+ })
+ .catch(this.$root.errorHandler)
+ },
+ toDate(datetime) {
+ if (datetime) {
+ return datetime.split(' ')[0]
+ }
+ }
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -45,8 +45,9 @@
Route::post('password-reset/verify', 'API\PasswordResetController@verify');
Route::post('password-reset', 'API\PasswordResetController@reset');
- Route::get('signup/plans', 'API\SignupController@plans');
Route::post('signup/init', 'API\SignupController@init');
+ Route::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');
}
@@ -66,7 +67,6 @@
Route::apiResource('groups', API\V4\GroupsController::class);
Route::get('groups/{id}/status', 'API\V4\GroupsController@status');
- Route::apiResource('entitlements', API\V4\EntitlementsController::class);
Route::apiResource('packages', API\V4\PackagesController::class);
Route::apiResource('skus', API\V4\SkusController::class);
@@ -148,12 +148,9 @@
],
function () {
Route::apiResource('domains', API\V4\Admin\DomainsController::class);
- Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm');
Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend');
Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend');
- Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class);
-
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');
@@ -173,3 +170,48 @@
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::apiResource('packages', API\V4\Reseller\PackagesController::class);
+
+ 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::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::apiResource('discounts', API\V4\Reseller\DiscountsController::class);
+
+ Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart');
+ }
+);
diff --git a/src/routes/web.php b/src/routes/web.php
--- a/src/routes/web.php
+++ b/src/routes/web.php
@@ -15,6 +15,11 @@
Route::fallback(
function () {
+ // Return 404 for requests to the API end-points that do not exist
+ if (strpos(request()->path(), 'api/') === 0) {
+ return \App\Http\Controllers\Controller::errorResponse(404);
+ }
+
$env = \App\Utils::uiEnv();
return view($env['view'])->with('env', $env);
}
diff --git a/src/tests/Browser/Meet/RoomsTest.php b/src/tests/Browser/Meet/RoomsTest.php
--- a/src/tests/Browser/Meet/RoomsTest.php
+++ b/src/tests/Browser/Meet/RoomsTest.php
@@ -90,7 +90,7 @@
->waitFor('.card-text a')
->assertSeeIn('.card-title', 'Voice & Video Conferencing')
->assertSeeIn('.card-text a', $href)
- ->assertAttribute('.card-text a', 'href', $href)
+ ->assertAttribute('.card-text a', 'href', '/meet/john')
->click('.card-text a')
->on(new RoomPage('john'))
// check that entering the room skips the logon form
diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php
--- a/src/tests/Browser/Pages/Home.php
+++ b/src/tests/Browser/Pages/Home.php
@@ -49,11 +49,11 @@
/**
* Submit logon form.
*
- * @param \Laravel\Dusk\Browser $browser The browser object
- * @param string $username User name
- * @param string $password User password
- * @param bool $wait_for_dashboard
- * @param array $config Client-site config
+ * @param \Tests\Browser $browser The browser object
+ * @param string $username User name
+ * @param string $password User password
+ * @param bool $wait_for_dashboard
+ * @param array $config Client-site config
*
* @return void
*/
diff --git a/src/tests/Browser/Pages/Reseller/Invitations.php b/src/tests/Browser/Pages/Reseller/Invitations.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/Reseller/Invitations.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Tests\Browser\Pages\Reseller;
+
+use Laravel\Dusk\Page;
+
+class Invitations extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/invitations';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->assertPathIs($this->url())
+ ->waitUntilMissing('@app .app-loader')
+ ->assertSeeIn('#invitations .card-title', 'Signup Invitations');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@create-button' => '.card-text button.create-invite',
+ '@create-dialog' => '#invite-create',
+ '@search-button' => '#search-form button',
+ '@search-input' => '#search-form input',
+ '@table' => '#invitations-list',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Reseller/DashboardTest.php b/src/tests/Browser/Reseller/DashboardTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/DashboardTest.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace Tests\Browser\Reseller;
+
+use Illuminate\Support\Facades\Queue;
+use Tests\Browser;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+
+class DashboardTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+
+ $jack = $this->getTestUser('jack@kolab.org');
+ $jack->setSetting('external_email', null);
+
+ $this->deleteTestUser('test@testsearch.com');
+ $this->deleteTestDomain('testsearch.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $jack->setSetting('external_email', null);
+
+ $this->deleteTestUser('test@testsearch.com');
+ $this->deleteTestDomain('testsearch.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test user search
+ */
+ public function testSearch(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->on(new Dashboard())
+ ->assertFocused('@search input')
+ ->assertMissing('@search table');
+
+ // Test search with no results
+ $browser->type('@search input', 'unknown')
+ ->click('@search form button')
+ ->assertToast(Toast::TYPE_INFO, '0 user accounts have been found.')
+ ->assertMissing('@search table');
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $jack->setSetting('external_email', 'john.doe.external@gmail.com');
+
+ // Test search with multiple results
+ $browser->type('@search input', 'john.doe.external@gmail.com')
+ ->click('@search form button')
+ ->assertToast(Toast::TYPE_INFO, '2 user accounts have been found.')
+ ->whenAvailable('@search table', function (Browser $browser) use ($john, $jack) {
+ $browser->assertElementsCount('tbody tr', 2)
+ ->with('tbody tr:first-child', function (Browser $browser) use ($jack) {
+ $browser->assertSeeIn('td:nth-child(1) a', $jack->email)
+ ->assertSeeIn('td:nth-child(2) a', $jack->id)
+ ->assertVisible('td:nth-child(3)')
+ ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/')
+ ->assertVisible('td:nth-child(4)')
+ ->assertText('td:nth-child(4)', '');
+ })
+ ->with('tbody tr:last-child', function (Browser $browser) use ($john) {
+ $browser->assertSeeIn('td:nth-child(1) a', $john->email)
+ ->assertSeeIn('td:nth-child(2) a', $john->id)
+ ->assertVisible('td:nth-child(3)')
+ ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/')
+ ->assertVisible('td:nth-child(4)')
+ ->assertText('td:nth-child(4)', '');
+ });
+ });
+
+ // Test search with single record result -> redirect to user page
+ $browser->type('@search input', 'kolab.org')
+ ->click('@search form button')
+ ->assertMissing('@search table')
+ ->waitForLocation('/user/' . $john->id)
+ ->waitUntilMissing('.app-loader')
+ ->whenAvailable('#user-info', function (Browser $browser) use ($john) {
+ $browser->assertSeeIn('.card-title', $john->email);
+ });
+ });
+ }
+
+ /**
+ * Test user search deleted user/domain
+ */
+ public function testSearchDeleted(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->on(new Dashboard())
+ ->assertFocused('@search input')
+ ->assertMissing('@search table');
+
+ // 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();
+
+ // Test search with multiple results
+ $browser->type('@search input', 'testsearch.com')
+ ->click('@search form button')
+ ->assertToast(Toast::TYPE_INFO, '1 user accounts have been found.')
+ ->whenAvailable('@search table', function (Browser $browser) use ($user) {
+ $browser->assertElementsCount('tbody tr', 1)
+ ->assertVisible('tbody tr:first-child.text-secondary')
+ ->with('tbody tr:first-child', function (Browser $browser) use ($user) {
+ $browser->assertSeeIn('td:nth-child(1) span', $user->email)
+ ->assertSeeIn('td:nth-child(2) span', $user->id)
+ ->assertVisible('td:nth-child(3)')
+ ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/')
+ ->assertVisible('td:nth-child(4)')
+ ->assertTextRegExp('td:nth-child(4)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/');
+ });
+ });
+ });
+ }
+}
diff --git a/src/tests/Browser/Reseller/DistlistTest.php b/src/tests/Browser/Reseller/DistlistTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/DistlistTest.php
@@ -0,0 +1,128 @@
+<?php
+
+namespace Tests\Browser\Reseller;
+
+use App\Group;
+use Illuminate\Support\Facades\Queue;
+use Tests\Browser;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Admin\Distlist as DistlistPage;
+use Tests\Browser\Pages\Admin\User as UserPage;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+
+class DistlistTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+
+ $this->deleteTestGroup('group-test@kolab.org');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestGroup('group-test@kolab.org');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test distlist info page (unauthenticated)
+ */
+ public function testDistlistUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $user = $this->getTestUser('john@kolab.org');
+ $group = $this->getTestGroup('group-test@kolab.org');
+ $group->assignToWallet($user->wallets->first());
+
+ $browser->visit('/distlist/' . $group->id)->on(new Home());
+ });
+ }
+
+ /**
+ * Test distribution list info page
+ */
+ public function testInfo(): void
+ {
+ Queue::fake();
+
+ $this->browse(function (Browser $browser) {
+ $user = $this->getTestUser('john@kolab.org');
+ $group = $this->getTestGroup('group-test@kolab.org');
+ $group->assignToWallet($user->wallets->first());
+ $group->members = ['test1@gmail.com', 'test2@gmail.com'];
+ $group->save();
+
+ $distlist_page = new DistlistPage($group->id);
+ $user_page = new UserPage($user->id);
+
+ // Goto the distlist page
+ $browser->visit(new Home())
+ ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->on(new Dashboard())
+ ->visit($user_page)
+ ->on($user_page)
+ ->click('@nav #tab-distlists')
+ ->pause(1000)
+ ->click('@user-distlists table tbody tr:first-child td a')
+ ->on($distlist_page)
+ ->assertSeeIn('@distlist-info .card-title', $group->email)
+ ->with('@distlist-info form', function (Browser $browser) use ($group) {
+ $browser->assertElementsCount('.row', 3)
+ ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)')
+ ->assertSeeIn('.row:nth-child(1) #distlistid', "{$group->id} ({$group->created_at})")
+ ->assertSeeIn('.row:nth-child(2) label', 'Status')
+ ->assertSeeIn('.row:nth-child(2) #status.text-danger', 'Not Ready')
+ ->assertSeeIn('.row:nth-child(3) label', 'Recipients')
+ ->assertSeeIn('.row:nth-child(3) #members', $group->members[0])
+ ->assertSeeIn('.row:nth-child(3) #members', $group->members[1]);
+ });
+
+ // Test invalid group identifier
+ $browser->visit('/distlist/abc')->assertErrorPage(404);
+ });
+ }
+
+ /**
+ * Test suspending/unsuspending a distribution list
+ *
+ * @depends testInfo
+ */
+ public function testSuspendAndUnsuspend(): void
+ {
+ Queue::fake();
+
+ $this->browse(function (Browser $browser) {
+ $user = $this->getTestUser('john@kolab.org');
+ $group = $this->getTestGroup('group-test@kolab.org');
+ $group->assignToWallet($user->wallets->first());
+ $group->status = Group::STATUS_ACTIVE | Group::STATUS_LDAP_READY;
+ $group->save();
+
+ $browser->visit(new DistlistPage($group->id))
+ ->assertVisible('@distlist-info #button-suspend')
+ ->assertMissing('@distlist-info #button-unsuspend')
+ ->assertSeeIn('@distlist-info #status.text-success', 'Active')
+ ->click('@distlist-info #button-suspend')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list suspended successfully.')
+ ->assertSeeIn('@distlist-info #status.text-warning', 'Suspended')
+ ->assertMissing('@distlist-info #button-suspend')
+ ->click('@distlist-info #button-unsuspend')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list unsuspended successfully.')
+ ->assertSeeIn('@distlist-info #status.text-success', 'Active')
+ ->assertVisible('@distlist-info #button-suspend')
+ ->assertMissing('@distlist-info #button-unsuspend');
+ });
+ }
+}
diff --git a/src/tests/Browser/Reseller/DomainTest.php b/src/tests/Browser/Reseller/DomainTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/DomainTest.php
@@ -0,0 +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@kolabnow.com');
+ $user = $this->getTestUser('john@kolab.org');
+ $user_page = new UserPage($user->id);
+
+ // Goto the domain page
+ $browser->visit(new Home())
+ ->submitLogon('reseller@kolabnow.com', 'reseller', 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 at)')
+ ->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);
+
+ // 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/InvitationsTest.php b/src/tests/Browser/Reseller/InvitationsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/InvitationsTest.php
@@ -0,0 +1,222 @@
+<?php
+
+namespace Tests\Browser\Reseller;
+
+use App\SignupInvitation;
+use Illuminate\Support\Facades\Queue;
+use Tests\Browser;
+use Tests\Browser\Components\Dialog;
+use Tests\Browser\Components\Menu;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\Reseller\Invitations;
+use Tests\TestCaseDusk;
+
+class InvitationsTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ SignupInvitation::truncate();
+ }
+
+ /**
+ * Test invitations page (unauthenticated)
+ */
+ public function testInvitationsUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/invitations')->on(new Home());
+ });
+ }
+
+ /**
+ * Test Invitations creation
+ */
+ public function testInvitationCreate(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $date_regexp = '/^20[0-9]{2}-/';
+
+ $browser->visit(new Home())
+ ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->on(new Dashboard())
+ ->assertSeeIn('@links .link-invitations', 'Invitations')
+ ->click('@links .link-invitations')
+ ->on(new Invitations())
+ ->assertElementsCount('@table tbody tr', 0)
+ ->assertMissing('#more-loader')
+ ->assertSeeIn('@table tfoot td', "There are no invitations in the database.")
+ ->assertSeeIn('@create-button', 'Create invite(s)');
+
+ // Create a single invite with email address input
+ $browser->click('@create-button')
+ ->with(new Dialog('#invite-create'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Invite for a signup')
+ ->assertFocused('@body input#email')
+ ->assertValue('@body input#email', '')
+ ->type('@body input#email', 'test')
+ ->assertSeeIn('@button-action', 'Send invite(s)')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, "Form validation error")
+ ->waitFor('@body input#email.is-invalid')
+ ->assertSeeIn(
+ '@body input#email.is-invalid + .invalid-feedback',
+ "The email must be a valid email address."
+ )
+ ->type('@body input#email', 'test@domain.tld')
+ ->click('@button-action');
+ })
+ ->assertToast(Toast::TYPE_SUCCESS, "The invitation has been created.")
+ ->waitUntilMissing('#invite-create')
+ ->waitUntilMissing('@table .app-loader')
+ ->assertElementsCount('@table tbody tr', 1)
+ ->assertMissing('@table tfoot')
+ ->assertSeeIn('@table tbody tr td.email', 'test@domain.tld')
+ ->assertText('@table tbody tr td.email title', 'Not sent yet')
+ ->assertTextRegExp('@table tbody tr td.datetime', $date_regexp)
+ ->assertVisible('@table tbody tr td.buttons button.button-delete')
+ ->assertVisible('@table tbody tr td.buttons button.button-resend:disabled');
+
+ sleep(1);
+
+ // Create invites from a file
+ $browser->click('@create-button')
+ ->with(new Dialog('#invite-create'), function (Browser $browser) {
+ $browser->assertFocused('@body input#email')
+ ->assertValue('@body input#email', '')
+ ->assertMissing('@body input#email.is-invalid')
+ // Submit an empty file
+ ->attach('@body input#file', __DIR__ . '/../../data/empty.csv')
+ ->assertSeeIn('@body input#file + label', 'empty.csv')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, "Form validation error")
+ // ->waitFor('input#file.is-invalid')
+ ->assertSeeIn(
+ '@body input#file.is-invalid + label + .invalid-feedback',
+ "Failed to find any valid email addresses in the uploaded file."
+ )
+ // Submit non-empty file
+ ->attach('@body input#file', __DIR__ . '/../../data/email.csv')
+ ->click('@button-action');
+ })
+ ->assertToast(Toast::TYPE_SUCCESS, "2 invitations has been created.")
+ ->waitUntilMissing('#invite-create')
+ ->waitUntilMissing('@table .app-loader')
+ ->assertElementsCount('@table tbody tr', 3)
+ ->assertTextRegExp('@table tbody tr:nth-child(1) td.email', '/email[12]@test\.com$/')
+ ->assertTextRegExp('@table tbody tr:nth-child(2) td.email', '/email[12]@test\.com$/');
+ });
+ }
+
+ /**
+ * Test Invitations deletion and resending
+ */
+ public function testInvitationDeleteAndResend(): void
+ {
+ $this->browse(function (Browser $browser) {
+ Queue::fake();
+ $i1 = SignupInvitation::create(['email' => 'test1@domain.org']);
+ $i2 = SignupInvitation::create(['email' => 'test2@domain.org']);
+ SignupInvitation::where('id', $i2->id)
+ ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]);
+
+ // Test deleting
+ $browser->visit(new Invitations())
+ // ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->assertElementsCount('@table tbody tr', 2)
+ ->click('@table tbody tr:first-child button.button-delete')
+ ->assertToast(Toast::TYPE_SUCCESS, "Invitation deleted successfully.")
+ ->assertElementsCount('@table tbody tr', 1);
+
+ // Test resending
+ $browser->click('@table tbody tr:first-child button.button-resend')
+ ->assertToast(Toast::TYPE_SUCCESS, "Invitation added to the sending queue successfully.")
+ ->assertElementsCount('@table tbody tr', 1);
+ });
+ }
+
+ /**
+ * Test Invitations list (paging and searching)
+ */
+ public function testInvitationsList(): void
+ {
+ $this->browse(function (Browser $browser) {
+ Queue::fake();
+ $i1 = SignupInvitation::create(['email' => 'email1@ext.com']);
+ $i2 = SignupInvitation::create(['email' => 'email2@ext.com']);
+ $i3 = SignupInvitation::create(['email' => 'email3@ext.com']);
+ $i4 = SignupInvitation::create(['email' => 'email4@other.com']);
+ $i5 = SignupInvitation::create(['email' => 'email5@other.com']);
+ $i6 = SignupInvitation::create(['email' => 'email6@other.com']);
+ $i7 = SignupInvitation::create(['email' => 'email7@other.com']);
+ $i8 = SignupInvitation::create(['email' => 'email8@other.com']);
+ $i9 = SignupInvitation::create(['email' => 'email9@other.com']);
+ $i10 = SignupInvitation::create(['email' => 'email10@other.com']);
+ $i11 = SignupInvitation::create(['email' => 'email11@other.com']);
+
+ SignupInvitation::query()->update(['created_at' => now()->subDays('1')]);
+ SignupInvitation::where('id', $i1->id)
+ ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]);
+ SignupInvitation::where('id', $i2->id)
+ ->update(['created_at' => now()->subHours('3'), 'status' => SignupInvitation::STATUS_SENT]);
+ SignupInvitation::where('id', $i3->id)
+ ->update(['created_at' => now()->subHours('4'), 'status' => SignupInvitation::STATUS_COMPLETED]);
+ SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays('3')]);
+
+ // Test paging (load more) feature
+ $browser->visit(new Invitations())
+ // ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->assertElementsCount('@table tbody tr', 10)
+ ->assertSeeIn('#more-loader button', 'Load more')
+ ->with('@table tbody', function ($browser) use ($i1, $i2, $i3) {
+ $browser->assertSeeIn('tr:nth-child(1) td.email', $i1->email)
+ ->assertText('tr:nth-child(1) td.email svg.text-danger title', 'Sending failed')
+ ->assertVisible('tr:nth-child(1) td.buttons button.button-delete')
+ ->assertVisible('tr:nth-child(1) td.buttons button.button-resend:not(:disabled)')
+ ->assertSeeIn('tr:nth-child(2) td.email', $i2->email)
+ ->assertText('tr:nth-child(2) td.email svg.text-primary title', 'Sent')
+ ->assertVisible('tr:nth-child(2) td.buttons button.button-delete')
+ ->assertVisible('tr:nth-child(2) td.buttons button.button-resend:not(:disabled)')
+ ->assertSeeIn('tr:nth-child(3) td.email', $i3->email)
+ ->assertText('tr:nth-child(3) td.email svg.text-success title', 'User signed up')
+ ->assertVisible('tr:nth-child(3) td.buttons button.button-delete')
+ ->assertVisible('tr:nth-child(3) td.buttons button.button-resend:disabled')
+ ->assertText('tr:nth-child(4) td.email svg title', 'Not sent yet')
+ ->assertVisible('tr:nth-child(4) td.buttons button.button-delete')
+ ->assertVisible('tr:nth-child(4) td.buttons button.button-resend:disabled');
+ })
+ ->click('#more-loader button')
+ ->whenAvailable('@table tbody tr:nth-child(11)', function ($browser) use ($i11) {
+ $browser->assertSeeIn('td.email', $i11->email);
+ })
+ ->assertMissing('#more-loader button');
+
+ // Test searching (by domain)
+ $browser->type('@search-input', 'ext.com')
+ ->click('@search-button')
+ ->waitUntilMissing('@table .app-loader')
+ ->assertElementsCount('@table tbody tr', 3)
+ ->assertMissing('#more-loader button')
+ // search by full email
+ ->type('@search-input', 'email7@other.com')
+ ->keys('@search-input', '{enter}')
+ ->waitUntilMissing('@table .app-loader')
+ ->assertElementsCount('@table tbody tr', 1)
+ ->assertSeeIn('@table tbody tr:nth-child(1) td.email', 'email7@other.com')
+ ->assertMissing('#more-loader button')
+ // reset search
+ ->vueClear('#search-form input')
+ ->keys('@search-input', '{enter}')
+ ->waitUntilMissing('@table .app-loader')
+ ->assertElementsCount('@table tbody tr', 10)
+ ->assertVisible('#more-loader button');
+ });
+ }
+}
diff --git a/src/tests/Browser/Reseller/LogonTest.php b/src/tests/Browser/Reseller/LogonTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/LogonTest.php
@@ -0,0 +1,144 @@
+<?php
+
+namespace Tests\Browser\Reseller;
+
+use Tests\Browser;
+use Tests\Browser\Components\Menu;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+
+class LogonTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * Test menu on logon page
+ */
+ public function testLogonMenu(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->with(new Menu(), function ($browser) {
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']);
+ })
+ ->assertMissing('@second-factor-input')
+ ->assertMissing('@forgot-password');
+ });
+ }
+
+ /**
+ * Test redirect to /login if user is unauthenticated
+ */
+ public function testLogonRedirect(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/dashboard');
+
+ // Checks if we're really on the login page
+ $browser->waitForLocation('/login')
+ ->on(new Home());
+ });
+ }
+
+ /**
+ * Logon with wrong password/user test
+ */
+ public function testLogonWrongCredentials(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('reseller@reseller.com', 'wrong')
+ // Error message
+ ->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.')
+ // Checks if we're still on the logon page
+ ->on(new Home());
+ });
+ }
+
+ /**
+ * Successful logon test
+ */
+ public function testLogonSuccessful(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('reseller@reseller.com', 'reseller', true);
+
+ // Checks if we're really on Dashboard page
+ $browser->on(new Dashboard())
+ ->within(new Menu(), function ($browser) {
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout', 'lang']);
+ })
+ ->assertUser('reseller@reseller.com');
+
+ // Test that visiting '/' with logged in user does not open logon form
+ // but "redirects" to the dashboard
+ $browser->visit('/')->on(new Dashboard());
+ });
+ }
+
+ /**
+ * Logout test
+ *
+ * @depends testLogonSuccessful
+ */
+ public function testLogout(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->on(new Dashboard());
+
+ // Click the Logout button
+ $browser->within(new Menu(), function ($browser) {
+ $browser->clickMenuItem('logout');
+ });
+
+ // We expect the logon page
+ $browser->waitForLocation('/login')
+ ->on(new Home());
+
+ // with default menu
+ $browser->within(new Menu(), function ($browser) {
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']);
+ });
+
+ // Success toast message
+ $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out');
+ });
+ }
+
+ /**
+ * Logout by URL test
+ */
+ public function testLogoutByURL(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('reseller@reseller.com', 'reseller', true);
+
+ // Checks if we're really on Dashboard page
+ $browser->on(new Dashboard());
+
+ // Use /logout url, and expect the logon page
+ $browser->visit('/logout')
+ ->waitForLocation('/login')
+ ->on(new Home());
+
+ // with default menu
+ $browser->within(new Menu(), function ($browser) {
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']);
+ });
+
+ // Success toast message
+ $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out');
+ });
+ }
+}
diff --git a/src/tests/Browser/Reseller/PaymentMollieTest.php b/src/tests/Browser/Reseller/PaymentMollieTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/PaymentMollieTest.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Tests\Browser\Reseller;
+
+use App\Providers\PaymentProvider;
+use App\Wallet;
+use Tests\Browser;
+use Tests\Browser\Components\Dialog;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\PaymentMollie;
+use Tests\Browser\Pages\Wallet as WalletPage;
+use Tests\TestCaseDusk;
+
+class PaymentMollieTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $user = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $user->wallets()->first();
+ $wallet->payments()->delete();
+ $wallet->balance = 0;
+ $wallet->save();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test the payment process
+ *
+ * @group mollie
+ */
+ public function testPayment(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $user = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $user->wallets()->first();
+ $wallet->payments()->delete();
+ $wallet->balance = 0;
+ $wallet->save();
+
+ $browser->visit(new Home())
+ ->submitLogon($user->email, 'reseller', true, ['paymentProvider' => 'mollie'])
+ ->on(new Dashboard())
+ ->click('@links .link-wallet')
+ ->on(new WalletPage())
+ ->assertSeeIn('@main button', 'Add credit')
+ ->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Top up your wallet')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->waitFor('#payment-method-selection #paypal')
+ ->assertMissing('#payment-method-selection #banktransfer')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Top up your wallet')
+ ->assertFocused('#amount')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Continue')
+ // Test error handling
+ ->type('@body #amount', 'aaa')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
+ // Submit valid data
+ ->type('@body #amount', '12.34')
+ // Note we use double click to assert it does not create redundant requests
+ ->click('@button-action')
+ ->click('@button-action');
+ })
+ ->on(new PaymentMollie())
+ ->assertSeeIn('@title', \config('app.name') . ' Payment')
+ ->assertSeeIn('@amount', 'CHF 12.34');
+
+ $this->assertSame(1, $wallet->payments()->count());
+
+ // Looks like the Mollie testing mode is limited.
+ // We'll select credit card method and mark the payment as paid
+ // We can't do much more, we have to trust Mollie their page works ;)
+
+ // For some reason I don't get the method selection form, it
+ // immediately jumps to the next step. Let's detect that
+ if ($browser->element('@methods')) {
+ $browser->click('@methods button.grid-button-creditcard')
+ ->waitFor('button.form__button');
+ }
+
+ $browser->click('@status-table input[value="paid"]')
+ ->click('button.form__button');
+
+ // Now it should redirect back to wallet page and in background
+ // use the webhook to update payment status (and balance).
+
+ // Looks like in test-mode the webhook is executed before redirect
+ // so we can expect balance updated on the wallet page
+
+ $browser->waitForLocation('/wallet')
+ ->on(new WalletPage())
+ ->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF');
+ });
+ }
+}
diff --git a/src/tests/Browser/Reseller/StatsTest.php b/src/tests/Browser/Reseller/StatsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/StatsTest.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Tests\Browser\Reseller;
+
+use Tests\Browser;
+use Tests\Browser\Pages\Admin\Stats;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+
+class StatsTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * Test Stats page (unauthenticated)
+ */
+ public function testStatsUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/stats')->on(new Home());
+ });
+ }
+
+ /**
+ * Test Stats page
+ */
+ public function testStats(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->on(new Dashboard())
+ ->assertSeeIn('@links .link-stats', 'Stats')
+ ->click('@links .link-stats')
+ ->on(new Stats())
+ ->assertElementsCount('@container > div', 3)
+ ->waitFor('@container #chart-users svg')
+ ->assertSeeIn('@container #chart-users svg .title', 'Users - last 8 weeks')
+ ->waitFor('@container #chart-users-all svg')
+ ->assertSeeIn('@container #chart-users-all svg .title', 'All Users - last year')
+ ->waitFor('@container #chart-discounts svg')
+ ->assertSeeIn('@container #chart-discounts svg .title', 'Discounts');
+ });
+ }
+}
diff --git a/src/tests/Browser/Reseller/UserFinancesTest.php b/src/tests/Browser/Reseller/UserFinancesTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/UserFinancesTest.php
@@ -0,0 +1,325 @@
+<?php
+
+namespace Tests\Browser\Reseller;
+
+use App\Discount;
+use App\Transaction;
+use App\User;
+use App\Wallet;
+use Carbon\Carbon;
+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;
+
+class UserFinancesTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $wallet = $john->wallets()->first();
+ $wallet->discount()->dissociate();
+ $wallet->balance = 0;
+ $wallet->save();
+ $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]);
+ }
+
+ /**
+ * Test Finances tab (and transactions)
+ */
+ public function testFinances(): void
+ {
+ // Assert Jack's Finances tab
+ $this->browse(function (Browser $browser) {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $wallet = $jack->wallets()->first();
+ $wallet->transactions()->delete();
+ $wallet->setSetting('stripe_id', 'abc');
+ $page = new UserPage($jack->id);
+
+ $browser->visit(new Home())
+ ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->on(new Dashboard())
+ ->visit($page)
+ ->on($page)
+ ->assertSeeIn('@nav #tab-finances', 'Finances')
+ ->with('@user-finances', function (Browser $browser) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertSeeIn('.card-title:first-child', 'Account balance')
+ ->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF')
+ ->with('form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 2)
+ ->assertSeeIn('.row:nth-child(1) label', 'Discount')
+ ->assertSeeIn('.row:nth-child(1) #discount span', 'none')
+ ->assertSeeIn('.row:nth-child(2) label', 'Stripe ID')
+ ->assertSeeIn('.row:nth-child(2) a', 'abc');
+ })
+ ->assertSeeIn('h2:nth-of-type(2)', 'Transactions')
+ ->with('table', function (Browser $browser) {
+ $browser->assertMissing('tbody')
+ ->assertSeeIn('tfoot td', "There are no transactions for this account.");
+ })
+ ->assertMissing('table + button');
+ });
+ });
+
+ // Assert John's Finances tab (with discount, and debit)
+ $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->transactions()->delete();
+ $wallet->discount()->associate($discount);
+ $wallet->debit(2010);
+ $wallet->save();
+
+ // Create test transactions
+ $transaction = Transaction::create([
+ 'user_email' => 'jeroen@jeroen.jeroen',
+ 'object_id' => $wallet->id,
+ 'object_type' => Wallet::class,
+ 'type' => Transaction::WALLET_CREDIT,
+ 'amount' => 100,
+ 'description' => 'Payment',
+ ]);
+ $transaction->created_at = Carbon::now()->subMonth();
+ $transaction->save();
+
+ // Click the managed-by link on Jack's page
+ $browser->click('@user-info #manager a')
+ ->on($page)
+ ->with('@user-finances', function (Browser $browser) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertSeeIn('.card-title:first-child', 'Account balance')
+ ->assertSeeIn('.card-title:first-child .text-danger', '-20,10 CHF')
+ ->with('form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:nth-child(1) label', 'Discount')
+ ->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher');
+ })
+ ->assertSeeIn('h2:nth-of-type(2)', 'Transactions')
+ ->with('table', function (Browser $browser) {
+ $browser->assertElementsCount('tbody tr', 2)
+ ->assertMissing('tfoot');
+
+ if (!$browser->isPhone()) {
+ $browser->assertSeeIn('tbody tr:last-child td.email', 'jeroen@jeroen.jeroen');
+ }
+ });
+ });
+ });
+
+ // 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');
+ $wallet = $ned->wallets()->first();
+ $wallet->balance = 0;
+ $wallet->save();
+ $page = new UserPage($ned->id);
+
+ $browser->click('@nav #tab-users')
+ ->click('@user-users tbody tr:nth-child(4) td:first-child a')
+ ->on($page)
+ ->with('@user-finances', function (Browser $browser) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertSeeIn('.card-title:first-child', 'Account balance')
+ ->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF')
+ ->with('form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:nth-child(1) label', 'Discount')
+ ->assertSeeIn('.row:nth-child(1) #discount span', 'none');
+ })
+ ->assertSeeIn('h2:nth-of-type(2)', 'Transactions')
+ ->with('table', function (Browser $browser) {
+ $browser->assertMissing('tbody')
+ ->assertSeeIn('tfoot td', "There are no transactions for this account.");
+ })
+ ->assertMissing('table + button');
+ });
+ });
+ }
+
+ /**
+ * Test editing wallet discount
+ *
+ * @depends testFinances
+ */
+ public function testWalletDiscount(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $john = $this->getTestUser('john@kolab.org');
+
+ $browser->visit(new UserPage($john->id))
+ ->pause(100)
+ ->waitUntilMissing('@user-finances .app-loader')
+ ->click('@user-finances #discount button')
+ // Test dialog content, and closing it with Cancel button
+ ->with(new Dialog('#discount-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Account discount')
+ ->assertFocused('@body select')
+ ->assertSelected('@body select', '')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Submit')
+ ->click('@button-cancel');
+ })
+ ->assertMissing('#discount-dialog')
+ ->click('@user-finances #discount button')
+ // Change the discount
+ ->with(new Dialog('#discount-dialog'), function (Browser $browser) {
+ $browser->click('@body select')
+ ->click('@body select option:nth-child(2)')
+ ->click('@button-action');
+ })
+ ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.')
+ ->assertSeeIn('#discount span', '10% - Test voucher')
+ ->click('@nav #tab-subscriptions')
+ ->with('@user-subscriptions', function (Browser $browser) {
+ $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
+ ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
+ })
+ // Change back to 'none'
+ ->click('@nav #tab-finances')
+ ->click('@user-finances #discount button')
+ ->with(new Dialog('#discount-dialog'), function (Browser $browser) {
+ $browser->click('@body select')
+ ->click('@body select option:nth-child(1)')
+ ->click('@button-action');
+ })
+ ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.')
+ ->assertSeeIn('#discount span', 'none')
+ ->click('@nav #tab-subscriptions')
+ ->with('@user-subscriptions', function (Browser $browser) {
+ $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF/month')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF/month')
+ ->assertMissing('table + .hint');
+ });
+ });
+ }
+
+ /**
+ * Test awarding/penalizing a wallet
+ *
+ * @depends testFinances
+ */
+ public function testBonusPenalty(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $john = $this->getTestUser('john@kolab.org');
+
+ $browser->visit(new UserPage($john->id))
+ ->waitFor('@user-finances #button-award')
+ ->click('@user-finances #button-award')
+ // Test dialog content, and closing it with Cancel button
+ ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add a bonus to the wallet')
+ ->assertFocused('@body input#oneoff_amount')
+ ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount')
+ ->assertvalue('@body input#oneoff_amount', '')
+ ->assertSeeIn('@body label[for="oneoff_description"]', 'Description')
+ ->assertvalue('@body input#oneoff_description', '')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Submit')
+ ->click('@button-cancel');
+ })
+ ->assertMissing('#oneoff-dialog');
+
+ // Test bonus
+ $browser->click('@user-finances #button-award')
+ ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
+ // Test input validation for a bonus
+ $browser->type('@body #oneoff_amount', 'aaa')
+ ->type('@body #oneoff_description', '')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #oneoff_amount.is-invalid')
+ ->assertVisible('@body #oneoff_description.is-invalid')
+ ->assertSeeIn(
+ '@body #oneoff_amount + span + .invalid-feedback',
+ 'The amount must be a number.'
+ )
+ ->assertSeeIn(
+ '@body #oneoff_description + .invalid-feedback',
+ 'The description field is required.'
+ );
+
+ // Test adding a bonus
+ $browser->type('@body #oneoff_amount', '12.34')
+ ->type('@body #oneoff_description', 'Test bonus')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The bonus has been added to the wallet successfully.');
+ })
+ ->assertMissing('#oneoff-dialog')
+ ->assertSeeIn('@user-finances .card-title span.text-success', '12,34 CHF')
+ ->waitUntilMissing('.app-loader')
+ ->with('table', function (Browser $browser) {
+ $browser->assertElementsCount('tbody tr', 3)
+ ->assertMissing('tfoot')
+ ->assertSeeIn('tbody tr:first-child td.description', 'Bonus: Test bonus')
+ ->assertSeeIn('tbody tr:first-child td.price', '12,34 CHF');
+
+ if (!$browser->isPhone()) {
+ $browser->assertSeeIn('tbody tr:first-child td.email', 'reseller@kolabnow.com');
+ }
+ });
+
+ $this->assertSame(1234, $john->wallets()->first()->balance);
+
+ // Test penalty
+ $browser->click('@user-finances #button-penalty')
+ // Test dialog content, and closing it with Cancel button
+ ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add a penalty to the wallet')
+ ->assertFocused('@body input#oneoff_amount')
+ ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount')
+ ->assertvalue('@body input#oneoff_amount', '')
+ ->assertSeeIn('@body label[for="oneoff_description"]', 'Description')
+ ->assertvalue('@body input#oneoff_description', '')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Submit')
+ ->click('@button-cancel');
+ })
+ ->assertMissing('#oneoff-dialog')
+ ->click('@user-finances #button-penalty')
+ ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
+ // Test input validation for a penalty
+ $browser->type('@body #oneoff_amount', '')
+ ->type('@body #oneoff_description', '')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #oneoff_amount.is-invalid')
+ ->assertVisible('@body #oneoff_description.is-invalid')
+ ->assertSeeIn(
+ '@body #oneoff_amount + span + .invalid-feedback',
+ 'The amount field is required.'
+ )
+ ->assertSeeIn(
+ '@body #oneoff_description + .invalid-feedback',
+ 'The description field is required.'
+ );
+
+ // Test adding a penalty
+ $browser->type('@body #oneoff_amount', '12.35')
+ ->type('@body #oneoff_description', 'Test penalty')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The penalty has been added to the wallet successfully.');
+ })
+ ->assertMissing('#oneoff-dialog')
+ ->assertSeeIn('@user-finances .card-title span.text-danger', '-0,01 CHF');
+
+ $this->assertSame(-1, $john->wallets()->first()->balance);
+ });
+ }
+}
diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/UserTest.php
@@ -0,0 +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@kolabnow.com', 'reseller', 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 at)')
+ ->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);
+
+ // 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', '4,44 CHF')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 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', '5,55 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.');
+ });
+ });
+ }
+
+ /**
+ * 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 at)')
+ ->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);
+
+ // 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', '3,99 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 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,99 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 at)')
+ ->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);
+
+ // 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', '3,99 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 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,99 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
+ ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,90 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']);
+ $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/Reseller/WalletTest.php b/src/tests/Browser/Reseller/WalletTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/WalletTest.php
@@ -0,0 +1,248 @@
+<?php
+
+namespace Tests\Browser\Reseller;
+
+use App\Payment;
+use App\Providers\PaymentProvider;
+use App\Transaction;
+use App\Wallet;
+use Carbon\Carbon;
+use Tests\Browser;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\Wallet as WalletPage;
+use Tests\TestCaseDusk;
+
+class WalletTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $reseller->wallets()->first();
+ $wallet->balance = 0;
+ $wallet->save();
+ $wallet->payments()->delete();
+ $wallet->transactions()->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test wallet page (unauthenticated)
+ */
+ public function testWalletUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/wallet')->on(new Home());
+ });
+ }
+
+ /**
+ * Test wallet "box" on Dashboard
+ */
+ public function testDashboard(): void
+ {
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+ Wallet::where('user_id', $reseller->id)->update(['balance' => 125]);
+
+ // Positive balance
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->on(new Dashboard())
+ ->assertSeeIn('@links .link-wallet .name', 'Wallet')
+ ->assertSeeIn('@links .link-wallet .badge-success', '1,25 CHF');
+ });
+
+ Wallet::where('user_id', $reseller->id)->update(['balance' => -1234]);
+
+ // Negative balance
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Dashboard())
+ ->assertSeeIn('@links .link-wallet .name', 'Wallet')
+ ->assertSeeIn('@links .link-wallet .badge-danger', '-12,34 CHF');
+ });
+ }
+
+ /**
+ * Test wallet page
+ *
+ * @depends testDashboard
+ */
+ public function testWallet(): void
+ {
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+ Wallet::where('user_id', $reseller->id)->update(['balance' => -1234]);
+
+ $this->browse(function (Browser $browser) {
+ $browser->click('@links .link-wallet')
+ ->on(new WalletPage())
+ ->assertSeeIn('#wallet .card-title', 'Account balance -12,34 CHF')
+ ->assertSeeIn('#wallet .card-title .text-danger', '-12,34 CHF')
+ ->assertSeeIn('#wallet .card-text', 'You are out of credit');
+ });
+ }
+
+ /**
+ * Test Receipts tab
+ *
+ * @depends testWallet
+ */
+ public function testReceipts(): void
+ {
+ $user = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $user->wallets()->first();
+ $wallet->payments()->delete();
+
+ // Assert Receipts tab content when there's no receipts available
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new WalletPage())
+ ->assertSeeIn('#wallet .card-title', 'Account balance 0,00 CHF')
+ ->assertSeeIn('#wallet .card-title .text-success', '0,00 CHF')
+ ->assertSeeIn('#wallet .card-text', 'You are in your free trial period.') // TODO
+ ->assertSeeIn('@nav #tab-receipts', 'Receipts')
+ ->with('@receipts-tab', function (Browser $browser) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertSeeIn('p', 'There are no receipts for payments')
+ ->assertDontSeeIn('p', 'Here you can download')
+ ->assertMissing('select')
+ ->assertMissing('button');
+ });
+ });
+
+ // Create some sample payments
+ $receipts = [];
+ $date = Carbon::create(intval(date('Y')) - 1, 3, 30);
+ $payment = Payment::create([
+ 'id' => 'AAA1',
+ 'status' => PaymentProvider::STATUS_PAID,
+ 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'description' => 'Paid in March',
+ 'wallet_id' => $wallet->id,
+ 'provider' => 'stripe',
+ 'amount' => 1111,
+ 'currency_amount' => 1111,
+ 'currency' => 'CHF',
+ ]);
+ $payment->updated_at = $date;
+ $payment->save();
+ $receipts[] = $date->format('Y-m');
+
+ $date = Carbon::create(intval(date('Y')) - 1, 4, 30);
+ $payment = Payment::create([
+ 'id' => 'AAA2',
+ 'status' => PaymentProvider::STATUS_PAID,
+ 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'description' => 'Paid in April',
+ 'wallet_id' => $wallet->id,
+ 'provider' => 'stripe',
+ 'amount' => 1111,
+ 'currency_amount' => 1111,
+ 'currency' => 'CHF',
+ ]);
+ $payment->updated_at = $date;
+ $payment->save();
+ $receipts[] = $date->format('Y-m');
+
+ // Assert Receipts tab with receipts available
+ $this->browse(function (Browser $browser) use ($receipts) {
+ $browser->refresh()
+ ->on(new WalletPage())
+ ->assertSeeIn('@nav #tab-receipts', 'Receipts')
+ ->with('@receipts-tab', function (Browser $browser) use ($receipts) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertDontSeeIn('p', 'There are no receipts for payments')
+ ->assertSeeIn('p', 'Here you can download')
+ ->assertSeeIn('button', 'Download')
+ ->assertElementsCount('select > option', 2)
+ ->assertSeeIn('select > option:nth-child(1)', $receipts[1])
+ ->assertSeeIn('select > option:nth-child(2)', $receipts[0]);
+
+ // Download a receipt file
+ $browser->select('select', $receipts[0])
+ ->click('button')
+ ->pause(2000);
+
+ $files = glob(__DIR__ . '/../downloads/*.pdf');
+
+ $filename = pathinfo($files[0], PATHINFO_BASENAME);
+ $this->assertTrue(strpos($filename, $receipts[0]) !== false);
+
+ $content = $browser->readDownloadedFile($filename, 0);
+ $this->assertStringStartsWith("%PDF-1.", $content);
+
+ $browser->removeDownloadedFile($filename);
+ });
+ });
+ }
+
+ /**
+ * Test History tab
+ *
+ * @depends testWallet
+ */
+ public function testHistory(): void
+ {
+ $user = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $user->wallets()->first();
+ $wallet->transactions()->delete();
+
+ // Create some sample transactions
+ $transactions = $this->createTestTransactions($wallet);
+ $transactions = array_reverse($transactions);
+ $pages = array_chunk($transactions, 10 /* page size*/);
+
+ $this->browse(function (Browser $browser) use ($pages) {
+ $browser->on(new WalletPage())
+ ->assertSeeIn('@nav #tab-history', 'History')
+ ->click('@nav #tab-history')
+ ->with('@history-tab', function (Browser $browser) use ($pages) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertElementsCount('table tbody tr', 10)
+ ->assertMissing('table td.email')
+ ->assertSeeIn('#transactions-loader button', 'Load more');
+
+ foreach ($pages[0] as $idx => $transaction) {
+ $selector = 'table tbody tr:nth-child(' . ($idx + 1) . ')';
+ $priceStyle = $transaction->type == Transaction::WALLET_AWARD ? 'text-success' : 'text-danger';
+ $browser->assertSeeIn("$selector td.description", $transaction->shortDescription())
+ ->assertMissing("$selector td.selection button")
+ ->assertVisible("$selector td.price.{$priceStyle}");
+ // TODO: Test more transaction details
+ }
+
+ // Load the next page
+ $browser->click('#transactions-loader button')
+ ->waitUntilMissing('.app-loader')
+ ->assertElementsCount('table tbody tr', 12)
+ ->assertMissing('#transactions-loader button');
+
+ $debitEntry = null;
+ foreach ($pages[1] as $idx => $transaction) {
+ $selector = 'table tbody tr:nth-child(' . ($idx + 1 + 10) . ')';
+ $priceStyle = $transaction->type == Transaction::WALLET_CREDIT ? 'text-success' : 'text-danger';
+ $browser->assertSeeIn("$selector td.description", $transaction->shortDescription());
+
+ if ($transaction->type == Transaction::WALLET_DEBIT) {
+ $debitEntry = $selector;
+ } else {
+ $browser->assertMissing("$selector td.selection button");
+ }
+ }
+ });
+ });
+ }
+}
diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php
--- a/src/tests/Browser/SignupTest.php
+++ b/src/tests/Browser/SignupTest.php
@@ -5,6 +5,7 @@
use App\Discount;
use App\Domain;
use App\SignupCode;
+use App\SignupInvitation;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Menu;
@@ -28,11 +29,15 @@
$this->deleteTestDomain('user-domain-signup.com');
}
+ /**
+ * {@inheritDoc}
+ */
public function tearDown(): void
{
$this->deleteTestUser('signuptestdusk@' . \config('app.domain'));
$this->deleteTestUser('admin@user-domain-signup.com');
$this->deleteTestDomain('user-domain-signup.com');
+ SignupInvitation::truncate();
parent::tearDown();
}
@@ -292,17 +297,24 @@
// Here we expect 3 text inputs, Back and Continue buttons
$browser->with('@step3', function ($step) {
- $step->assertVisible('#signup_login');
- $step->assertVisible('#signup_password');
- $step->assertVisible('#signup_confirm');
- $step->assertVisible('select#signup_domain');
- $step->assertVisible('[type=button]');
- $step->assertVisible('[type=submit]');
- $step->assertFocused('#signup_login');
- $step->assertValue('select#signup_domain', \config('app.domain'));
- $step->assertValue('#signup_login', '');
- $step->assertValue('#signup_password', '');
- $step->assertValue('#signup_confirm', '');
+ $domains_count = count(Domain::getPublicDomains());
+
+ $step->assertSeeIn('.card-title', 'Sign Up - Step 3/3')
+ ->assertMissing('#signup_last_name')
+ ->assertMissing('#signup_first_name')
+ ->assertVisible('#signup_login')
+ ->assertVisible('#signup_password')
+ ->assertVisible('#signup_confirm')
+ ->assertVisible('select#signup_domain')
+ ->assertElementsCount('select#signup_domain option', $domains_count, false)
+ ->assertVisible('[type=button]')
+ ->assertVisible('[type=submit]')
+ ->assertSeeIn('[type=submit]', 'Submit')
+ ->assertFocused('#signup_login')
+ ->assertValue('select#signup_domain', \config('app.domain'))
+ ->assertValue('#signup_login', '')
+ ->assertValue('#signup_password', '')
+ ->assertValue('#signup_confirm', '');
// TODO: Test domain selector
});
@@ -540,4 +552,85 @@
$discount = Discount::where('code', 'TEST')->first();
$this->assertSame($discount->id, $user->wallets()->first()->discount_id);
}
+
+ /**
+ * Test signup via invitation link
+ */
+ public function testSignupInvitation(): void
+ {
+ // Test non-existing invitation
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/signup/invite/TEST')
+ ->onWithoutAssert(new Signup())
+ ->waitFor('#app > #error-page')
+ ->assertErrorPage(404);
+ });
+
+ $invitation = SignupInvitation::create(['email' => 'test@domain.org']);
+
+ $this->browse(function (Browser $browser) use ($invitation) {
+ $browser->visit('/signup/invite/' . $invitation->id)
+ ->onWithoutAssert(new Signup())
+ ->waitUntilMissing('.app-loader')
+ ->with('@step3', function ($step) {
+ $domains_count = count(Domain::getPublicDomains());
+
+ $step->assertMissing('.card-title')
+ ->assertVisible('#signup_last_name')
+ ->assertVisible('#signup_first_name')
+ ->assertVisible('#signup_login')
+ ->assertVisible('#signup_password')
+ ->assertVisible('#signup_confirm')
+ ->assertVisible('select#signup_domain')
+ ->assertElementsCount('select#signup_domain option', $domains_count, false)
+ ->assertVisible('[type=submit]')
+ ->assertMissing('[type=button]') // Back button
+ ->assertSeeIn('[type=submit]', 'Sign Up')
+ ->assertFocused('#signup_first_name')
+ ->assertValue('select#signup_domain', \config('app.domain'))
+ ->assertValue('#signup_first_name', '')
+ ->assertValue('#signup_last_name', '')
+ ->assertValue('#signup_login', '')
+ ->assertValue('#signup_password', '')
+ ->assertValue('#signup_confirm', '');
+
+ // Submit invalid data
+ $step->type('#signup_login', '*')
+ ->type('#signup_password', '12345678')
+ ->type('#signup_confirm', '123456789')
+ ->click('[type=submit]')
+ ->waitFor('#signup_login.is-invalid')
+ ->assertVisible('#signup_domain + .invalid-feedback')
+ ->assertVisible('#signup_password.is-invalid')
+ ->assertVisible('#signup_password + .invalid-feedback')
+ ->assertFocused('#signup_login')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error');
+
+ // Submit valid data
+ $step->type('#signup_confirm', '12345678')
+ ->type('#signup_login', 'signuptestdusk')
+ ->type('#signup_first_name', 'First')
+ ->type('#signup_last_name', 'Last')
+ ->click('[type=submit]');
+ })
+ // At this point we should be auto-logged-in to dashboard
+ ->waitUntilMissing('@step3')
+ ->waitUntilMissing('.app-loader')
+ ->on(new Dashboard())
+ ->assertUser('signuptestdusk@' . \config('app.domain'))
+ // Logout the user
+ ->within(new Menu(), function ($browser) {
+ $browser->clickMenuItem('logout');
+ });
+ });
+
+ $invitation->refresh();
+ $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first();
+
+ $this->assertTrue($invitation->isCompleted());
+ $this->assertSame($user->id, $invitation->user_id);
+ $this->assertSame('First', $user->getSetting('first_name'));
+ $this->assertSame('Last', $user->getSetting('last_name'));
+ $this->assertSame($invitation->email, $user->getSetting('external_email'));
+ }
}
diff --git a/src/tests/Feature/Console/DiscountListTest.php b/src/tests/Feature/Console/DiscountsTest.php
rename from src/tests/Feature/Console/DiscountListTest.php
rename to src/tests/Feature/Console/DiscountsTest.php
--- a/src/tests/Feature/Console/DiscountListTest.php
+++ b/src/tests/Feature/Console/DiscountsTest.php
@@ -4,11 +4,11 @@
use Tests\TestCase;
-class DiscountListTest extends TestCase
+class DiscountsTest extends TestCase
{
public function testHandle(): void
{
- $this->artisan('discount:list')
+ $this->artisan('discounts')
->assertExitCode(0);
$this->markTestIncomplete();
diff --git a/src/tests/Feature/Controller/Admin/DomainsTest.php b/src/tests/Feature/Controller/Admin/DomainsTest.php
--- a/src/tests/Feature/Controller/Admin/DomainsTest.php
+++ b/src/tests/Feature/Controller/Admin/DomainsTest.php
@@ -3,6 +3,8 @@
namespace Tests\Feature\Controller\Admin;
use App\Domain;
+use App\Entitlement;
+use App\Sku;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
@@ -30,6 +32,19 @@
}
/**
+ * Test domains confirming (not implemented)
+ */
+ public function testConfirm(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $domain = $this->getTestDomain('kolab.org');
+
+ // This end-point does not exist for admins
+ $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/confirm");
+ $response->assertStatus(404);
+ }
+
+ /**
* Test domains searching (/api/v4/domains)
*/
public function testIndex(): void
@@ -92,6 +107,55 @@
}
/**
+ * Test fetching domain info
+ */
+ public function testShow(): void
+ {
+ $sku_domain = Sku::where('title', 'domain-hosting')->first();
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $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
+ ]);
+
+ // Only admins can access it
+ $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->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']);
+ // Note: Other properties are being tested in the user controller tests
+ }
+
+ /**
+ * Test fetching domain status (GET /api/v4/domains/<domain-id>/status)
+ */
+ public function testStatus(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $domain = $this->getTestDomain('kolab.org');
+
+ // This end-point does not exist for admins
+ $response = $this->actingAs($admin)->get("/api/v4/domains/{$domain->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
* Test domain suspending (POST /api/v4/domains/<domain-id>/suspend)
*/
public function testSuspend(): void
diff --git a/src/tests/Feature/Controller/Admin/GroupsTest.php b/src/tests/Feature/Controller/Admin/GroupsTest.php
--- a/src/tests/Feature/Controller/Admin/GroupsTest.php
+++ b/src/tests/Feature/Controller/Admin/GroupsTest.php
@@ -94,6 +94,47 @@
}
/**
+ * Test fetching group info
+ */
+ public function testShow(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('test1@domainscontroller.com');
+ $group = $this->getTestGroup('group-test@kolab.org');
+ $group->assignToWallet($user->wallets->first());
+
+ // Only admins can access it
+ $response = $this->actingAs($user)->get("api/v4/groups/{$group->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/groups/{$group->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($group->id, $json['id']);
+ $this->assertEquals($group->email, $json['email']);
+ $this->assertEquals($group->status, $json['status']);
+ }
+
+ /**
+ * Test fetching domain status (GET /api/v4/domains/<domain-id>/status)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $group = $this->getTestGroup('group-test@kolab.org');
+ $group->assignToWallet($user->wallets->first());
+
+ // This end-point does not exist for admins
+ $response = $this->actingAs($admin)->get("/api/v4/groups/{$group->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
* Test group creating (POST /api/v4/groups)
*/
public function testStore(): void
diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Admin/SkusTest.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Tests\Feature\Controller\Admin;
+
+use App\Sku;
+use Tests\TestCase;
+
+class SkusTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useAdminUrl();
+
+ $this->clearBetaEntitlements();
+ $this->clearMeetEntitlements();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->clearBetaEntitlements();
+ $this->clearMeetEntitlements();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test fetching SKUs list
+ */
+ public function testIndex(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('john@kolab.org');
+ $sku = Sku::where('title', 'mailbox')->first();
+
+ // Unauth access not allowed
+ $response = $this->get("api/v4/skus");
+ $response->assertStatus(401);
+
+ // User access not allowed on admin API
+ $response = $this->actingAs($user)->get("api/v4/skus");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/skus");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(9, $json);
+
+ $this->assertSame(100, $json[0]['prio']);
+ $this->assertSame($sku->id, $json[0]['id']);
+ $this->assertSame($sku->title, $json[0]['title']);
+ $this->assertSame($sku->name, $json[0]['name']);
+ $this->assertSame($sku->description, $json[0]['description']);
+ $this->assertSame($sku->cost, $json[0]['cost']);
+ $this->assertSame($sku->units_free, $json[0]['units_free']);
+ $this->assertSame($sku->period, $json[0]['period']);
+ $this->assertSame($sku->active, $json[0]['active']);
+ $this->assertSame('user', $json[0]['type']);
+ $this->assertSame('mailbox', $json[0]['handler']);
+ }
+
+ /**
+ * Test fetching SKUs list for a user (GET /users/<id>/skus)
+ */
+ public function testUserSkus(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('john@kolab.org');
+
+ // Unauth access not allowed
+ $response = $this->get("api/v4/users/{$user->id}/skus");
+ $response->assertStatus(401);
+
+ // Non-admin access not allowed
+ $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(8, $json);
+ // Note: Details are tested where we test API\V4\SkusController
+ }
+}
diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php
--- a/src/tests/Feature/Controller/Admin/UsersTest.php
+++ b/src/tests/Feature/Controller/Admin/UsersTest.php
@@ -42,6 +42,24 @@
}
/**
+ * 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
@@ -248,6 +266,18 @@
}
/**
+ * 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
diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Admin/WalletsTest.php
--- a/src/tests/Feature/Controller/Admin/WalletsTest.php
+++ b/src/tests/Feature/Controller/Admin/WalletsTest.php
@@ -76,6 +76,10 @@
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$wallet = $user->wallets()->first();
$balance = $wallet->balance;
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $user->wallets()->first();
+ $reseller_wallet = $reseller->wallets()->first();
+ $reseller_balance = $reseller_wallet->balance;
Transaction::where('object_id', $wallet->id)
->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY])
@@ -109,6 +113,7 @@
$this->assertSame('The bonus has been added to the wallet successfully.', $json['message']);
$this->assertSame($balance += 5000, $json['balance']);
$this->assertSame($balance, $wallet->fresh()->balance);
+ $this->assertSame($reseller_balance, $reseller_wallet->fresh()->balance);
$transaction = Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_AWARD)->first();
@@ -128,6 +133,7 @@
$this->assertSame('The penalty has been added to the wallet successfully.', $json['message']);
$this->assertSame($balance -= 4000, $json['balance']);
$this->assertSame($balance, $wallet->fresh()->balance);
+ $this->assertSame($reseller_balance, $reseller_wallet->fresh()->balance);
$transaction = Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_PENALTY)->first();
diff --git a/src/tests/Feature/Controller/Reseller/DiscountsTest.php b/src/tests/Feature/Controller/Reseller/DiscountsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/DiscountsTest.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use App\Discount;
+use App\Tenant;
+use Tests\TestCase;
+
+class DiscountsTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $tenant = Tenant::where('title', 'Sample Tenant')->first();
+ $tenant->discounts()->delete();
+
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ \config(['app.tenant_id' => 1]);
+
+ $tenant = Tenant::where('title', 'Sample Tenant')->first();
+ $tenant->discounts()->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test listing discounts (/api/v4/discounts)
+ */
+ public function testIndex(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller = $this->getTestUser('reseller@reseller.com');
+ $reseller2 = $this->getTestUser('reseller@kolabnow.com');
+ $tenant = Tenant::where('title', 'Sample Tenant')->first();
+
+ \config(['app.tenant_id' => $tenant->id]);
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/discounts");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/discounts");
+ $response->assertStatus(403);
+
+ // Reseller user, but different tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/discounts");
+ $response->assertStatus(403);
+
+ // Reseller (empty list)
+ $response = $this->actingAs($reseller)->get("api/v4/discounts");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+
+ // Add some discounts
+ $discount_test = Discount::create([
+ 'description' => 'Test reseller voucher',
+ 'code' => 'RESELLER-TEST',
+ 'discount' => 10,
+ 'active' => true,
+ ]);
+
+ $discount_free = Discount::create([
+ 'description' => 'Free account',
+ 'discount' => 100,
+ 'active' => true,
+ ]);
+
+ $discount_test->tenant_id = $tenant->id;
+ $discount_test->save();
+ $discount_free->tenant_id = $tenant->id;
+ $discount_free->save();
+
+ $response = $this->actingAs($reseller)->get("api/v4/discounts");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertSame($discount_test->id, $json['list'][0]['id']);
+ $this->assertSame($discount_test->discount, $json['list'][0]['discount']);
+ $this->assertSame($discount_test->code, $json['list'][0]['code']);
+ $this->assertSame($discount_test->description, $json['list'][0]['description']);
+ $this->assertSame('10% - Test reseller voucher [RESELLER-TEST]', $json['list'][0]['label']);
+
+ $this->assertSame($discount_free->id, $json['list'][1]['id']);
+ $this->assertSame($discount_free->discount, $json['list'][1]['discount']);
+ $this->assertSame($discount_free->code, $json['list'][1]['code']);
+ $this->assertSame($discount_free->description, $json['list'][1]['description']);
+ $this->assertSame('100% - Free account', $json['list'][1]['label']);
+ }
+}
diff --git a/src/tests/Feature/Controller/Reseller/DomainsTest.php b/src/tests/Feature/Controller/Reseller/DomainsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/DomainsTest.php
@@ -0,0 +1,310 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use App\Domain;
+use App\Entitlement;
+use App\Sku;
+use App\Tenant;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DomainsTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ \config(['app.tenant_id' => 1]);
+
+ $this->deleteTestDomain('domainscontroller.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ \config(['app.tenant_id' => 1]);
+ $this->deleteTestDomain('domainscontroller.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test domain confirm request
+ */
+ public function testConfirm(): void
+ {
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $domain = $this->getTestDomain('domainscontroller.com', [
+ 'status' => Domain::STATUS_NEW,
+ 'type' => Domain::TYPE_EXTERNAL,
+ ]);
+
+ // THe end-point exists on the users controller, but not reseller's
+ $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}/confirm");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test domains searching (/api/v4/domains)
+ */
+ public function testIndex(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/domains");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/domains");
+ $response->assertStatus(403);
+
+ // Reseller from a different tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/domains");
+ $response->assertStatus(403);
+
+ // Search with no matches expected
+ $response = $this->actingAs($reseller1)->get("api/v4/domains?search=abcd12.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search by a domain name
+ $response = $this->actingAs($reseller1)->get("api/v4/domains?search=kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame('kolab.org', $json['list'][0]['namespace']);
+
+ // Search by owner
+
+ $response = $this->actingAs($reseller1)->get("api/v4/domains?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame('kolab.org', $json['list'][0]['namespace']);
+
+ // Search by owner (Ned is a controller on John's wallets,
+ // here we expect only domains assigned to Ned's wallet(s))
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $response = $this->actingAs($reseller1)->get("api/v4/domains?owner={$ned->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertCount(0, $json['list']);
+
+ // Test unauth access to other tenant's domains
+ \config(['app.tenant_id' => 2]);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/domains?search=kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/domains?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ }
+
+ /**
+ * Test fetching domain info
+ */
+ public function testShow(): void
+ {
+ $sku_domain = Sku::where('title', 'domain-hosting')->first();
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('test1@domainscontroller.com');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.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
+ ]);
+
+ // Unauthorized access (user)
+ $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}");
+ $response->assertStatus(403);
+
+ // Unauthorized access (admin)
+ $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}");
+ $response->assertStatus(403);
+
+ // Unauthorized access (tenant != env-tenant)
+ $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller1)->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']);
+ // Note: Other properties are being tested in the user controller tests
+
+ // Unauthorized access (other domain's tenant)
+ \config(['app.tenant_id' => 2]);
+ $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test fetching domain status (GET /api/v4/domains/<domain-id>/status)
+ */
+ public function testStatus(): void
+ {
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $domain = $this->getTestDomain('kolab.org');
+
+ // This end-point does not exist for resellers
+ $response = $this->actingAs($reseller1)->get("/api/v4/domains/{$domain->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test domain suspending (POST /api/v4/domains/<domain-id>/suspend)
+ */
+ public function testSuspend(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
+
+ \config(['app.tenant_id' => 2]);
+
+ $domain = $this->getTestDomain('domainscontroller.com', [
+ 'status' => Domain::STATUS_NEW,
+ 'type' => Domain::TYPE_EXTERNAL,
+ ]);
+ $user = $this->getTestUser('test@domainscontroller.com');
+
+ // Test unauthorized access to the reseller API (user)
+ $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/suspend", []);
+ $response->assertStatus(403);
+
+ $this->assertFalse($domain->fresh()->isSuspended());
+
+ // Test unauthorized access to the reseller API (admin)
+ $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/suspend", []);
+ $response->assertStatus(403);
+
+ $this->assertFalse($domain->fresh()->isSuspended());
+
+ // Test unauthorized access to the reseller API (reseller in another tenant)
+ $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/suspend", []);
+ $response->assertStatus(403);
+
+ $this->assertFalse($domain->fresh()->isSuspended());
+
+ // Test suspending the domain
+ $response = $this->actingAs($reseller2)->post("/api/v4/domains/{$domain->id}/suspend", []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Domain suspended successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ $this->assertTrue($domain->fresh()->isSuspended());
+
+ // Test authenticated reseller, but domain belongs to another tenant
+ \config(['app.tenant_id' => 1]);
+ $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/suspend", []);
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test user un-suspending (POST /api/v4/users/<user-id>/unsuspend)
+ */
+ public function testUnsuspend(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
+
+ \config(['app.tenant_id' => 2]);
+
+ $domain = $this->getTestDomain('domainscontroller.com', [
+ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED,
+ 'type' => Domain::TYPE_EXTERNAL,
+ ]);
+ $user = $this->getTestUser('test@domainscontroller.com');
+
+ // Test unauthorized access to reseller API (user)
+ $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/unsuspend", []);
+ $response->assertStatus(403);
+
+ $this->assertTrue($domain->fresh()->isSuspended());
+
+ // Test unauthorized access to reseller API (admin)
+ $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/unsuspend", []);
+ $response->assertStatus(403);
+
+ $this->assertTrue($domain->fresh()->isSuspended());
+
+ // Test unauthorized access to reseller API (another tenant)
+ $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/unsuspend", []);
+ $response->assertStatus(403);
+
+ $this->assertTrue($domain->fresh()->isSuspended());
+
+ // Test suspending the user
+ $response = $this->actingAs($reseller2)->post("/api/v4/domains/{$domain->id}/unsuspend", []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Domain unsuspended successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ $this->assertFalse($domain->fresh()->isSuspended());
+
+ // Test unauthorized access to reseller API (another tenant)
+ \config(['app.tenant_id' => 1]);
+ $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/unsuspend", []);
+ $response->assertStatus(404);
+ }
+}
diff --git a/src/tests/Feature/Controller/Admin/GroupsTest.php b/src/tests/Feature/Controller/Reseller/GroupsTest.php
copy from src/tests/Feature/Controller/Admin/GroupsTest.php
copy to src/tests/Feature/Controller/Reseller/GroupsTest.php
--- a/src/tests/Feature/Controller/Admin/GroupsTest.php
+++ b/src/tests/Feature/Controller/Reseller/GroupsTest.php
@@ -1,6 +1,6 @@
<?php
-namespace Tests\Feature\Controller\Admin;
+namespace Tests\Feature\Controller\Reseller;
use App\Group;
use Illuminate\Support\Facades\Queue;
@@ -14,7 +14,7 @@
public function setUp(): void
{
parent::setUp();
- self::useAdminUrl();
+ self::useResellerUrl();
$this->deleteTestGroup('group-test@kolab.org');
}
@@ -36,6 +36,8 @@
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
@@ -43,8 +45,16 @@
$response = $this->actingAs($user)->get("api/v4/groups");
$response->assertStatus(403);
- // Search with no search criteria
+ // Admin user
$response = $this->actingAs($admin)->get("api/v4/groups");
+ $response->assertStatus(403);
+
+ // Reseller from a different tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/groups");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($reseller1)->get("api/v4/groups");
$response->assertStatus(200);
$json = $response->json();
@@ -53,7 +63,7 @@
$this->assertSame([], $json['list']);
// Search with no matches expected
- $response = $this->actingAs($admin)->get("api/v4/groups?search=john@kolab.org");
+ $response = $this->actingAs($reseller1)->get("api/v4/groups?search=john@kolab.org");
$response->assertStatus(200);
$json = $response->json();
@@ -62,7 +72,7 @@
$this->assertSame([], $json['list']);
// Search by email
- $response = $this->actingAs($admin)->get("api/v4/groups?search={$group->email}");
+ $response = $this->actingAs($reseller1)->get("api/v4/groups?search={$group->email}");
$response->assertStatus(200);
$json = $response->json();
@@ -72,7 +82,7 @@
$this->assertSame($group->email, $json['list'][0]['email']);
// Search by owner
- $response = $this->actingAs($admin)->get("api/v4/groups?owner={$user->id}");
+ $response = $this->actingAs($reseller1)->get("api/v4/groups?owner={$user->id}");
$response->assertStatus(200);
$json = $response->json();
@@ -84,13 +94,82 @@
// Search by owner (Ned is a controller on John's wallets,
// here we expect only domains assigned to Ned's wallet(s))
$ned = $this->getTestUser('ned@kolab.org');
- $response = $this->actingAs($admin)->get("api/v4/groups?owner={$ned->id}");
+ $response = $this->actingAs($reseller1)->get("api/v4/groups?owner={$ned->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
+
+ // Test unauth access to other tenant's groups
+ \config(['app.tenant_id' => 2]);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/groups?search=kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/groups?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ }
+
+ /**
+ * Test fetching group info
+ */
+ public function testShow(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('test1@domainscontroller.com');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
+ $group = $this->getTestGroup('group-test@kolab.org');
+ $group->assignToWallet($user->wallets->first());
+
+ // Only resellers can access it
+ $response = $this->actingAs($user)->get("api/v4/groups/{$group->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/groups/{$group->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/groups/{$group->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller1)->get("api/v4/groups/{$group->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($group->id, $json['id']);
+ $this->assertEquals($group->email, $json['email']);
+ $this->assertEquals($group->status, $json['status']);
+ }
+
+ /**
+ * Test fetching group status (GET /api/v4/domains/<domain-id>/status)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $group = $this->getTestGroup('group-test@kolab.org');
+ $group->assignToWallet($user->wallets->first());
+
+ // This end-point does not exist for admins
+ $response = $this->actingAs($reseller1)->get("/api/v4/groups/{$group->id}/status");
+ $response->assertStatus(404);
}
/**
@@ -100,13 +179,17 @@
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
- // Test unauthorized access to admin API
+ // Test unauthorized access to reseller API
$response = $this->actingAs($user)->post("/api/v4/groups", []);
$response->assertStatus(403);
- // Admin can't create groups
+ // Reseller or admin can't create groups
$response = $this->actingAs($admin)->post("/api/v4/groups", []);
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller1)->post("/api/v4/groups", []);
$response->assertStatus(404);
}
@@ -119,21 +202,27 @@
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
- // Test unauthorized access to admin API
+ // Test unauthorized access to reseller API
$response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/suspend", []);
$response->assertStatus(403);
+ // Test unauthorized access to reseller API
+ $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", []);
+ $response->assertStatus(403);
+
// Test non-existing group ID
- $response = $this->actingAs($admin)->post("/api/v4/groups/abc/suspend", []);
+ $response = $this->actingAs($reseller1)->post("/api/v4/groups/abc/suspend", []);
$response->assertStatus(404);
$this->assertFalse($group->fresh()->isSuspended());
// Test suspending the group
- $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", []);
+ $response = $this->actingAs($reseller1)->post("/api/v4/groups/{$group->id}/suspend", []);
$response->assertStatus(200);
$json = $response->json();
@@ -143,6 +232,12 @@
$this->assertCount(2, $json);
$this->assertTrue($group->fresh()->isSuspended());
+
+ // Test unauth access to other tenant's groups
+ \config(['app.tenant_id' => 2]);
+
+ $response = $this->actingAs($reseller2)->post("/api/v4/groups/{$group->id}/suspend", []);
+ $response->assertStatus(404);
}
/**
@@ -154,23 +249,29 @@
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
$group->status |= Group::STATUS_SUSPENDED;
$group->save();
- // Test unauthorized access to admin API
+ // Test unauthorized access to reseller API
$response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/unsuspend", []);
$response->assertStatus(403);
+ // Test unauthorized access to reseller API
+ $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []);
+ $response->assertStatus(403);
+
// Invalid group ID
- $response = $this->actingAs($admin)->post("/api/v4/groups/abc/unsuspend", []);
+ $response = $this->actingAs($reseller1)->post("/api/v4/groups/abc/unsuspend", []);
$response->assertStatus(404);
$this->assertTrue($group->fresh()->isSuspended());
// Test suspending the group
- $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []);
+ $response = $this->actingAs($reseller1)->post("/api/v4/groups/{$group->id}/unsuspend", []);
$response->assertStatus(200);
$json = $response->json();
@@ -180,5 +281,11 @@
$this->assertCount(2, $json);
$this->assertFalse($group->fresh()->isSuspended());
+
+ // Test unauth access to other tenant's groups
+ \config(['app.tenant_id' => 2]);
+
+ $response = $this->actingAs($reseller2)->post("/api/v4/groups/{$group->id}/unsuspend", []);
+ $response->assertStatus(404);
}
}
diff --git a/src/tests/Feature/Controller/Reseller/InvitationsTest.php b/src/tests/Feature/Controller/Reseller/InvitationsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/InvitationsTest.php
@@ -0,0 +1,350 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use App\SignupInvitation;
+use App\Tenant;
+use Illuminate\Http\Testing\File;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class InvitationsTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ SignupInvitation::truncate();
+
+ \config(['app.tenant_id' => 1]);
+
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ SignupInvitation::truncate();
+
+ \config(['app.tenant_id' => 1]);
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test deleting invitations (DELETE /api/v4/invitations/<id>)
+ */
+ public function testDestroy(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller = $this->getTestUser('reseller@reseller.com');
+ $reseller2 = $this->getTestUser('reseller@kolabnow.com');
+ $tenant = Tenant::where('title', 'Sample Tenant')->first();
+
+ \config(['app.tenant_id' => $tenant->id]);
+
+ $inv = SignupInvitation::create(['email' => 'email1@ext.com']);
+
+ // Non-admin user
+ $response = $this->actingAs($user)->delete("api/v4/invitations/{$inv->id}");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->delete("api/v4/invitations/{$inv->id}");
+ $response->assertStatus(403);
+
+ // Reseller user, but different tenant
+ $response = $this->actingAs($reseller2)->delete("api/v4/invitations/{$inv->id}");
+ $response->assertStatus(403);
+
+ // Reseller - non-existing invitation identifier
+ $response = $this->actingAs($reseller)->delete("api/v4/invitations/abd");
+ $response->assertStatus(404);
+
+ // Reseller - existing invitation
+ $response = $this->actingAs($reseller)->delete("api/v4/invitations/{$inv->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Invitation deleted successfully.", $json['message']);
+ $this->assertSame(null, SignupInvitation::find($inv->id));
+ }
+
+ /**
+ * Test listing invitations (GET /api/v4/invitations)
+ */
+ public function testIndex(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller = $this->getTestUser('reseller@reseller.com');
+ $reseller2 = $this->getTestUser('reseller@kolabnow.com');
+ $tenant = Tenant::where('title', 'Sample Tenant')->first();
+
+ \config(['app.tenant_id' => $tenant->id]);
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/invitations");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/invitations");
+ $response->assertStatus(403);
+
+ // Reseller user, but different tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/invitations");
+ $response->assertStatus(403);
+
+ // Reseller (empty list)
+ $response = $this->actingAs($reseller)->get("api/v4/invitations");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ $this->assertSame(1, $json['page']);
+ $this->assertFalse($json['hasMore']);
+
+ // Add some invitations
+ $i1 = SignupInvitation::create(['email' => 'email1@ext.com']);
+ $i2 = SignupInvitation::create(['email' => 'email2@ext.com']);
+ $i3 = SignupInvitation::create(['email' => 'email3@ext.com']);
+ $i4 = SignupInvitation::create(['email' => 'email4@other.com']);
+ $i5 = SignupInvitation::create(['email' => 'email5@other.com']);
+ $i6 = SignupInvitation::create(['email' => 'email6@other.com']);
+ $i7 = SignupInvitation::create(['email' => 'email7@other.com']);
+ $i8 = SignupInvitation::create(['email' => 'email8@other.com']);
+ $i9 = SignupInvitation::create(['email' => 'email9@other.com']);
+ $i10 = SignupInvitation::create(['email' => 'email10@other.com']);
+ $i11 = SignupInvitation::create(['email' => 'email11@other.com']);
+ $i12 = SignupInvitation::create(['email' => 'email12@test.com']);
+ $i13 = SignupInvitation::create(['email' => 'email13@ext.com']);
+
+ SignupInvitation::query()->update(['created_at' => now()->subDays('1')]);
+ SignupInvitation::where('id', $i1->id)
+ ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]);
+ SignupInvitation::where('id', $i2->id)
+ ->update(['created_at' => now()->subHours('3'), 'status' => SignupInvitation::STATUS_SENT]);
+ SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays('3')]);
+ SignupInvitation::where('id', $i12->id)->update(['tenant_id' => 1]);
+ SignupInvitation::where('id', $i13->id)->update(['tenant_id' => 1]);
+
+ $response = $this->actingAs($reseller)->get("api/v4/invitations");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(10, $json['count']);
+ $this->assertSame(1, $json['page']);
+ $this->assertTrue($json['hasMore']);
+ $this->assertSame($i1->id, $json['list'][0]['id']);
+ $this->assertSame($i1->email, $json['list'][0]['email']);
+ $this->assertSame(true, $json['list'][0]['isFailed']);
+ $this->assertSame(false, $json['list'][0]['isNew']);
+ $this->assertSame(false, $json['list'][0]['isSent']);
+ $this->assertSame(false, $json['list'][0]['isCompleted']);
+ $this->assertSame($i2->id, $json['list'][1]['id']);
+ $this->assertSame($i2->email, $json['list'][1]['email']);
+ $this->assertFalse(in_array($i12->email, array_column($json['list'], 'email')));
+ $this->assertFalse(in_array($i13->email, array_column($json['list'], 'email')));
+
+ $response = $this->actingAs($reseller)->get("api/v4/invitations?page=2");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertSame(2, $json['page']);
+ $this->assertFalse($json['hasMore']);
+ $this->assertSame($i11->id, $json['list'][0]['id']);
+
+ // Test searching (email address)
+ $response = $this->actingAs($reseller)->get("api/v4/invitations?search=email3@ext.com");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertSame(1, $json['page']);
+ $this->assertFalse($json['hasMore']);
+ $this->assertSame($i3->id, $json['list'][0]['id']);
+
+ // Test searching (domain)
+ $response = $this->actingAs($reseller)->get("api/v4/invitations?search=ext.com");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(3, $json['count']);
+ $this->assertSame(1, $json['page']);
+ $this->assertFalse($json['hasMore']);
+ $this->assertSame($i1->id, $json['list'][0]['id']);
+ }
+
+ /**
+ * Test resending invitations (POST /api/v4/invitations/<id>/resend)
+ */
+ public function testResend(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller = $this->getTestUser('reseller@reseller.com');
+ $reseller2 = $this->getTestUser('reseller@kolabnow.com');
+ $tenant = Tenant::where('title', 'Sample Tenant')->first();
+
+ \config(['app.tenant_id' => $tenant->id]);
+
+ $inv = SignupInvitation::create(['email' => 'email1@ext.com']);
+ SignupInvitation::where('id', $inv->id)->update(['status' => SignupInvitation::STATUS_FAILED]);
+
+ // Non-admin user
+ $response = $this->actingAs($user)->post("api/v4/invitations/{$inv->id}/resend");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->post("api/v4/invitations/{$inv->id}/resend");
+ $response->assertStatus(403);
+
+ // Reseller user, but different tenant
+ $response = $this->actingAs($reseller2)->post("api/v4/invitations/{$inv->id}/resend");
+ $response->assertStatus(403);
+
+ // Reseller - non-existing invitation identifier
+ $response = $this->actingAs($reseller)->post("api/v4/invitations/abd/resend");
+ $response->assertStatus(404);
+
+ // Reseller - existing invitation
+ $response = $this->actingAs($reseller)->post("api/v4/invitations/{$inv->id}/resend");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Invitation added to the sending queue successfully.", $json['message']);
+ $this->assertTrue($inv->fresh()->isNew());
+ }
+
+ /**
+ * Test creating invitations (POST /api/v4/invitations)
+ */
+ public function testStore(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller = $this->getTestUser('reseller@reseller.com');
+ $reseller2 = $this->getTestUser('reseller@kolabnow.com');
+ $tenant = Tenant::where('title', 'Sample Tenant')->first();
+
+ \config(['app.tenant_id' => $tenant->id]);
+
+ // Non-admin user
+ $response = $this->actingAs($user)->post("api/v4/invitations", []);
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->post("api/v4/invitations", []);
+ $response->assertStatus(403);
+
+ // Reseller user, but different tenant
+ $response = $this->actingAs($reseller2)->post("api/v4/invitations", []);
+ $response->assertStatus(403);
+
+ // Reseller (empty post)
+ $response = $this->actingAs($reseller)->post("api/v4/invitations", []);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame("The email field is required.", $json['errors']['email'][0]);
+
+ // Invalid email address
+ $post = ['email' => 'test'];
+ $response = $this->actingAs($reseller)->post("api/v4/invitations", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame("The email must be a valid email address.", $json['errors']['email'][0]);
+
+ // Valid email address
+ $post = ['email' => 'test@external.org'];
+ $response = $this->actingAs($reseller)->post("api/v4/invitations", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("The invitation has been created.", $json['message']);
+ $this->assertSame(1, $json['count']);
+ $this->assertSame(1, SignupInvitation::count());
+
+ // Test file input (empty file)
+ $tmpfile = tmpfile();
+ fwrite($tmpfile, "");
+ $file = new File('test.csv', $tmpfile);
+ $post = ['file' => $file];
+ $response = $this->actingAs($reseller)->post("api/v4/invitations", $post);
+
+ fclose($tmpfile);
+
+ $response->assertStatus(422);
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Failed to find any valid email addresses in the uploaded file.", $json['errors']['file']);
+
+ // Test file input with an invalid email address
+ $tmpfile = tmpfile();
+ fwrite($tmpfile, "t1@domain.tld\r\nt2@domain");
+ $file = new File('test.csv', $tmpfile);
+ $post = ['file' => $file];
+ $response = $this->actingAs($reseller)->post("api/v4/invitations", $post);
+
+ fclose($tmpfile);
+
+ $response->assertStatus(422);
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Found an invalid email address (t2@domain) on line 2.", $json['errors']['file']);
+
+ // Test file input (two addresses)
+ $tmpfile = tmpfile();
+ fwrite($tmpfile, "t1@domain.tld\r\nt2@domain.tld");
+ $file = new File('test.csv', $tmpfile);
+ $post = ['file' => $file];
+ $response = $this->actingAs($reseller)->post("api/v4/invitations", $post);
+
+ fclose($tmpfile);
+
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertSame(1, SignupInvitation::where('email', 't1@domain.tld')->count());
+ $this->assertSame(1, SignupInvitation::where('email', 't2@domain.tld')->count());
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("2 invitations has been created.", $json['message']);
+ $this->assertSame(2, $json['count']);
+ }
+}
diff --git a/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php
@@ -0,0 +1,257 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use App\Http\Controllers\API\V4\Reseller\PaymentsController;
+use App\Payment;
+use App\Providers\PaymentProvider;
+use App\Transaction;
+use App\Wallet;
+use App\WalletSetting;
+use GuzzleHttp\Psr7\Response;
+use Illuminate\Support\Facades\Bus;
+use Tests\TestCase;
+use Tests\BrowserAddonTrait;
+use Tests\MollieMocksTrait;
+
+class PaymentsMollieTest extends TestCase
+{
+ use MollieMocksTrait;
+ use BrowserAddonTrait;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ // All tests in this file use Mollie
+ \config(['services.payment_provider' => 'mollie']);
+
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $reseller->wallets()->first();
+ Payment::where('wallet_id', $wallet->id)->delete();
+ Wallet::where('id', $wallet->id)->update(['balance' => 0]);
+ WalletSetting::where('wallet_id', $wallet->id)->delete();
+ Transaction::where('object_id', $wallet->id)->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $reseller->wallets()->first();
+ Payment::where('wallet_id', $wallet->id)->delete();
+ Wallet::where('id', $wallet->id)->update(['balance' => 0]);
+ WalletSetting::where('wallet_id', $wallet->id)->delete();
+ Transaction::where('object_id', $wallet->id)->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test creating/updating/deleting an outo-payment mandate
+ *
+ * @group mollie
+ */
+ public function testMandates(): void
+ {
+ // Unauth access not allowed
+ $response = $this->get("api/v4/payments/mandate");
+ $response->assertStatus(401);
+ $response = $this->post("api/v4/payments/mandate", []);
+ $response->assertStatus(401);
+ $response = $this->put("api/v4/payments/mandate", []);
+ $response->assertStatus(401);
+ $response = $this->delete("api/v4/payments/mandate");
+ $response->assertStatus(401);
+
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $reseller->wallets()->first();
+ $wallet->balance = -10;
+ $wallet->save();
+
+ // Test creating a mandate (valid input)
+ $post = ['amount' => 20.10, 'balance' => 0];
+ $response = $this->actingAs($reseller)->post("api/v4/payments/mandate", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']);
+
+ // Assert the proper payment amount has been used
+ $payment = Payment::where('id', $json['id'])->first();
+
+ $this->assertSame(2010, $payment->amount);
+ $this->assertSame($wallet->id, $payment->wallet_id);
+ $this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description);
+ $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
+
+ // Test fetching the mandate information
+ $response = $this->actingAs($reseller)->get("api/v4/payments/mandate");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals(20.10, $json['amount']);
+ $this->assertEquals(0, $json['balance']);
+ $this->assertEquals('Credit Card', $json['method']);
+ $this->assertSame(true, $json['isPending']);
+ $this->assertSame(false, $json['isValid']);
+ $this->assertSame(false, $json['isDisabled']);
+
+ $mandate_id = $json['id'];
+
+ // We would have to invoke a browser to accept the "first payment" to make
+ // the mandate validated/completed. Instead, we'll mock the mandate object.
+ $mollie_response = [
+ 'resource' => 'mandate',
+ 'id' => $mandate_id,
+ 'status' => 'valid',
+ 'method' => 'creditcard',
+ 'details' => [
+ 'cardNumber' => '4242',
+ 'cardLabel' => 'Visa',
+ ],
+ 'customerId' => 'cst_GMfxGPt7Gj',
+ 'createdAt' => '2020-04-28T11:09:47+00:00',
+ ];
+
+ $responseStack = $this->mockMollie();
+ $responseStack->append(new Response(200, [], json_encode($mollie_response)));
+
+ $wallet = $reseller->wallets()->first();
+ $wallet->setSetting('mandate_disabled', 1);
+
+ $response = $this->actingAs($reseller)->get("api/v4/payments/mandate");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals(20.10, $json['amount']);
+ $this->assertEquals(0, $json['balance']);
+ $this->assertEquals('Visa (**** **** **** 4242)', $json['method']);
+ $this->assertSame(false, $json['isPending']);
+ $this->assertSame(true, $json['isValid']);
+ $this->assertSame(true, $json['isDisabled']);
+
+ Bus::fake();
+ $wallet->setSetting('mandate_disabled', null);
+ $wallet->balance = 1000;
+ $wallet->save();
+
+ // Test updating a mandate (valid input)
+ $responseStack->append(new Response(200, [], json_encode($mollie_response)));
+
+ $post = ['amount' => 30.10, 'balance' => 10];
+ $response = $this->actingAs($reseller)->put("api/v4/payments/mandate", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The auto-payment has been updated.', $json['message']);
+ $this->assertSame($mandate_id, $json['id']);
+ $this->assertFalse($json['isDisabled']);
+
+ $wallet->refresh();
+
+ $this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
+ $this->assertEquals(10, $wallet->getSetting('mandate_balance'));
+
+ Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0);
+
+ $this->unmockMollie();
+
+ // Delete mandate
+ $response = $this->actingAs($reseller)->delete("api/v4/payments/mandate");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The auto-payment has been removed.', $json['message']);
+ }
+
+ /**
+ * Test creating a payment
+ *
+ * @group mollie
+ */
+ public function testStore(): void
+ {
+ Bus::fake();
+
+ // Unauth access not allowed
+ $response = $this->post("api/v4/payments", []);
+ $response->assertStatus(401);
+
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+
+ // Successful payment
+ $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
+ $response = $this->actingAs($reseller)->post("api/v4/payments", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']);
+ }
+
+ /**
+ * Test listing a pending payment
+ *
+ * @group mollie
+ */
+ public function testListingPayments(): void
+ {
+ Bus::fake();
+
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+
+ // Empty response
+ $response = $this->actingAs($reseller)->get("api/v4/payments/pending");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame(0, $json['count']);
+ $this->assertSame(1, $json['page']);
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertCount(0, $json['list']);
+
+ $response = $this->actingAs($reseller)->get("api/v4/payments/has-pending");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(false, $json['hasPending']);
+ }
+
+ /**
+ * Test listing payment methods
+ *
+ * @group mollie
+ */
+ public function testListingPaymentMethods(): void
+ {
+ Bus::fake();
+
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+
+ $response = $this->actingAs($reseller)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF);
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('creditcard', $json[0]['id']);
+ $this->assertSame('paypal', $json[1]['id']);
+ }
+}
diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/SkusTest.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use App\Sku;
+use Tests\TestCase;
+
+class SkusTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+
+ \config(['app.tenant_id' => 1]);
+
+ $this->clearBetaEntitlements();
+ $this->clearMeetEntitlements();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ \config(['app.tenant_id' => 1]);
+
+ $this->clearBetaEntitlements();
+ $this->clearMeetEntitlements();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test fetching SKUs list
+ */
+ public function testIndex(): void
+ {
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('john@kolab.org');
+ $sku = Sku::where('title', 'mailbox')->first();
+
+ // Unauth access not allowed
+ $response = $this->get("api/v4/skus");
+ $response->assertStatus(401);
+
+ // User access not allowed on admin API
+ $response = $this->actingAs($user)->get("api/v4/skus");
+ $response->assertStatus(403);
+
+ // Admin access not allowed
+ $response = $this->actingAs($admin)->get("api/v4/skus");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller1)->get("api/v4/skus");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(9, $json);
+
+ $this->assertSame(100, $json[0]['prio']);
+ $this->assertSame($sku->id, $json[0]['id']);
+ $this->assertSame($sku->title, $json[0]['title']);
+ $this->assertSame($sku->name, $json[0]['name']);
+ $this->assertSame($sku->description, $json[0]['description']);
+ $this->assertSame($sku->cost, $json[0]['cost']);
+ $this->assertSame($sku->units_free, $json[0]['units_free']);
+ $this->assertSame($sku->period, $json[0]['period']);
+ $this->assertSame($sku->active, $json[0]['active']);
+ $this->assertSame('user', $json[0]['type']);
+ $this->assertSame('mailbox', $json[0]['handler']);
+
+ // TODO: Test limiting SKUs to the tenant's SKUs
+ }
+
+ /**
+ * Test fetching SKUs list for a user (GET /users/<id>/skus)
+ */
+ public function testUserSkus(): void
+ {
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('john@kolab.org');
+
+ // Unauth access not allowed
+ $response = $this->get("api/v4/users/{$user->id}/skus");
+ $response->assertStatus(401);
+
+ // User access not allowed
+ $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
+ $response->assertStatus(403);
+
+ // Admin access not allowed
+ $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus");
+ $response->assertStatus(403);
+
+ // Reseller from another tenant not allowed
+ $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus");
+ $response->assertStatus(403);
+
+ // Reseller access
+ $response = $this->actingAs($reseller1)->get("api/v4/users/{$user->id}/skus");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(8, $json);
+ // Note: Details are tested where we test API\V4\SkusController
+
+ // Reseller from another tenant not allowed
+ \config(['app.tenant_id' => 2]);
+ $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus");
+ $response->assertStatus(404);
+ }
+}
diff --git a/src/tests/Feature/Controller/Reseller/StatsTest.php b/src/tests/Feature/Controller/Reseller/StatsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/StatsTest.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use Tests\TestCase;
+
+class StatsTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test charts (GET /api/v4/stats/chart/<chart>)
+ */
+ public function testChart(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+
+ // Unauth access
+ $response = $this->get("api/v4/stats/chart/discounts");
+ $response->assertStatus(401);
+
+ // Normal user
+ $response = $this->actingAs($user)->get("api/v4/stats/chart/discounts");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/stats/chart/discounts");
+ $response->assertStatus(403);
+
+ // Unknown chart name
+ $response = $this->actingAs($reseller)->get("api/v4/stats/chart/unknown");
+ $response->assertStatus(404);
+
+ // 'income' chart
+ $response = $this->actingAs($reseller)->get("api/v4/stats/chart/income");
+ $response->assertStatus(404);
+
+ // 'discounts' chart
+ $response = $this->actingAs($reseller)->get("api/v4/stats/chart/discounts");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('Discounts', $json['title']);
+ $this->assertSame('donut', $json['type']);
+ $this->assertSame([], $json['data']['labels']);
+ $this->assertSame([['values' => []]], $json['data']['datasets']);
+
+ // 'users' chart
+ $response = $this->actingAs($reseller)->get("api/v4/stats/chart/users");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('Users - last 8 weeks', $json['title']);
+ $this->assertCount(8, $json['data']['labels']);
+ $this->assertSame(date('Y-W'), $json['data']['labels'][7]);
+ $this->assertCount(2, $json['data']['datasets']);
+ $this->assertSame('Created', $json['data']['datasets'][0]['name']);
+ $this->assertSame('Deleted', $json['data']['datasets'][1]['name']);
+
+ // 'users-all' chart
+ $response = $this->actingAs($reseller)->get("api/v4/stats/chart/users-all");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('All Users - last year', $json['title']);
+ $this->assertCount(54, $json['data']['labels']);
+ $this->assertCount(1, $json['data']['datasets']);
+ }
+}
diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Reseller/UsersTest.php
copy from src/tests/Feature/Controller/Admin/UsersTest.php
copy to src/tests/Feature/Controller/Reseller/UsersTest.php
--- a/src/tests/Feature/Controller/Admin/UsersTest.php
+++ b/src/tests/Feature/Controller/Reseller/UsersTest.php
@@ -1,9 +1,10 @@
<?php
-namespace Tests\Feature\Controller\Admin;
+namespace Tests\Feature\Controller\Reseller;
-use App\Auth\SecondFactor;
+use App\Tenant;
use App\Sku;
+use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
@@ -15,15 +16,12 @@
public function setUp(): void
{
parent::setUp();
- self::useAdminUrl();
+ self::useResellerUrl();
+ \config(['app.tenant_id' => 1]);
$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);
}
/**
@@ -35,28 +33,60 @@
$this->deleteTestUser('test@testsearch.com');
$this->deleteTestDomain('testsearch.com');
- $jack = $this->getTestUser('jack@kolab.org');
- $jack->setSetting('external_email', null);
+ \config(['app.tenant_id' => 1]);
parent::tearDown();
}
/**
+ * Test user deleting (DELETE /api/v4/users/<id>)
+ */
+ public function testDestroy(): void
+ {
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+
+ // Test unauth access
+ $response = $this->delete("api/v4/users/{$user->id}");
+ $response->assertStatus(401);
+
+ // The end-point does not exist
+ $response = $this->actingAs($reseller1)->delete("api/v4/users/{$user->id}");
+ $response->assertStatus(404);
+ }
+
+ /**
* Test users searching (/api/v4/users)
*/
public function testIndex(): void
{
+ Queue::fake();
+
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
- $group = $this->getTestGroup('group-test@kolab.org');
- $group->assignToWallet($user->wallets->first());
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
+
+ \config(['app.tenant_id' => 2]);
- // Non-admin user
+ // Guess access
+ $response = $this->get("api/v4/users");
+ $response->assertStatus(401);
+
+ // Normal user
$response = $this->actingAs($user)->get("api/v4/users");
$response->assertStatus(403);
- // Search with no search criteria
+ // Admin user
$response = $this->actingAs($admin)->get("api/v4/users");
+ $response->assertStatus(403);
+
+ // Reseller from another tenant
+ $response = $this->actingAs($reseller1)->get("api/v4/users");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($reseller2)->get("api/v4/users");
$response->assertStatus(200);
$json = $response->json();
@@ -65,7 +95,7 @@
$this->assertSame([], $json['list']);
// Search with no matches expected
- $response = $this->actingAs($admin)->get("api/v4/users?search=abcd1234efgh5678");
+ $response = $this->actingAs($reseller2)->get("api/v4/users?search=abcd1234efgh5678");
$response->assertStatus(200);
$json = $response->json();
@@ -73,8 +103,56 @@
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
+ // Search by domain in another tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/users?search=kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search by user ID in another tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/users?search={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search by email (primary) - existing user in another tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/users?search=john@kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search by owner - existing user in another tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/users?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Create a domain with some users in the Sample Tenant so we have anything to search for
+ $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]);
+ $domain->tenant_id = 2;
+ $domain->save();
+ $user = $this->getTestUser('test@testsearch.com');
+ $user->tenant_id = 2;
+ $user->save();
+ $plan = \App\Plan::where('title', 'group')->first();
+ $user->assignPlan($plan, $domain);
+ $user->setAliases(['alias@testsearch.com']);
+ $user->setSetting('external_email', 'john.doe.external@gmail.com');
+
// Search by domain
- $response = $this->actingAs($admin)->get("api/v4/users?search=kolab.org");
+ $response = $this->actingAs($reseller2)->get("api/v4/users?search=testsearch.com");
$response->assertStatus(200);
$json = $response->json();
@@ -85,7 +163,7 @@
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by user ID
- $response = $this->actingAs($admin)->get("api/v4/users?search={$user->id}");
+ $response = $this->actingAs($reseller2)->get("api/v4/users?search={$user->id}");
$response->assertStatus(200);
$json = $response->json();
@@ -95,8 +173,8 @@
$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");
+ // Search by email (primary) - existing user in reseller's tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/users?search=test@testsearch.com");
$response->assertStatus(200);
$json = $response->json();
@@ -107,7 +185,7 @@
$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 = $this->actingAs($reseller2)->get("api/v4/users?search=alias@testsearch.com");
$response->assertStatus(200);
$json = $response->json();
@@ -117,45 +195,20 @@
$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");
+ // Search by email (external), there are two users with this email, but only one
+ // in the reseller's tenant
+ $response = $this->actingAs($reseller2)->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);
+ $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 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 = $this->actingAs($reseller2)->get("api/v4/users?owner={$user->id}");
$response->assertStatus(200);
$json = $response->json();
@@ -166,15 +219,9 @@
$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 = $this->actingAs($reseller2)->get("api/v4/users?search=test@testsearch.com");
$response->assertStatus(200);
$json = $response->json();
@@ -185,7 +232,7 @@
$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 = $this->actingAs($reseller2)->get("api/v4/users?search=alias@testsearch.com");
$response->assertStatus(200);
$json = $response->json();
@@ -196,7 +243,7 @@
$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 = $this->actingAs($reseller2)->get("api/v4/users?search=testsearch.com");
$response->assertStatus(200);
$json = $response->json();
@@ -215,23 +262,35 @@
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
- $sku2fa = Sku::firstOrCreate(['title' => '2fa']);
+ $sku2fa = \App\Sku::firstOrCreate(['title' => '2fa']);
$user->assignSku($sku2fa);
- SecondFactor::seed('userscontrollertest1@userscontroller.com');
+ \App\Auth\SecondFactor::seed('userscontrollertest1@userscontroller.com');
- // Test unauthorized access to admin API
+ // Test unauthorized access
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []);
$response->assertStatus(403);
+ $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []);
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/reset2FA", []);
+ $response->assertStatus(403);
+
+ // Touching admins is forbidden
+ $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/reset2FA", []);
+ $response->assertStatus(404);
+
$entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get();
$this->assertCount(1, $entitlements);
- $sf = new SecondFactor($user);
+ $sf = new \App\Auth\SecondFactor($user);
$this->assertCount(1, $sf->factors());
// Test reseting 2FA
- $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []);
+ $response = $this->actingAs($reseller1)->post("/api/v4/users/{$user->id}/reset2FA", []);
$response->assertStatus(200);
$json = $response->json();
@@ -243,8 +302,25 @@
$entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get();
$this->assertCount(0, $entitlements);
- $sf = new SecondFactor($user);
+ $sf = new \App\Auth\SecondFactor($user);
$this->assertCount(0, $sf->factors());
+
+ // Other tenant's user
+ \config(['app.tenant_id' => 2]);
+ $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/reset2FA", []);
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test user creation (POST /api/v4/users)
+ */
+ public function testStore(): void
+ {
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+
+ // The end-point does not exist
+ $response = $this->actingAs($reseller1)->post("/api/v4/users", []);
+ $response->assertStatus(404);
}
/**
@@ -256,15 +332,26 @@
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
- // Test unauthorized access to admin API
+ // Test unauthorized access
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/suspend", []);
$response->assertStatus(403);
+ $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []);
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/suspend", []);
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/suspend", []);
+ $response->assertStatus(404);
+
$this->assertFalse($user->isSuspended());
// Test suspending the user
- $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []);
+ $response = $this->actingAs($reseller1)->post("/api/v4/users/{$user->id}/suspend", []);
$response->assertStatus(200);
$json = $response->json();
@@ -274,6 +361,11 @@
$this->assertCount(2, $json);
$this->assertTrue($user->fresh()->isSuspended());
+
+ // Access to other tenant's users
+ \config(['app.tenant_id' => 2]);
+ $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/suspend", []);
+ $response->assertStatus(404);
}
/**
@@ -285,17 +377,28 @@
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/unsuspend", []);
$response->assertStatus(403);
+ $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []);
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/unsuspend", []);
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/unsuspend", []);
+ $response->assertStatus(404);
+
$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 = $this->actingAs($reseller1)->post("/api/v4/users/{$user->id}/unsuspend", []);
$response->assertStatus(200);
$json = $response->json();
@@ -305,6 +408,11 @@
$this->assertCount(2, $json);
$this->assertFalse($user->fresh()->isSuspended());
+
+ // Access to other tenant's users
+ \config(['app.tenant_id' => 2]);
+ $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/unsuspend", []);
+ $response->assertStatus(404);
}
/**
@@ -314,13 +422,24 @@
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
- // Test unauthorized access to admin API
+ // Test unauthorized access
$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(403);
+
+ $response = $this->actingAs($reseller2)->put("/api/v4/users/{$user->id}", []);
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller1)->put("/api/v4/users/{$admin->id}", []);
+ $response->assertStatus(404);
+
+ // Test updatig the user data (empty data)
+ $response = $this->actingAs($reseller1)->put("/api/v4/users/{$user->id}", []);
$response->assertStatus(200);
$json = $response->json();
@@ -331,7 +450,7 @@
// Test error handling
$post = ['external_email' => 'aaa'];
- $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post);
+ $response = $this->actingAs($reseller1)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(422);
$json = $response->json();
@@ -342,7 +461,7 @@
// Test real update
$post = ['external_email' => 'modified@test.com'];
- $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post);
+ $response = $this->actingAs($reseller1)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(200);
$json = $response->json();
@@ -351,5 +470,10 @@
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertSame('modified@test.com', $user->getSetting('external_email'));
+
+ // Access to other tenant's users
+ \config(['app.tenant_id' => 2]);
+ $response = $this->actingAs($reseller2)->put("/api/v4/users/{$user->id}", $post);
+ $response->assertStatus(404);
}
}
diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Reseller/WalletsTest.php
copy from src/tests/Feature/Controller/Admin/WalletsTest.php
copy to src/tests/Feature/Controller/Reseller/WalletsTest.php
--- a/src/tests/Feature/Controller/Admin/WalletsTest.php
+++ b/src/tests/Feature/Controller/Reseller/WalletsTest.php
@@ -1,6 +1,6 @@
<?php
-namespace Tests\Feature\Controller\Admin;
+namespace Tests\Feature\Controller\Reseller;
use App\Discount;
use App\Transaction;
@@ -14,7 +14,8 @@
public function setUp(): void
{
parent::setUp();
- self::useAdminUrl();
+ self::useResellerUrl();
+ \config(['app.tenant_id' => 1]);
}
/**
@@ -22,6 +23,7 @@
*/
public function tearDown(): void
{
+ \config(['app.tenant_id' => 1]);
parent::tearDown();
}
@@ -36,6 +38,8 @@
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
$wallet = $user->wallets()->first();
$wallet->discount_id = null;
$wallet->save();
@@ -52,6 +56,14 @@
// Admin user
$response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}");
+ $response->assertStatus(403);
+
+ // Reseller from a different tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}");
+ $response->assertStatus(403);
+
+ // Reseller
+ $response = $this->actingAs($reseller1)->get("api/v4/wallets/{$wallet->id}");
$response->assertStatus(200);
$json = $response->json();
@@ -65,6 +77,12 @@
$this->assertTrue(!empty($json['provider']));
$this->assertTrue(empty($json['providerLink']));
$this->assertTrue(!empty($json['mandate']));
+ $this->assertTrue(!empty($json['notice']));
+
+ // Reseller from a different tenant
+ \config(['app.tenant_id' => 2]);
+ $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}");
+ $response->assertStatus(404);
}
/**
@@ -74,20 +92,33 @@
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
$wallet = $user->wallets()->first();
+ $reseller1_wallet = $reseller1->wallets()->first();
$balance = $wallet->balance;
+ $reseller1_balance = $reseller1_wallet->balance;
Transaction::where('object_id', $wallet->id)
->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY])
->delete();
+ Transaction::where('object_id', $reseller1_wallet->id)->delete();
// Non-admin user
$response = $this->actingAs($user)->post("api/v4/wallets/{$wallet->id}/one-off", []);
$response->assertStatus(403);
+ // Admin user
+ $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", []);
+ $response->assertStatus(403);
+
+ // Reseller from a different tenant
+ $response = $this->actingAs($reseller2)->post("api/v4/wallets/{$wallet->id}/one-off", []);
+ $response->assertStatus(403);
+
// Admin user - invalid input
$post = ['amount' => 'aaaa'];
- $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
+ $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
$response->assertStatus(422);
$json = $response->json();
@@ -98,9 +129,9 @@
$this->assertCount(2, $json);
$this->assertCount(2, $json['errors']);
- // Admin user - a valid bonus
+ // A valid bonus
$post = ['amount' => '50', 'description' => 'A bonus'];
- $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
+ $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
$response->assertStatus(200);
$json = $response->json();
@@ -109,17 +140,25 @@
$this->assertSame('The bonus has been added to the wallet successfully.', $json['message']);
$this->assertSame($balance += 5000, $json['balance']);
$this->assertSame($balance, $wallet->fresh()->balance);
+ $this->assertSame($reseller1_balance -= 5000, $reseller1_wallet->fresh()->balance);
$transaction = Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_AWARD)->first();
$this->assertSame($post['description'], $transaction->description);
$this->assertSame(5000, $transaction->amount);
- $this->assertSame($admin->email, $transaction->user_email);
+ $this->assertSame($reseller1->email, $transaction->user_email);
+
+ $transaction = Transaction::where('object_id', $reseller1_wallet->id)
+ ->where('type', Transaction::WALLET_DEBIT)->first();
+
+ $this->assertSame("Awarded user {$user->email}", $transaction->description);
+ $this->assertSame(-5000, $transaction->amount);
+ $this->assertSame($reseller1->email, $transaction->user_email);
- // Admin user - a valid penalty
+ // A valid penalty
$post = ['amount' => '-40', 'description' => 'A penalty'];
- $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
+ $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
$response->assertStatus(200);
$json = $response->json();
@@ -128,13 +167,26 @@
$this->assertSame('The penalty has been added to the wallet successfully.', $json['message']);
$this->assertSame($balance -= 4000, $json['balance']);
$this->assertSame($balance, $wallet->fresh()->balance);
+ $this->assertSame($reseller1_balance += 4000, $reseller1_wallet->fresh()->balance);
$transaction = Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_PENALTY)->first();
$this->assertSame($post['description'], $transaction->description);
$this->assertSame(-4000, $transaction->amount);
- $this->assertSame($admin->email, $transaction->user_email);
+ $this->assertSame($reseller1->email, $transaction->user_email);
+
+ $transaction = Transaction::where('object_id', $reseller1_wallet->id)
+ ->where('type', Transaction::WALLET_CREDIT)->first();
+
+ $this->assertSame("Penalized user {$user->email}", $transaction->description);
+ $this->assertSame(4000, $transaction->amount);
+ $this->assertSame($reseller1->email, $transaction->user_email);
+
+ // Reseller from a different tenant
+ \config(['app.tenant_id' => 2]);
+ $response = $this->actingAs($reseller2)->post("api/v4/wallets/{$wallet->id}/one-off", []);
+ $response->assertStatus(404);
}
/**
@@ -145,22 +197,33 @@
// Note: Here we're testing only that the end-point works,
// and admin can get the transaction log, response details
// are tested in Feature/Controller/WalletsTest.php
+
$this->deleteTestUser('wallets-controller@kolabnow.com');
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$wallet = $user->wallets()->first();
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
// Non-admin
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(403);
+ // Admin
+ $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}/transactions");
+ $response->assertStatus(403);
+
+ // Reseller from a different tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}/transactions");
+ $response->assertStatus(403);
+
// Create some sample transactions
$transactions = $this->createTestTransactions($wallet);
$transactions = array_reverse($transactions);
$pages = array_chunk($transactions, 10 /* page size*/);
// Get the 2nd page
- $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}/transactions?page=2");
+ $response = $this->actingAs($reseller1)->get("api/v4/wallets/{$wallet->id}/transactions?page=2");
$response->assertStatus(200);
$json = $response->json();
@@ -178,8 +241,14 @@
$this->assertFalse($json['list'][$idx]['hasDetails']);
}
- // The 'user' key is set only on the admin end-point
+ // The 'user' key is set only on the admin/reseller end-point
+ // FIXME: Should we hide this for resellers?
$this->assertSame('jeroen@jeroen.jeroen', $json['list'][1]['user']);
+
+ // Reseller from a different tenant
+ \config(['app.tenant_id' => 2]);
+ $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}/transactions");
+ $response->assertStatus(403);
}
/**
@@ -187,8 +256,10 @@
*/
public function testUpdate(): void
{
- $user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
+ $user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$discount = Discount::where('code', 'TEST')->first();
@@ -196,9 +267,17 @@
$response = $this->actingAs($user)->put("api/v4/wallets/{$wallet->id}", []);
$response->assertStatus(403);
+ // Admin
+ $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", []);
+ $response->assertStatus(403);
+
+ // Reseller from another tenant
+ $response = $this->actingAs($reseller2)->put("api/v4/wallets/{$wallet->id}", []);
+ $response->assertStatus(403);
+
// Admin user - setting a discount
$post = ['discount' => $discount->id];
- $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post);
+ $response = $this->actingAs($reseller1)->put("api/v4/wallets/{$wallet->id}", $post);
$response->assertStatus(200);
$json = $response->json();
@@ -213,7 +292,7 @@
// Admin user - removing a discount
$post = ['discount' => null];
- $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post);
+ $response = $this->actingAs($reseller1)->put("api/v4/wallets/{$wallet->id}", $post);
$response->assertStatus(200);
$json = $response->json();
@@ -224,5 +303,10 @@
$this->assertSame(null, $json['discount_id']);
$this->assertTrue(empty($json['discount_description']));
$this->assertSame(null, $wallet->fresh()->discount);
+
+ // Reseller from a different tenant
+ \config(['app.tenant_id' => 2]);
+ $response = $this->actingAs($reseller2)->put("api/v4/wallets/{$wallet->id}", []);
+ $response->assertStatus(404);
}
}
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -6,6 +6,7 @@
use App\Discount;
use App\Domain;
use App\SignupCode;
+use App\SignupInvitation as SI;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
@@ -28,11 +29,13 @@
$this->deleteTestUser("SignupControllerTest1@$this->domain");
$this->deleteTestUser("signuplogin@$this->domain");
$this->deleteTestUser("admin@external.com");
+ $this->deleteTestUser("test-inv@kolabnow.com");
$this->deleteTestDomain('external.com');
$this->deleteTestDomain('signup-domain.com');
$this->deleteTestGroup('group-test@kolabnow.com');
+ SI::truncate();
}
/**
@@ -43,11 +46,13 @@
$this->deleteTestUser("SignupControllerTest1@$this->domain");
$this->deleteTestUser("signuplogin@$this->domain");
$this->deleteTestUser("admin@external.com");
+ $this->deleteTestUser("test-inv@kolabnow.com");
$this->deleteTestDomain('external.com');
$this->deleteTestDomain('signup-domain.com');
$this->deleteTestGroup('group-test@kolabnow.com');
+ SI::truncate();
parent::tearDown();
}
@@ -77,10 +82,8 @@
/**
* Test fetching plans for signup
- *
- * @return void
*/
- public function testSignupPlans()
+ public function testSignupPlans(): void
{
$response = $this->get('/api/auth/signup/plans');
$json = $response->json();
@@ -96,11 +99,36 @@
}
/**
+ * Test fetching invitation
+ */
+ public function testSignupInvitations(): void
+ {
+ Queue::fake();
+
+ $invitation = SI::create(['email' => 'email1@ext.com']);
+
+ // Test existing invitation
+ $response = $this->get("/api/auth/signup/invitations/{$invitation->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($invitation->id, $json['id']);
+
+ // Test non-existing invitation
+ $response = $this->get("/api/auth/signup/invitations/abc");
+ $response->assertStatus(404);
+
+ // Test completed invitation
+ SI::where('id', $invitation->id)->update(['status' => SI::STATUS_COMPLETED]);
+ $response = $this->get("/api/auth/signup/invitations/{$invitation->id}");
+ $response->assertStatus(404);
+ }
+
+ /**
* Test signup initialization with invalid input
- *
- * @return void
*/
- public function testSignupInitInvalidInput()
+ public function testSignupInitInvalidInput(): void
{
// Empty input data
$data = [];
@@ -167,10 +195,8 @@
/**
* Test signup initialization with valid input
- *
- * @return array
*/
- public function testSignupInitValidInput()
+ public function testSignupInitValidInput(): array
{
Queue::fake();
@@ -243,9 +269,8 @@
* Test signup code verification with invalid input
*
* @depends testSignupInitValidInput
- * @return void
*/
- public function testSignupVerifyInvalidInput(array $result)
+ public function testSignupVerifyInvalidInput(array $result): void
{
// Empty data
$data = [];
@@ -293,10 +318,8 @@
* Test signup code verification with valid input
*
* @depends testSignupInitValidInput
- *
- * @return array
*/
- public function testSignupVerifyValidInput(array $result)
+ public function testSignupVerifyValidInput(array $result): array
{
$code = SignupCode::find($result['code']);
$data = [
@@ -324,9 +347,8 @@
* Test last signup step with invalid input
*
* @depends testSignupVerifyValidInput
- * @return void
*/
- public function testSignupInvalidInput(array $result)
+ public function testSignupInvalidInput(array $result): void
{
// Empty data
$data = [];
@@ -456,9 +478,8 @@
* Test last signup step with valid input (user creation)
*
* @depends testSignupVerifyValidInput
- * @return void
*/
- public function testSignupValidInput(array $result)
+ public function testSignupValidInput(array $result): void
{
$queue = Queue::fake();
@@ -520,10 +541,8 @@
/**
* Test signup for a group (custom domain) account
- *
- * @return void
*/
- public function testSignupGroupAccount()
+ public function testSignupGroupAccount(): void
{
Queue::fake();
@@ -642,6 +661,59 @@
}
/**
+ * Test signup via invitation
+ */
+ public function testSignupViaInvitation(): void
+ {
+ Queue::fake();
+
+ $invitation = SI::create(['email' => 'email1@ext.com']);
+
+ $post = [
+ 'invitation' => 'abc',
+ 'first_name' => 'Signup',
+ 'last_name' => 'User',
+ 'login' => 'test-inv',
+ 'domain' => 'kolabnow.com',
+ 'password' => 'test',
+ 'password_confirmation' => 'test',
+ ];
+
+ // Test invalid invitation identifier
+ $response = $this->post('/api/auth/signup', $post);
+ $response->assertStatus(404);
+
+ // Test valid input
+ $post['invitation'] = $invitation->id;
+ $response = $this->post('/api/auth/signup', $post);
+ $result = $response->json();
+
+ $response->assertStatus(200);
+ $this->assertSame('success', $result['status']);
+ $this->assertSame('bearer', $result['token_type']);
+ $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0);
+ $this->assertNotEmpty($result['access_token']);
+ $this->assertSame('test-inv@kolabnow.com', $result['email']);
+
+ // Check if the user has been created
+ $user = User::where('email', 'test-inv@kolabnow.com')->first();
+
+ $this->assertNotEmpty($user);
+
+ // Check user settings
+ $this->assertSame($invitation->email, $user->getSetting('external_email'));
+ $this->assertSame($post['first_name'], $user->getSetting('first_name'));
+ $this->assertSame($post['last_name'], $user->getSetting('last_name'));
+
+ $invitation->refresh();
+
+ $this->assertSame($user->id, $invitation->user_id);
+ $this->assertTrue($invitation->isCompleted());
+
+ // TODO: Test POST params validation
+ }
+
+ /**
* List of login/domain validation cases for testValidateLogin()
*
* @return array Arguments for testValidateLogin()
diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php
--- a/src/tests/Feature/DomainTest.php
+++ b/src/tests/Feature/DomainTest.php
@@ -123,12 +123,18 @@
$public_domains = Domain::getPublicDomains();
$this->assertNotContains('public-active.com', $public_domains);
- $domain = Domain::where('namespace', 'public-active.com')->first();
$domain->type = Domain::TYPE_PUBLIC;
$domain->save();
$public_domains = Domain::getPublicDomains();
$this->assertContains('public-active.com', $public_domains);
+
+ // Domains of other tenants should not be returned
+ $domain->tenant_id = 2;
+ $domain->save();
+
+ $public_domains = Domain::getPublicDomains();
+ $this->assertNotContains('public-active.com', $public_domains);
}
/**
diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php
--- a/src/tests/Feature/EntitlementTest.php
+++ b/src/tests/Feature/EntitlementTest.php
@@ -82,7 +82,6 @@
);
$domain->assignPackage($packageDomain, $owner);
-
$owner->assignPackage($packageKolab);
$owner->assignPackage($packageKolab, $user);
@@ -128,57 +127,4 @@
$this->assertEquals($user->id, $entitlement->entitleable->id);
$this->assertTrue($entitlement->entitleable instanceof \App\User);
}
-
- /**
- * @todo This really should be in User or Wallet tests file
- */
- public function testBillDeletedEntitlement(): void
- {
- $user = $this->getTestUser('entitlement-test@kolabnow.com');
- $package = \App\Package::where('title', 'kolab')->first();
-
- $storage = \App\Sku::where('title', 'storage')->first();
-
- $user->assignPackage($package);
- // some additional SKUs so we have something to delete.
- $user->assignSku($storage, 4);
-
- // the mailbox, the groupware, the 2 original storage and the additional 4
- $this->assertCount(8, $user->fresh()->entitlements);
-
- $wallet = $user->wallets()->first();
-
- $backdate = Carbon::now()->subWeeks(7);
- $this->backdateEntitlements($user->entitlements, $backdate);
-
- $charge = $wallet->chargeEntitlements();
-
- $this->assertSame(-1099, $wallet->balance);
-
- $balance = $wallet->balance;
- $discount = \App\Discount::where('discount', 30)->first();
- $wallet->discount()->associate($discount);
- $wallet->save();
-
- $user->removeSku($storage, 4);
-
- // we expect the wallet to have been charged for ~3 weeks of use of
- // 4 deleted storage entitlements, it should also take discount into account
- $backdate->addMonthsWithoutOverflow(1);
- $diffInDays = $backdate->diffInDays(Carbon::now());
-
- // entitlements-num * cost * discount * days-in-month
- $max = intval(4 * 25 * 0.7 * $diffInDays / 28);
- $min = intval(4 * 25 * 0.7 * $diffInDays / 31);
-
- $wallet->refresh();
- $this->assertTrue($wallet->balance >= $balance - $max);
- $this->assertTrue($wallet->balance <= $balance - $min);
-
- $transactions = \App\Transaction::where('object_id', $wallet->id)
- ->where('object_type', \App\Wallet::class)->get();
-
- // one round of the monthly invoicing, four sku deletions getting invoiced
- $this->assertCount(5, $transactions);
- }
}
diff --git a/src/tests/Feature/Jobs/SignupInvitationEmailTest.php b/src/tests/Feature/Jobs/SignupInvitationEmailTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/SignupInvitationEmailTest.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Tests\Feature\Jobs;
+
+use App\Jobs\SignupInvitationEmail;
+use App\Mail\SignupInvitation;
+use App\SignupInvitation as SI;
+use Illuminate\Queue\Events\JobFailed;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SignupInvitationEmailTest extends TestCase
+{
+ private $invitation;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ Queue::fake();
+
+ $this->invitation = SI::create(['email' => 'SignupInvitationEmailTest@external.com']);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->invitation->delete();
+ }
+
+ /**
+ * Test job handle
+ */
+ public function testSignupInvitationEmailHandle(): void
+ {
+ Mail::fake();
+
+ // Assert that no jobs were pushed...
+ Mail::assertNothingSent();
+
+ $job = new SignupInvitationEmail($this->invitation);
+ $job->handle();
+
+ // Assert the email sending job was pushed once
+ Mail::assertSent(SignupInvitation::class, 1);
+
+ // Assert the mail was sent to the code's email
+ Mail::assertSent(SignupInvitation::class, function ($mail) {
+ return $mail->hasTo($this->invitation->email);
+ });
+
+ $this->assertTrue($this->invitation->isSent());
+ }
+
+ /**
+ * Test job failure handling
+ */
+ public function testSignupInvitationEmailFailure(): void
+ {
+ $this->markTestIncomplete();
+ }
+}
diff --git a/src/tests/Feature/PlanTest.php b/src/tests/Feature/PlanTest.php
--- a/src/tests/Feature/PlanTest.php
+++ b/src/tests/Feature/PlanTest.php
@@ -106,4 +106,19 @@
$this->assertTrue($plan->cost() == $package_costs);
}
+
+ public function testTenant(): void
+ {
+ $plan = Plan::where('title', 'individual')->first();
+
+ $tenant = $plan->tenant()->first();
+
+ $this->assertInstanceof(\App\Tenant::class, $tenant);
+ $this->assertSame(1, $tenant->id);
+
+ $tenant = $plan->tenant;
+
+ $this->assertInstanceof(\App\Tenant::class, $tenant);
+ $this->assertSame(1, $tenant->id);
+ }
}
diff --git a/src/tests/Feature/SignupInvitationTest.php b/src/tests/Feature/SignupInvitationTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/SignupInvitationTest.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\SignupInvitation as SI;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SignupInvitationTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ SI::truncate();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ SI::truncate();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test SignupInvitation creation
+ */
+ public function testCreate(): void
+ {
+ Queue::fake();
+
+ $invitation = SI::create(['email' => 'test@domain.org']);
+
+ $this->assertSame('test@domain.org', $invitation->email);
+ $this->assertSame(SI::STATUS_NEW, $invitation->status);
+ $this->assertSame(\config('app.tenant_id'), $invitation->tenant_id);
+ $this->assertTrue(preg_match('/^[a-f0-9-]{36}$/', $invitation->id) > 0);
+
+ Queue::assertPushed(\App\Jobs\SignupInvitationEmail::class, 1);
+
+ Queue::assertPushed(
+ \App\Jobs\SignupInvitationEmail::class,
+ function ($job) use ($invitation) {
+ $inv = TestCase::getObjectProperty($job, 'invitation');
+
+ return $inv->id === $invitation->id && $inv->email === $invitation->email;
+ }
+ );
+
+ $inst = SI::find($invitation->id);
+
+ $this->assertInstanceOf(SI::class, $inst);
+ $this->assertSame($inst->id, $invitation->id);
+ $this->assertSame($inst->email, $invitation->email);
+ }
+
+ /**
+ * Test SignupInvitation update
+ */
+ public function testUpdate(): void
+ {
+ Queue::fake();
+
+ $invitation = SI::create(['email' => 'test@domain.org']);
+
+ Queue::fake();
+
+ // Test that these status changes do not dispatch the email sending job
+ foreach ([SI::STATUS_FAILED, SI::STATUS_SENT, SI::STATUS_COMPLETED, SI::STATUS_NEW] as $status) {
+ $invitation->status = $status;
+ $invitation->save();
+ }
+
+ Queue::assertNothingPushed();
+
+ // SENT -> NEW should resend the invitation
+ SI::where('id', $invitation->id)->update(['status' => SI::STATUS_SENT]);
+ $invitation->refresh();
+ $invitation->status = SI::STATUS_NEW;
+ $invitation->save();
+
+ Queue::assertPushed(\App\Jobs\SignupInvitationEmail::class, 1);
+
+ Queue::assertPushed(
+ \App\Jobs\SignupInvitationEmail::class,
+ function ($job) use ($invitation) {
+ $inv = TestCase::getObjectProperty($job, 'invitation');
+
+ return $inv->id === $invitation->id && $inv->email === $invitation->email;
+ }
+ );
+
+ Queue::fake();
+
+ // FAILED -> NEW should resend the invitation
+ SI::where('id', $invitation->id)->update(['status' => SI::STATUS_FAILED]);
+ $invitation->refresh();
+ $invitation->status = SI::STATUS_NEW;
+ $invitation->save();
+
+ Queue::assertPushed(\App\Jobs\SignupInvitationEmail::class, 1);
+
+ Queue::assertPushed(
+ \App\Jobs\SignupInvitationEmail::class,
+ function ($job) use ($invitation) {
+ $inv = TestCase::getObjectProperty($job, 'invitation');
+
+ return $inv->id === $invitation->id && $inv->email === $invitation->email;
+ }
+ );
+ }
+}
diff --git a/src/tests/Feature/SkuTest.php b/src/tests/Feature/SkuTest.php
--- a/src/tests/Feature/SkuTest.php
+++ b/src/tests/Feature/SkuTest.php
@@ -91,4 +91,19 @@
$entitlement->entitleable_type
);
}
+
+ public function testSkuTenant(): void
+ {
+ $sku = Sku::where('title', 'storage')->first();
+
+ $tenant = $sku->tenant()->first();
+
+ $this->assertInstanceof(\App\Tenant::class, $tenant);
+ $this->assertSame(1, $tenant->id);
+
+ $tenant = $sku->tenant;
+
+ $this->assertInstanceof(\App\Tenant::class, $tenant);
+ $this->assertSame(1, $tenant->id);
+ }
}
diff --git a/src/tests/Feature/TenantTest.php b/src/tests/Feature/TenantTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/TenantTest.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Tenant;
+use Tests\TestCase;
+
+class TenantTest extends TestCase
+{
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test Tenant::wallet() method
+ */
+ public function testWallet(): void
+ {
+ $tenant = Tenant::find(1);
+ $user = \App\User::where('email', 'reseller@kolabnow.com')->first();
+
+ $wallet = $tenant->wallet();
+
+ $this->assertInstanceof(\App\Wallet::class, $wallet);
+ $this->assertSame($user->wallets->first()->id, $wallet->id);
+ }
+}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -84,14 +84,144 @@
$this->markTestIncomplete();
}
+ /**
+ * Test User::canRead() method
+ */
public function testCanRead(): void
{
- $this->markTestIncomplete();
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $domain = $this->getTestDomain('kolab.org');
+
+ // Admin
+ $this->assertTrue($admin->canRead($admin));
+ $this->assertTrue($admin->canRead($john));
+ $this->assertTrue($admin->canRead($jack));
+ $this->assertTrue($admin->canRead($reseller1));
+ $this->assertTrue($admin->canRead($reseller2));
+ $this->assertTrue($admin->canRead($domain));
+ $this->assertTrue($admin->canRead($domain->wallet()));
+
+ // Reseller - kolabnow
+ $this->assertTrue($reseller1->canRead($john));
+ $this->assertTrue($reseller1->canRead($jack));
+ $this->assertTrue($reseller1->canRead($reseller1));
+ $this->assertTrue($reseller1->canRead($domain));
+ $this->assertTrue($reseller1->canRead($domain->wallet()));
+ $this->assertFalse($reseller1->canRead($reseller2));
+ $this->assertFalse($reseller1->canRead($admin));
+
+ // Reseller - different tenant
+ $this->assertTrue($reseller2->canRead($reseller2));
+ $this->assertFalse($reseller2->canRead($john));
+ $this->assertFalse($reseller2->canRead($jack));
+ $this->assertFalse($reseller2->canRead($reseller1));
+ $this->assertFalse($reseller2->canRead($domain));
+ $this->assertFalse($reseller2->canRead($domain->wallet()));
+ $this->assertFalse($reseller2->canRead($admin));
+
+ // Normal user - account owner
+ $this->assertTrue($john->canRead($john));
+ $this->assertTrue($john->canRead($ned));
+ $this->assertTrue($john->canRead($jack));
+ $this->assertTrue($john->canRead($domain));
+ $this->assertTrue($john->canRead($domain->wallet()));
+ $this->assertFalse($john->canRead($reseller1));
+ $this->assertFalse($john->canRead($reseller2));
+ $this->assertFalse($john->canRead($admin));
+
+ // Normal user - a non-owner and non-controller
+ $this->assertTrue($jack->canRead($jack));
+ $this->assertFalse($jack->canRead($john));
+ $this->assertFalse($jack->canRead($domain));
+ $this->assertFalse($jack->canRead($domain->wallet()));
+ $this->assertFalse($jack->canRead($reseller1));
+ $this->assertFalse($jack->canRead($reseller2));
+ $this->assertFalse($jack->canRead($admin));
+
+ // Normal user - John's wallet controller
+ $this->assertTrue($ned->canRead($ned));
+ $this->assertTrue($ned->canRead($john));
+ $this->assertTrue($ned->canRead($jack));
+ $this->assertTrue($ned->canRead($domain));
+ $this->assertTrue($ned->canRead($domain->wallet()));
+ $this->assertFalse($ned->canRead($reseller1));
+ $this->assertFalse($ned->canRead($reseller2));
+ $this->assertFalse($ned->canRead($admin));
}
+ /**
+ * Test User::canUpdate() method
+ */
public function testCanUpdate(): void
{
- $this->markTestIncomplete();
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $reseller1 = $this->getTestUser('reseller@kolabnow.com');
+ $reseller2 = $this->getTestUser('reseller@reseller.com');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $domain = $this->getTestDomain('kolab.org');
+
+ // Admin
+ $this->assertTrue($admin->canUpdate($admin));
+ $this->assertTrue($admin->canUpdate($john));
+ $this->assertTrue($admin->canUpdate($jack));
+ $this->assertTrue($admin->canUpdate($reseller1));
+ $this->assertTrue($admin->canUpdate($reseller2));
+ $this->assertTrue($admin->canUpdate($domain));
+ $this->assertTrue($admin->canUpdate($domain->wallet()));
+
+ // Reseller - kolabnow
+ $this->assertTrue($reseller1->canUpdate($john));
+ $this->assertTrue($reseller1->canUpdate($jack));
+ $this->assertTrue($reseller1->canUpdate($reseller1));
+ $this->assertTrue($reseller1->canUpdate($domain));
+ $this->assertTrue($reseller1->canUpdate($domain->wallet()));
+ $this->assertFalse($reseller1->canUpdate($reseller2));
+ $this->assertFalse($reseller1->canUpdate($admin));
+
+ // Reseller - different tenant
+ $this->assertTrue($reseller2->canUpdate($reseller2));
+ $this->assertFalse($reseller2->canUpdate($john));
+ $this->assertFalse($reseller2->canUpdate($jack));
+ $this->assertFalse($reseller2->canUpdate($reseller1));
+ $this->assertFalse($reseller2->canUpdate($domain));
+ $this->assertFalse($reseller2->canUpdate($domain->wallet()));
+ $this->assertFalse($reseller2->canUpdate($admin));
+
+ // Normal user - account owner
+ $this->assertTrue($john->canUpdate($john));
+ $this->assertTrue($john->canUpdate($ned));
+ $this->assertTrue($john->canUpdate($jack));
+ $this->assertTrue($john->canUpdate($domain));
+ $this->assertFalse($john->canUpdate($domain->wallet()));
+ $this->assertFalse($john->canUpdate($reseller1));
+ $this->assertFalse($john->canUpdate($reseller2));
+ $this->assertFalse($john->canUpdate($admin));
+
+ // Normal user - a non-owner and non-controller
+ $this->assertTrue($jack->canUpdate($jack));
+ $this->assertFalse($jack->canUpdate($john));
+ $this->assertFalse($jack->canUpdate($domain));
+ $this->assertFalse($jack->canUpdate($domain->wallet()));
+ $this->assertFalse($jack->canUpdate($reseller1));
+ $this->assertFalse($jack->canUpdate($reseller2));
+ $this->assertFalse($jack->canUpdate($admin));
+
+ // Normal user - John's wallet controller
+ $this->assertTrue($ned->canUpdate($ned));
+ $this->assertTrue($ned->canUpdate($john));
+ $this->assertTrue($ned->canUpdate($jack));
+ $this->assertTrue($ned->canUpdate($domain));
+ $this->assertFalse($ned->canUpdate($domain->wallet()));
+ $this->assertFalse($ned->canUpdate($reseller1));
+ $this->assertFalse($ned->canUpdate($reseller2));
+ $this->assertFalse($ned->canUpdate($admin));
}
/**
@@ -167,26 +297,32 @@
public function testDomains(): void
{
$user = $this->getTestUser('john@kolab.org');
- $domains = [];
+ $domain = $this->getTestDomain('useraccount.com', [
+ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE,
+ 'type' => Domain::TYPE_PUBLIC,
+ ]);
- foreach ($user->domains() as $domain) {
- $domains[] = $domain->namespace;
- }
+ $domains = collect($user->domains())->pluck('namespace')->all();
- $this->assertContains(\config('app.domain'), $domains);
+ $this->assertContains($domain->namespace, $domains);
$this->assertContains('kolab.org', $domains);
// Jack is not the wallet controller, so for him the list should not
// include John's domains, kolab.org specifically
$user = $this->getTestUser('jack@kolab.org');
- $domains = [];
- foreach ($user->domains() as $domain) {
- $domains[] = $domain->namespace;
- }
+ $domains = collect($user->domains())->pluck('namespace')->all();
- $this->assertContains(\config('app.domain'), $domains);
+ $this->assertContains($domain->namespace, $domains);
$this->assertNotContains('kolab.org', $domains);
+
+ // Public domains of other tenants should not be returned
+ $domain->tenant_id = 2;
+ $domain->save();
+
+ $domains = collect($user->domains())->pluck('namespace')->all();
+
+ $this->assertNotContains($domain->namespace, $domains);
}
public function testUserQuota(): void
@@ -335,6 +471,51 @@
}
/**
+ * Test handling negative balance on user deletion
+ */
+ public function testDeleteWithNegativeBalance(): void
+ {
+ $user = $this->getTestUser('user-test@' . \config('app.domain'));
+ $wallet = $user->wallets()->first();
+ $wallet->balance = -1000;
+ $wallet->save();
+ $reseller_wallet = $user->tenant->wallet();
+ $reseller_wallet->balance = 0;
+ $reseller_wallet->save();
+ \App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete();
+
+ $user->delete();
+
+ $reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id)
+ ->where('object_type', \App\Wallet::class)->get();
+
+ $this->assertSame(-1000, $reseller_wallet->fresh()->balance);
+ $this->assertCount(1, $reseller_transactions);
+ $trans = $reseller_transactions[0];
+ $this->assertSame("Deleted user {$user->email}", $trans->description);
+ $this->assertSame(-1000, $trans->amount);
+ $this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type);
+ }
+
+ /**
+ * Test handling positive balance on user deletion
+ */
+ public function testDeleteWithPositiveBalance(): void
+ {
+ $user = $this->getTestUser('user-test@' . \config('app.domain'));
+ $wallet = $user->wallets()->first();
+ $wallet->balance = 1000;
+ $wallet->save();
+ $reseller_wallet = $user->tenant->wallet();
+ $reseller_wallet->balance = 0;
+ $reseller_wallet->save();
+
+ $user->delete();
+
+ $this->assertSame(0, $reseller_wallet->fresh()->balance);
+ }
+
+ /**
* Tests for User::aliasExists()
*/
public function testAliasExists(): void
diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php
--- a/src/tests/Feature/WalletTest.php
+++ b/src/tests/Feature/WalletTest.php
@@ -5,8 +5,10 @@
use App\Package;
use App\User;
use App\Sku;
+use App\Transaction;
use App\Wallet;
use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class WalletTest extends TestCase
@@ -39,6 +41,8 @@
$this->deleteTestUser($user);
}
+ Sku::select()->update(['fee' => 0]);
+
parent::tearDown();
}
@@ -279,4 +283,116 @@
$this->assertCount(0, $userB->accounts);
}
+
+ /**
+ * Test for charging and removing entitlements (including tenant commission calculations)
+ */
+ public function testChargeAndDeleteEntitlements(): void
+ {
+ $user = $this->getTestUser('jane@kolabnow.com');
+ $wallet = $user->wallets()->first();
+ $discount = \App\Discount::where('discount', 30)->first();
+ $wallet->discount()->associate($discount);
+ $wallet->save();
+
+ // Add 40% fee to all SKUs
+ Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]);
+
+ $package = Package::where('title', 'kolab')->first();
+ $storage = Sku::where('title', 'storage')->first();
+ $user->assignPackage($package);
+ $user->assignSku($storage, 2);
+ $user->refresh();
+
+ // Reset reseller's wallet balance and transactions
+ $reseller_wallet = $user->tenant->wallet();
+ $reseller_wallet->balance = 0;
+ $reseller_wallet->save();
+ Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete();
+
+ // ------------------------------------
+ // Test normal charging of entitlements
+ // ------------------------------------
+
+ // Backdate and chanrge entitlements, we're expecting one month to be charged
+ // Set fake NOW date to make simpler asserting results that depend on number of days in current/last month
+ Carbon::setTestNow(Carbon::create(2021, 5, 21, 12));
+ $backdate = Carbon::now()->subWeeks(7);
+ $this->backdateEntitlements($user->entitlements, $backdate);
+ $charge = $wallet->chargeEntitlements();
+ $wallet->refresh();
+ $reseller_wallet->refresh();
+
+ // 388 + 310 + 17 + 17 = 732
+ $this->assertSame(-732, $wallet->balance);
+ // 388 - 555 x 40% + 310 - 444 x 40% + 34 - 50 x 40% = 312
+ $this->assertSame(312, $reseller_wallet->balance);
+
+ $transactions = Transaction::where('object_id', $wallet->id)
+ ->where('object_type', \App\Wallet::class)->get();
+ $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id)
+ ->where('object_type', \App\Wallet::class)->get();
+
+ $this->assertCount(1, $reseller_transactions);
+ $trans = $reseller_transactions[0];
+ $this->assertSame("Charged user jane@kolabnow.com", $trans->description);
+ $this->assertSame(312, $trans->amount);
+ $this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
+
+ $this->assertCount(1, $transactions);
+ $trans = $transactions[0];
+ $this->assertSame('', $trans->description);
+ $this->assertSame(-732, $trans->amount);
+ $this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
+
+ // TODO: Test entitlement transaction records
+
+ // -----------------------------------
+ // Test charging on entitlement delete
+ // -----------------------------------
+
+ $transactions = Transaction::where('object_id', $wallet->id)
+ ->where('object_type', \App\Wallet::class)->delete();
+ $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id)
+ ->where('object_type', \App\Wallet::class)->delete();
+
+ $user->removeSku($storage, 2);
+
+ // we expect the wallet to have been charged for 19 days of use of
+ // 2 deleted storage entitlements
+ $wallet->refresh();
+ $reseller_wallet->refresh();
+
+ // 2 x round(25 / 31 * 19 * 0.7) = 22
+ $this->assertSame(-(732 + 22), $wallet->balance);
+ // 22 - 2 x round(25 * 0.4 / 31 * 19) = 10
+ $this->assertSame(312 + 10, $reseller_wallet->balance);
+
+ $transactions = Transaction::where('object_id', $wallet->id)
+ ->where('object_type', \App\Wallet::class)->get();
+ $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id)
+ ->where('object_type', \App\Wallet::class)->get();
+
+ $this->assertCount(2, $reseller_transactions);
+ $trans = $reseller_transactions[0];
+ $this->assertSame("Charged user jane@kolabnow.com", $trans->description);
+ $this->assertSame(5, $trans->amount);
+ $this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
+ $trans = $reseller_transactions[1];
+ $this->assertSame("Charged user jane@kolabnow.com", $trans->description);
+ $this->assertSame(5, $trans->amount);
+ $this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
+
+ $this->assertCount(2, $transactions);
+ $trans = $transactions[0];
+ $this->assertSame('', $trans->description);
+ $this->assertSame(-11, $trans->amount);
+ $this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
+ $trans = $transactions[1];
+ $this->assertSame('', $trans->description);
+ $this->assertSame(-11, $trans->amount);
+ $this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
+
+ // TODO: Test entitlement transaction records
+ }
}
diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -3,22 +3,44 @@
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) {
- $entitlement->created_at = $targetDate;
- $entitlement->updated_at = $targetDate;
- $entitlement->save();
+ $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();
- $owner = $entitlement->wallet->owner;
- $owner->created_at = $targetDate;
- $owner->save();
+ \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]);
}
}
@@ -33,4 +55,16 @@
\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'));
+ }
}
diff --git a/src/tests/TestCaseDusk.php b/src/tests/TestCaseDusk.php
--- a/src/tests/TestCaseDusk.php
+++ b/src/tests/TestCaseDusk.php
@@ -102,4 +102,15 @@
// we can also just call visit() with full url
Browser::$baseUrl = str_replace('//', '//admin.', \config('app.url'));
}
+
+ /**
+ * Set baseURL to the reseller UI location
+ */
+ protected static function useResellerUrl(): void
+ {
+ // This will set baseURL for all tests in this file
+ // If we wanted to visit both user and admin in one test
+ // we can also just call visit() with full url
+ Browser::$baseUrl = str_replace('//', '//reseller.', \config('app.url'));
+ }
}
diff --git a/src/tests/Unit/Mail/SignupInvitationTest.php b/src/tests/Unit/Mail/SignupInvitationTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Mail/SignupInvitationTest.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Tests\Unit\Mail;
+
+use App\Mail\SignupInvitation;
+use App\SignupInvitation as SI;
+use App\Utils;
+use Tests\MailInterceptTrait;
+use Tests\TestCase;
+
+class SignupInvitationTest extends TestCase
+{
+ use MailInterceptTrait;
+
+ /**
+ * Test email content
+ */
+ public function testBuild(): void
+ {
+ $invitation = new SI([
+ 'id' => 'abc',
+ 'email' => 'test@email',
+ ]);
+
+ $mail = $this->fakeMail(new SignupInvitation($invitation));
+
+ $html = $mail['html'];
+ $plain = $mail['plain'];
+
+ $url = Utils::serviceUrl('/signup/invite/' . $invitation->id);
+ $link = "<a href=\"$url\">$url</a>";
+ $appName = \config('app.name');
+
+ $this->assertMailSubject("$appName Invitation", $mail['message']);
+
+ $this->assertStringStartsWith('<!DOCTYPE html>', $html);
+ $this->assertTrue(strpos($html, $link) > 0);
+ $this->assertTrue(strpos($html, "invited to join $appName") > 0);
+
+ $this->assertStringStartsWith("Hi,", $plain);
+ $this->assertTrue(strpos($plain, "invited to join $appName") > 0);
+ $this->assertTrue(strpos($plain, $url) > 0);
+ }
+}
diff --git a/src/tests/Unit/SignupInvitationTest.php b/src/tests/Unit/SignupInvitationTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/SignupInvitationTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\SignupInvitation;
+use Tests\TestCase;
+
+class SignupInvitationTest extends TestCase
+{
+ /**
+ * Test is*() methods
+ *
+ * @return void
+ */
+ public function testStatus()
+ {
+ $invitation = new SignupInvitation();
+
+ $statuses = [
+ SignupInvitation::STATUS_NEW,
+ SignupInvitation::STATUS_SENT,
+ SignupInvitation::STATUS_FAILED,
+ SignupInvitation::STATUS_COMPLETED,
+ ];
+
+ foreach ($statuses as $status) {
+ $invitation->status = $status;
+
+ $this->assertSame($status === SignupInvitation::STATUS_NEW, $invitation->isNew());
+ $this->assertSame($status === SignupInvitation::STATUS_SENT, $invitation->isSent());
+ $this->assertSame($status === SignupInvitation::STATUS_FAILED, $invitation->isFailed());
+ $this->assertSame($status === SignupInvitation::STATUS_COMPLETED, $invitation->isCompleted());
+ }
+ }
+}
diff --git a/src/tests/data/email.csv b/src/tests/data/email.csv
new file mode 100644
--- /dev/null
+++ b/src/tests/data/email.csv
@@ -0,0 +1,2 @@
+email1@test.com
+email2@test.com
diff --git a/src/tests/data/empty.csv b/src/tests/data/empty.csv
new file mode 100644
diff --git a/src/webpack.mix.js b/src/webpack.mix.js
--- a/src/webpack.mix.js
+++ b/src/webpack.mix.js
@@ -22,13 +22,14 @@
}
})
+mix.js('resources/js/user/app.js', 'public/js/user.js').vue()
+ .js('resources/js/admin/app.js', 'public/js/admin.js').vue()
+ .js('resources/js/reseller/app.js', 'public/js/reseller.js').vue()
+
mix.before(() => {
spawn('php', ['resources/build/before.php'], { stdio: 'inherit' })
})
-mix.js('resources/js/user.js', 'public/js').vue()
- .js('resources/js/admin.js', 'public/js').vue()
-
glob.sync('resources/themes/*/', {}).forEach(fromDir => {
const toDir = fromDir.replace('resources/themes/', 'public/themes/')

File Metadata

Mime Type
text/plain
Expires
Mon, Mar 30, 2:22 PM (4 d, 5 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18813351
Default Alt Text
D2572.1774880539.diff (454 KB)

Event Timeline