Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117752017
D2428.1775187692.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
197 KB
Referenced Files
None
Subscribers
None
D2428.1775187692.diff
View Options
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">×</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
Details
Attached
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)
Attached To
Mode
D2428: Signup Invitations
Attached
Detach File
Event Timeline