Page MenuHomePhorge

D2428.1775187692.diff
No OneTemporary

Authored By
Unknown
Size
197 KB
Referenced Files
None
Subscribers
None

D2428.1775187692.diff

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
ASSET_URL=http://127.0.0.1:8000
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,8 +2,24 @@
namespace App\Console;
-class Command extends \Illuminate\Console\Command
+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.
*
@@ -25,7 +41,7 @@
*
* @return mixed
*/
- public function getObject($objectClass, $objectIdOrTitle, $objectTitle)
+ public function getObject($objectClass, $objectIdOrTitle, $objectTitle = null)
{
if ($this->hasOption('with-deleted') && $this->option('with-deleted')) {
$object = $objectClass::withTrashed()->find($objectIdOrTitle);
@@ -68,6 +84,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/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,95 @@
+<?php
+
+namespace App\Console\Commands\Discount;
+
+use Illuminate\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';
+
+ /**
+ * Create a new command instance.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ parent::__construct();
+ }
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $source = \App\Discount::find($this->argument('source'));
+
+ if (!$source) {
+ $this->error("No such source discount: {$source}");
+ return 1;
+ }
+
+ $target = \App\Discount::find($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/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
@@ -9,6 +9,11 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
+/**
+ * Create a (mail-enabled) distribution group.
+ *
+ * @see \App\Console\Commands\Scalpel\Group\CreateCommand
+ */
class CreateCommand extends Command
{
/**
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/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php
--- a/src/app/Console/Commands/WalletCharge.php
+++ b/src/app/Console/Commands/WalletCharge.php
@@ -42,7 +42,7 @@
// Find specified wallet by ID
$wallet = Wallet::find($wallet);
- if (!$wallet || !$wallet->owner) {
+ if (!$wallet || !$wallet->owner || $wallet->owner->tenant_id != \config('app.tenant_id')) {
return 1;
}
@@ -51,6 +51,7 @@
// Get all wallets, excluding deleted accounts
$wallets = Wallet::select('wallets.*')
->join('users', 'users.id', '=', 'wallets.user_id')
+ ->withEnvTenant('users')
->whereNull('users.deleted_at')
->get();
}
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
{
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
{
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;
@@ -113,6 +114,33 @@
return response()->json(['status' => 'success', 'code' => $code->code]);
}
+ /**
+ * 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.
*
@@ -190,10 +218,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
@@ -219,10 +287,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);
@@ -254,14 +318,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/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,45 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+use App\Discount;
+use App\Http\Controllers\Controller;
+
+class DiscountsController extends Controller
+{
+ /**
+ * Returns (active) discounts defined in the system.
+ *
+ * @return \Illuminate\Http\JsonResponse JSON response
+ */
+ public function index()
+ {
+ $user = auth()->user();
+
+ $discounts = $user->tenant->discounts()
+ ->where('active', true)
+ ->orderBy('discount')
+ ->get()
+ ->map(function ($discount) {
+ $label = $discount->discount . '% - ' . $discount->description;
+
+ if ($discount->code) {
+ $label .= " [{$discount->code}]";
+ }
+
+ return [
+ 'id' => $discount->id,
+ 'discount' => $discount->discount,
+ 'code' => $discount->code,
+ 'description' => $discount->description,
+ 'label' => $label,
+ ];
+ });
+
+ return response()->json([
+ 'status' => 'success',
+ 'list' => $discounts,
+ 'count' => count($discounts),
+ ]);
+ }
+}
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,55 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+use App\Domain;
+use App\User;
+
+class DomainsController extends \App\Http\Controllers\API\V4\DomainsController
+{
+ /**
+ * Search for domains
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $search = trim(request()->input('search'));
+ $owner = trim(request()->input('owner'));
+ $result = collect([]);
+
+ if ($owner) {
+ if ($owner = User::find($owner)) {
+ foreach ($owner->wallets as $wallet) {
+ $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get();
+
+ foreach ($entitlements as $entitlement) {
+ $domain = $entitlement->entitleable;
+ $result->push($domain);
+ }
+ }
+
+ $result = $result->sortBy('namespace');
+ }
+ } elseif (!empty($search)) {
+ if ($domain = Domain::where('namespace', $search)->first()) {
+ $result->push($domain);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(function ($domain) {
+ $data = $domain->toArray();
+ $data = array_merge($data, self::domainStatuses($domain));
+ return $data;
+ });
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxdomains', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/EntitlementsController.php b/src/app/Http/Controllers/API/V4/Reseller/EntitlementsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/EntitlementsController.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+class EntitlementsController extends \App\Http\Controllers\API\V4\EntitlementsController
+{
+}
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,251 @@
+<?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++;
+
+ 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/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/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,148 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+use App\Domain;
+use App\User;
+use App\UserAlias;
+use App\UserSetting;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Validator;
+
+class UsersController extends \App\Http\Controllers\API\V4\UsersController
+{
+ /**
+ * Searching of user accounts.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $search = trim(request()->input('search'));
+ $owner = trim(request()->input('owner'));
+ $result = collect([]);
+
+ if ($owner) {
+ $owner = User::where('id', $owner)
+ ->withUserTenant()
+ ->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)
+ ->withUserTenant()
+ ->whereNull('role')
+ ->orderBy('email')
+ ->get();
+
+ if ($result->isEmpty()) {
+ // Search by an alias
+ $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id');
+
+ // Search by an external email
+ $ext_user_ids = UserSetting::where('key', 'external_email')
+ ->where('value', $search)
+ ->get()
+ ->pluck('user_id');
+
+ $user_ids = $user_ids->merge($ext_user_ids)->unique();
+
+ if (!$user_ids->isEmpty()) {
+ $result = User::withTrashed()->whereIn('id', $user_ids)
+ ->withUserTenant()
+ ->whereNull('role')
+ ->orderBy('email')
+ ->get();
+ }
+ }
+ } elseif (is_numeric($search)) {
+ // Search by user ID
+ $user = User::withTrashed()->where('id', $search)
+ ->withUserTenant()
+ ->whereNull('role')
+ ->first();
+
+ if ($user) {
+ $result->push($user);
+ }
+ } elseif (!empty($search)) {
+ // Search by domain
+ $domain = Domain::withTrashed()->where('namespace', $search)
+ ->withUserTenant()
+ ->first();
+
+ if ($domain) {
+ if (
+ ($wallet = $domain->wallet())
+ && ($owner = $wallet->owner()->withTrashed()->withUserTenant()->first())
+ && empty($owner->role)
+ ) {
+ $result->push($owner);
+ }
+ }
+ }
+
+ // Process the result
+ $result = $result->map(function ($user) {
+ $data = $user->toArray();
+ $data = array_merge($data, self::userStatuses($user));
+ return $data;
+ });
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxusers', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Update user data.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @params string $id User identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function update(Request $request, $id)
+ {
+ $user = User::where('id', $id)->withUserTenant()->first();
+
+ if (empty($user) || $user->role == 'admin') {
+ return $this->errorResponse(404);
+ }
+
+ // For now admins can change only user external email address
+
+ $rules = [];
+
+ if (array_key_exists('external_email', $request->input())) {
+ $rules['external_email'] = 'email';
+ }
+
+ // Validate input
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ // Update user settings
+ $settings = $request->only(array_keys($rules));
+
+ if (!empty($settings)) {
+ $user->setSettings($settings);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => __('app.user-update-success'),
+ ]);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/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,49 @@
+<?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\WalletsController
+{
+ /**
+ * Update wallet data.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @params string $id Wallet identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function update(Request $request, $id)
+ {
+ $wallet = Wallet::find($id);
+
+ if (empty($wallet)) {
+ return $this->errorResponse(404);
+ }
+
+ if (array_key_exists('discount', $request->input())) {
+ if (empty($request->discount)) {
+ $wallet->discount()->dissociate();
+ $wallet->save();
+ } elseif ($discount = Discount::find($request->discount)) {
+ $wallet->discount()->associate($discount);
+ $wallet->save();
+ }
+ }
+
+ $response = $wallet->toArray();
+
+ if ($wallet->discount) {
+ $response['discount'] = $wallet->discount->discount;
+ $response['discount_description'] = $wallet->discount->description;
+ }
+
+ $response['status'] = 'success';
+ $response['message'] = \trans('app.wallet-update-success');
+
+ return response()->json($response);
+ }
+}
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
@@ -63,6 +63,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,
@@ -79,10 +80,10 @@
\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/AuthenticateReseller.php b/src/app/Http/Middleware/AuthenticateReseller.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Middleware/AuthenticateReseller.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+
+class AuthenticateReseller
+{
+ /**
+ * Handle an incoming request.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @param \Closure $next
+ * @return mixed
+ */
+ public function handle($request, Closure $next)
+ {
+ $user = auth()->user();
+
+ if (!$user) {
+ abort(403, "Unauthorized");
+ }
+
+ if ($user->role !== "reseller") {
+ abort(403, "Unauthorized");
+ }
+
+ if ($user->tenant_id != \config('app.tenant_id')) {
+ abort(403, "Unauthorized");
+ }
+
+ return $next($request);
+ }
+}
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/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/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');
}
/**
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;
@@ -35,6 +36,7 @@
\App\PackageSku::observe(\App\Observers\PackageSkuObserver::class);
\App\Plan::observe(\App\Observers\PlanObserver::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 +59,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/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/Tenant.php b/src/app/Tenant.php
new file mode 100644
--- /dev/null
+++ b/src/app/Tenant.php
@@ -0,0 +1,40 @@
+<?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');
+ }
+}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -21,6 +21,7 @@
* @property int $id
* @property string $password
* @property int $status
+ * @property int $tenant_id
*/
class User extends Authenticatable implements JWTSubject
{
@@ -58,7 +59,7 @@
'email',
'password',
'password_ldap',
- 'status'
+ 'status',
];
/**
@@ -221,7 +222,7 @@
*/
public function canRead($object): bool
{
- if ($this->role == "admin") {
+ if ($this->role == 'admin') {
return true;
}
@@ -229,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);
}
@@ -239,7 +252,7 @@
$wallet = $object->wallet();
- return $this->wallets->contains($wallet) || $this->accounts->contains($wallet);
+ return $wallet && ($this->wallets->contains($wallet) || $this->accounts->contains($wallet));
}
/**
@@ -251,10 +264,6 @@
*/
public function canUpdate($object): bool
{
- if (!method_exists($object, 'wallet')) {
- return false;
- }
-
if ($object instanceof User && $this->id == $object->id) {
return true;
}
@@ -588,6 +597,16 @@
$this->save();
}
+ /**
+ * The tenant for this user account.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function tenant()
+ {
+ return $this->belongsTo('App\Tenant', 'tenant_id', 'id');
+ }
+
/**
* Unsuspend this domain.
*
diff --git a/src/app/Utils.php b/src/app/Utils.php
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -384,6 +384,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/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/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,53 @@
+<?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();
+ }
+ );
+
+ Schema::table(
+ 'users',
+ function (Blueprint $table) {
+ $table->bigInteger('tenant_id')->unsigned()->nullable();
+
+ $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'users',
+ function (Blueprint $table) {
+ $table->dropForeign(['tenant_id']);
+ $table->dropColumn('tenant_id');
+ }
+ );
+
+ Schema::dropIfExists('tenants');
+ }
+}
diff --git a/src/database/migrations/2021_02_19_093832_discounts_add_tenant_id.php b/src/database/migrations/2021_02_19_093832_discounts_add_tenant_id.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2021_02_19_093832_discounts_add_tenant_id.php
@@ -0,0 +1,42 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class DiscountsAddTenantId extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'discounts',
+ function (Blueprint $table) {
+ $table->bigInteger('tenant_id')->unsigned()->default(\config('app.tenant_id'))->nullable();
+
+ $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'discounts',
+ function (Blueprint $table) {
+ $table->dropForeign(['tenant_id']);
+ $table->dropColumn('tenant_id');
+ }
+ );
+ }
+}
diff --git a/src/database/migrations/2021_02_19_093833_domains_add_tenant_id.php b/src/database/migrations/2021_02_19_093833_domains_add_tenant_id.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2021_02_19_093833_domains_add_tenant_id.php
@@ -0,0 +1,42 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class DomainsAddTenantId extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'domains',
+ function (Blueprint $table) {
+ $table->bigInteger('tenant_id')->unsigned()->default(\config('app.tenant_id'))->nullable();
+
+ $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'domains',
+ function (Blueprint $table) {
+ $table->dropForeign(['tenant_id']);
+ $table->dropColumn('tenant_id');
+ }
+ );
+ }
+}
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/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()
+ {
+ Tenant::create(
+ [
+ 'title' => 'Kolab Now'
+ ]
+ );
+
+ 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/package-lock.json b/src/package-lock.json
--- a/src/package-lock.json
+++ b/src/package-lock.json
@@ -5136,7 +5136,7 @@
"css": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz",
- "integrity": "sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw==",
+ "integrity": "sha1-xkZ1XHOXHyu6amAeLPL9cbEpiSk=",
"dev": true,
"requires": {
"inherits": "^2.0.3",
@@ -5148,7 +5148,7 @@
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "integrity": "sha1-dHIq8y6WFOnCh6jQu95IteLxomM=",
"dev": true
}
}
diff --git a/src/phpstan.neon b/src/phpstan.neon
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -7,8 +7,12 @@
- '#Access to an undefined property Illuminate\\Database\\Eloquent\\Model::\$id#'
- '#Access to an undefined property Illuminate\\Database\\Eloquent\\Model::\$created_at#'
- '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::toString\(\)#'
+ - '#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,10 +1,10 @@
-import DashboardComponent from '../vue/Admin/Dashboard'
-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 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
@@ -78,7 +78,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']
@@ -425,7 +425,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,10 +1,11 @@
-import DashboardComponent from '../vue/Admin/Dashboard'
-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 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'
const routes = [
{
@@ -33,12 +34,20 @@
name: 'logout',
component: LogoutComponent
},
+ {
+ path: '/invitations',
+ name: 'invitations',
+ component: InvitationsComponent,
+ meta: { requiresAuth: true }
+ },
+/*
{
path: '/stats',
name: 'stats',
component: StatsComponent,
meta: { requiresAuth: true }
},
+*/
{
path: '/user/:user',
name: 'user',
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,22 +1,22 @@
-import DashboardComponent from '../vue/Dashboard'
-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 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 = [
{
@@ -76,6 +76,11 @@
component: MeetComponent,
meta: { requiresAuth: true }
},
+ {
+ path: '/signup/invite/:param',
+ name: 'signup-invite',
+ component: SignupComponent
+ },
{
path: '/signup/:param?',
alias: '/signup/voucher/:param',
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
@@ -45,6 +45,12 @@
'search-foundxdomains' => ':x domains 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' => "TODO",
+ 'signupinvitation-body1' => "TODO",
+ 'signupinvitation-body2' => "TODO",
+
'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
@@ -115,7 +115,10 @@
}
table {
- td.buttons,
+ th {
+ white-space: nowrap;
+ }
+
td.email,
td.price,
td.datetime,
@@ -124,6 +127,7 @@
white-space: nowrap;
}
+ td.buttons,
th.price,
td.price {
width: 1%;
@@ -279,6 +283,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 @@
{
"title": "Explore",
"location": "https://kolabnow.com/",
- "admin": true
+ "admin": true,
+ "reseller": true
},
{
"title": "Blog",
"location": "https://blogs.kolabnow.com/",
- "admin": true
+ "admin": true,
+ "reseller": true
},
{
"title": "Support",
"location": "/support",
"page": "support",
- "admin": true
+ "admin": true,
+ "reseller": true
},
{
"title": "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/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="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">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">Forgot password?</router-link>
- <a v-if="webmailURL && !$root.isAdmin" :href="webmailURL" id="webmail">Webmail</a>
+ <router-link v-if="$root.isUser && $root.hasRoute('password-reset')" :to="{ name: 'password-reset' }" id="forgot-password">Forgot password?</router-link>
+ <a v-if="webmailURL && $root.isUser" :href="webmailURL" id="webmail">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,24 @@
+<template>
+ <div class="container" dusk="dashboard-component">
+ <user-search></user-search>
+ <div id="dashboard-nav" class="mt-3">
+ <router-link class="card link-invitations" :to="{ name: 'invitations' }">
+ <svg-icon icon="envelope-open-text"></svg-icon><span class="name">Invitations</span>
+ </router-link>
+ </div>
+ </div>
+</template>
+
+<script>
+ import UserSearch from '../Widgets/UserSearch'
+ import { library } from '@fortawesome/fontawesome-svg-core'
+ import { faEnvelopeOpenText } from '@fortawesome/free-solid-svg-icons'
+
+ library.add(faEnvelopeOpenText)
+
+ export default {
+ components: {
+ UserSearch
+ }
+ }
+</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/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'}">Signup</router-link>
</li>
<li class="nav-item" v-if="loggedIn">
@@ -72,9 +72,12 @@
// TODO: Different menu for different loggedIn state
- if (window.isAdmin && !item.admin) {
- return
- } else if (!window.isAdmin && item.admin === 'only') {
+ if (
+ (window.isAdmin && !item.admin)
+ || (!window.isAdmin && item.admin === 'only')
+ || (window.isReseller && !item.reseller)
+ || (!window.isReseller && item.reseller === 'only')
+ ) {
return
}
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');
}
@@ -160,3 +161,26 @@
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::get('domains/{id}/confirm', 'API\V4\Reseller\DomainsController@confirm');
+
+ Route::apiResource('entitlements', API\V4\Reseller\EntitlementsController::class);
+ 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::apiResource('skus', API\V4\Reseller\SkusController::class);
+ Route::apiResource('users', API\V4\Reseller\UsersController::class);
+ Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus');
+ Route::apiResource('wallets', API\V4\Reseller\WalletsController::class);
+ Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions');
+ Route::apiResource('discounts', API\V4\Reseller\DiscountsController::class);
+ }
+);
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/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']);
+ })
+ ->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']);
+ })
+ ->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']);
+ });
+
+ // 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']);
+ });
+
+ // Success toast message
+ $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out');
+ });
+ }
+}
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();
}
@@ -294,17 +299,22 @@
// 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', '');
+ $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', 13, 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
});
@@ -542,4 +552,83 @@
$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) {
+ $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', 13, 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/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/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,348 @@
+<?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();
+
+ 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/UsersTest.php b/src/tests/Feature/Controller/Reseller/UsersTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/UsersTest.php
@@ -0,0 +1,285 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use App\Tenant;
+use App\User;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class UsersTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ \config(['app.tenant_id' => 1]);
+
+ // $this->deleteTestUser('UsersControllerTest1@userscontroller.com');
+ $this->deleteTestUser('test@testsearch.com');
+ $this->deleteTestDomain('testsearch.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ // $this->deleteTestUser('UsersControllerTest1@userscontroller.com');
+ $this->deleteTestUser('test@testsearch.com');
+ $this->deleteTestDomain('testsearch.com');
+
+ \config(['app.tenant_id' => 1]);
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test users searching (/api/v4/users)
+ */
+ 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]);
+
+ // Normal user
+ $response = $this->actingAs($user)->get("api/v4/users");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/users");
+ $response->assertStatus(403);
+
+ // Reseller from another tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/users");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($reseller)->get("api/v4/users");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search with no matches expected
+ $response = $this->actingAs($reseller)->get("api/v4/users?search=abcd1234efgh5678");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search by domain in another tenant
+ $response = $this->actingAs($reseller)->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($reseller)->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($reseller)->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($reseller)->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 = $tenant->id;
+ $domain->save();
+ $user = $this->getTestUser('test@testsearch.com');
+ $user->tenant_id = $tenant->id;
+ $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($reseller)->get("api/v4/users?search=testsearch.com");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($user->id, $json['list'][0]['id']);
+ $this->assertSame($user->email, $json['list'][0]['email']);
+
+ // Search by user ID
+ $response = $this->actingAs($reseller)->get("api/v4/users?search={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($user->id, $json['list'][0]['id']);
+ $this->assertSame($user->email, $json['list'][0]['email']);
+
+ // Search by email (primary) - existing user in reseller's tenant
+ $response = $this->actingAs($reseller)->get("api/v4/users?search=test@testsearch.com");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($user->id, $json['list'][0]['id']);
+ $this->assertSame($user->email, $json['list'][0]['email']);
+
+ // Search by email (alias)
+ $response = $this->actingAs($reseller)->get("api/v4/users?search=alias@testsearch.com");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($user->id, $json['list'][0]['id']);
+ $this->assertSame($user->email, $json['list'][0]['email']);
+
+ // Search by email (external), there are two users with this email, but only one
+ // in the reseller's tenant
+ $response = $this->actingAs($reseller)->get("api/v4/users?search=john.doe.external@gmail.com");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($user->id, $json['list'][0]['id']);
+ $this->assertSame($user->email, $json['list'][0]['email']);
+
+ // Search by owner
+ $response = $this->actingAs($reseller)->get("api/v4/users?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($user->id, $json['list'][0]['id']);
+ $this->assertSame($user->email, $json['list'][0]['email']);
+
+ // Deleted users/domains
+ $user->delete();
+
+ $response = $this->actingAs($reseller)->get("api/v4/users?search=test@testsearch.com");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($user->id, $json['list'][0]['id']);
+ $this->assertSame($user->email, $json['list'][0]['email']);
+ $this->assertTrue($json['list'][0]['isDeleted']);
+
+ $response = $this->actingAs($reseller)->get("api/v4/users?search=alias@testsearch.com");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($user->id, $json['list'][0]['id']);
+ $this->assertSame($user->email, $json['list'][0]['email']);
+ $this->assertTrue($json['list'][0]['isDeleted']);
+
+ $response = $this->actingAs($reseller)->get("api/v4/users?search=testsearch.com");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($user->id, $json['list'][0]['id']);
+ $this->assertSame($user->email, $json['list'][0]['email']);
+ $this->assertTrue($json['list'][0]['isDeleted']);
+ }
+
+ /**
+ * Test user update (PUT /api/v4/users/<user-id>)
+ */
+ public function testUpdate(): void
+ {
+ $this->markTestIncomplete();
+/*
+ $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+
+ // Test unauthorized access to admin API
+ $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", []);
+ $response->assertStatus(403);
+
+ // Test updatig the user data (empty data)
+ $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("User data updated successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test error handling
+ $post = ['external_email' => 'aaa'];
+ $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The external email must be a valid email address.", $json['errors']['external_email'][0]);
+ $this->assertCount(2, $json);
+
+ // Test real update
+ $post = ['external_email' => 'modified@test.com'];
+ $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("User data updated successfully.", $json['message']);
+ $this->assertCount(2, $json);
+ $this->assertSame('modified@test.com', $user->getSetting('external_email'));
+*/
+ }
+}
diff --git a/src/tests/Feature/Controller/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();
@@ -95,12 +98,37 @@
$this->assertArrayHasKey('button', $json['plans'][0]);
}
+ /**
+ * 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();
@@ -641,6 +660,59 @@
// TODO: Check if the access token works
}
+ /**
+ * 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()
*
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/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/UserTest.php b/src/tests/Feature/UserTest.php
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -84,9 +84,74 @@
$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));
}
public function testCanUpdate(): void
diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -33,4 +33,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,43 @@
+<?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, 'TODO') > 0);
+
+ $this->assertStringStartsWith('TODO', $plain);
+ $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
@@ -33,8 +33,9 @@
}
})
-mix.js('resources/js/user.js', 'public/js')
- .js('resources/js/admin.js', 'public/js')
+mix.js('resources/js/user/app.js', 'public/js/user.js')
+ .js('resources/js/admin/app.js', 'public/js/admin.js')
+ .js('resources/js/reseller/app.js', 'public/js/reseller.js')
glob.sync('resources/themes/*/', {}).forEach(fromDir => {
const toDir = fromDir.replace('resources/themes/', 'public/themes/')

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 3:41 AM (10 h, 20 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822450
Default Alt Text
D2428.1775187692.diff (197 KB)

Event Timeline