diff --git a/docker-compose.yml b/docker-compose.yml --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,6 +59,7 @@ container_name: kolab-mariadb environment: MYSQL_ROOT_PASSWORD: Welcome2KolabSystems + TZ: "+02:00" healthcheck: interval: 10s test: test -e /var/run/mysqld/mysqld.sock diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -6,6 +6,7 @@ APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com APP_THEME=default +APP_TENANT_ID=1 APP_LOCALE=en APP_LOCALES=en,de diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -2,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/DBPing.php b/src/app/Console/Commands/DB/PingCommand.php rename from src/app/Console/Commands/DBPing.php rename to src/app/Console/Commands/DB/PingCommand.php --- a/src/app/Console/Commands/DBPing.php +++ b/src/app/Console/Commands/DB/PingCommand.php @@ -1,11 +1,11 @@ error("The application timezone is not configured to be UTC"); + return 1; + } + + if ($result[0]->{'Value'} != '+00:00' && $result[0]->{'Value'} != 'UTC') { + $this->error("The database timezone is neither configured as '+00:00' nor 'UTC'"); + return 1; + } + + return 0; + } +} diff --git a/src/app/Console/Commands/Discount/MergeCommand.php b/src/app/Console/Commands/Discount/MergeCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Discount/MergeCommand.php @@ -0,0 +1,95 @@ + 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 @@ -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 @@ 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/DomainSetWallet.php b/src/app/Console/Commands/DomainSetWallet.php --- a/src/app/Console/Commands/DomainSetWallet.php +++ b/src/app/Console/Commands/DomainSetWallet.php @@ -60,6 +60,7 @@ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => 0, + 'fee' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ] diff --git a/src/app/Console/Commands/Group/CreateCommand.php b/src/app/Console/Commands/Group/CreateCommand.php --- a/src/app/Console/Commands/Group/CreateCommand.php +++ b/src/app/Console/Commands/Group/CreateCommand.php @@ -7,6 +7,11 @@ use App\Http\Controllers\API\V4\GroupsController; use Illuminate\Support\Facades\DB; +/** + * Create a (mail-enabled) distribution group. + * + * @see \App\Console\Commands\Scalpel\Group\CreateCommand + */ class CreateCommand extends Command { /** diff --git a/src/app/Console/Commands/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 @@ +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 { @@ -86,6 +89,7 @@ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), + 'fee' => $sku->fee, 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -11,10 +11,18 @@ * * Owned by a {@link \App\User}, billed to a {@link \App\Wallet}. * - * @property \App\User $owner The owner of this entitlement (subject). - * @property \App\Sku $sku The SKU to which this entitlement applies. - * @property \App\Wallet $wallet The wallet to which this entitlement is charged. - * @property \App\Domain|\App\User $entitleable The entitled object (receiver of the entitlement). + * @property int $cost + * @property ?string $description + * @property \App\Domain|\App\User $entitleable The entitled object (receiver of the entitlement). + * @property int $entitleable_id + * @property string $entitleable_type + * @property int $fee + * @property string $id + * @property \App\User $owner The owner of this entitlement (subject). + * @property \App\Sku $sku The SKU to which this entitlement applies. + * @property string $sku_id + * @property \App\Wallet $wallet The wallet to which this entitlement is charged. + * @property string $wallet_id */ class Entitlement extends Model { @@ -45,11 +53,13 @@ 'entitleable_id', 'entitleable_type', 'cost', - 'description' + 'description', + 'fee', ]; protected $casts = [ 'cost' => 'integer', + 'fee' => 'integer' ]; /** diff --git a/src/app/Group.php b/src/app/Group.php --- a/src/app/Group.php +++ b/src/app/Group.php @@ -64,6 +64,7 @@ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => Group::class ]); 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; @@ -111,6 +112,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. * @@ -188,10 +216,50 @@ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - // Validate verification codes (again) - $v = $this->verify($request); - if ($v->status() !== 200) { - return $v; + // Signup via invitation + if ($request->invitation) { + $invitation = SignupInvitation::withEnvTenant()->find($request->invitation); + + if (empty($invitation) || $invitation->isCompleted()) { + return $this->errorResponse(404); + } + + // Check required fields + $v = Validator::make( + $request->all(), + [ + 'first_name' => 'max:128', + 'last_name' => 'max:128', + 'voucher' => 'max:32', + ] + ); + + $errors = $v->fails() ? $v->errors()->toArray() : []; + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $settings = [ + 'external_email' => $invitation->email, + 'first_name' => $request->first_name, + 'last_name' => $request->last_name, + ]; + } else { + // Validate verification codes (again) + $v = $this->verify($request); + if ($v->status() !== 200) { + return $v; + } + + // Get user name/email from the verification code database + $code_data = $v->getData(); + + $settings = [ + 'external_email' => $code_data->email, + 'first_name' => $code_data->first_name, + 'last_name' => $code_data->last_name, + ]; } // Find the voucher discount @@ -217,10 +285,6 @@ return response()->json(['status' => 'error', 'errors' => $errors], 422); } - // Get user name/email from the verification code database - $code_data = $v->getData(); - $user_email = $code_data->email; - // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($login); $domain_name = Str::lower($domain_name); @@ -252,14 +316,19 @@ $user->assignPlan($plan, $domain); // Save the external email and plan in user settings - $user->setSettings([ - 'external_email' => $user_email, - 'first_name' => $code_data->first_name, - 'last_name' => $code_data->last_name, - ]); + $user->setSettings($settings); + + // Update the invitation + if (!empty($invitation)) { + $invitation->status = SignupInvitation::STATUS_COMPLETED; + $invitation->user_id = $user->id; + $invitation->save(); + } // Remove the verification code - $this->code->delete(); + if ($this->code) { + $this->code->delete(); + } DB::commit(); diff --git a/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php b/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php --- a/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php @@ -16,7 +16,10 @@ { $discounts = []; - Discount::where('active', true)->orderBy('discount')->get() + Discount::withEnvTenant() + ->where('active', true) + ->orderBy('discount') + ->get() ->map(function ($discount) use (&$discounts) { $label = $discount->discount . '% - ' . $discount->description; diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -20,7 +20,7 @@ $result = collect([]); if ($owner) { - if ($owner = User::find($owner)) { + if ($owner = User::withEnvTenant()->find($owner)) { foreach ($owner->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); @@ -33,7 +33,7 @@ $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { - if ($domain = Domain::where('namespace', $search)->first()) { + if ($domain = Domain::withEnvTenant()->where('namespace', $search)->first()) { $result->push($domain); } } @@ -64,7 +64,7 @@ */ public function suspend(Request $request, $id) { - $domain = Domain::find($id); + $domain = Domain::withEnvTenant()->find($id); if (empty($domain) || $domain->isPublic()) { return $this->errorResponse(404); @@ -88,7 +88,7 @@ */ public function unsuspend(Request $request, $id) { - $domain = Domain::find($id); + $domain = Domain::withEnvTenant()->find($id); if (empty($domain) || $domain->isPublic()) { return $this->errorResponse(404); diff --git a/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php b/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php deleted file mode 100644 --- a/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php +++ /dev/null @@ -1,7 +0,0 @@ -find($owner)) { foreach ($owner->wallets as $wallet) { $wallet->entitlements()->where('entitleable_type', Group::class)->get() ->each(function ($entitlement) use ($result) { @@ -31,7 +31,7 @@ $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { - if ($group = Group::where('email', $search)->first()) { + if ($group = Group::withEnvTenant()->where('email', $search)->first()) { $result->push($group); } } @@ -78,7 +78,7 @@ */ public function suspend(Request $request, $id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); @@ -102,7 +102,7 @@ */ public function unsuspend(Request $request, $id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); diff --git a/src/app/Http/Controllers/API/V4/Admin/StatsController.php b/src/app/Http/Controllers/API/V4/Admin/StatsController.php --- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php @@ -6,6 +6,7 @@ use App\User; use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; class StatsController extends \App\Http\Controllers\Controller @@ -18,6 +19,14 @@ public const COLOR_BLUE_DARK = '#0056b3'; public const COLOR_ORANGE = '#f1a539'; + /** @var array List of enabled charts */ + protected $charts = [ + 'discounts', + 'income', + 'users', + 'users-all', + ]; + /** * Fetch chart data * @@ -33,7 +42,7 @@ $method = 'chart' . implode('', array_map('ucfirst', explode('-', $chart))); - if (!method_exists($this, $method)) { + if (!in_array($chart, $this->charts) || !method_exists($this, $method)) { return $this->errorResponse(404); } @@ -53,9 +62,14 @@ ->join('users', 'users.id', '=', 'wallets.user_id') ->where('discount', '>', 0) ->whereNull('users.deleted_at') - ->groupBy('discounts.discount') - ->pluck('cnt', 'discount') - ->all(); + ->groupBy('discounts.discount'); + + $addTenantScope = function ($builder, $tenantId) { + return $builder->where('users.tenant_id', $tenantId); + }; + + $discounts = $this->applyTenantScope($discounts, $addTenantScope) + ->pluck('cnt', 'discount')->all(); $labels = array_keys($discounts); $discounts = array_values($discounts); @@ -119,7 +133,20 @@ ->where('updated_at', '>=', $start->toDateString()) ->where('status', PaymentProvider::STATUS_PAID) ->whereIn('type', [PaymentProvider::TYPE_ONEOFF, PaymentProvider::TYPE_RECURRING]) - ->groupByRaw('1') + ->groupByRaw('1'); + + $addTenantScope = function ($builder, $tenantId) { + $where = '`wallet_id` IN (' + . 'select `id` from `wallets` ' + . 'join `users` on (`wallets`.`user_id` = `users`.`id`) ' + . 'where `payments`.`wallet_id` = `wallets`.`id` ' + . 'and `users`.`tenant_id` = ' . intval($tenantId) + . ')'; + + return $builder->whereRaw($where); + }; + + $payments = $this->applyTenantScope($payments, $addTenantScope) ->pluck('amount', 'period') ->map(function ($amount) { return $amount / 100; @@ -185,14 +212,15 @@ $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); + + $created = $this->applyTenantScope($created)->get(); + $deleted = $this->applyTenantScope($deleted)->get(); $empty = array_fill_keys($labels, 0); $created = array_values(array_merge($empty, $created->pluck('cnt', 'period')->all())); @@ -260,16 +288,16 @@ $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); - $count = DB::table('users')->whereNull('deleted_at')->count(); + $created = $this->applyTenantScope($created)->get(); + $deleted = $this->applyTenantScope($deleted)->get(); + $count = $this->applyTenantScope(DB::table('users')->whereNull('deleted_at'))->count(); $empty = array_fill_keys($labels, 0); $created = array_merge($empty, $created->pluck('cnt', 'period')->all()); @@ -313,4 +341,29 @@ ] ]; } + + /** + * Add tenant scope to the queries when needed + * + * @param \Illuminate\Database\Query\Builder $query The query + * @param callable $addQuery Additional tenant-scope query-modifier + * + * @return \Illuminate\Database\Query\Builder + */ + protected function applyTenantScope($query, $addQuery = null) + { + $user = Auth::guard()->user(); + + if ($user->role == 'reseller') { + if ($addQuery) { + $query = $addQuery($query, \config('app.tenant_id')); + } else { + $query = $query->withEnvTenant(); + } + } + + // TODO: Tenant selector for admins + + return $query; + } } diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -13,6 +13,18 @@ class UsersController extends \App\Http\Controllers\API\V4\UsersController { + /** + * Delete a user. + * + * @param int $id User identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function destroy($id) + { + return $this->errorResponse(404); + } + /** * Searching of user accounts. * @@ -25,13 +37,21 @@ $result = collect([]); if ($owner) { - if ($owner = User::find($owner)) { - $result = $owner->users(false)->orderBy('email')->get(); + $owner = User::where('id', $owner) + ->withEnvTenant() + ->whereNull('role') + ->first(); + + if ($owner) { + $result = $owner->users(false)->whereNull('role')->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email $result = User::withTrashed()->where('email', $search) - ->orderBy('email')->get(); + ->withEnvTenant() + ->whereNull('role') + ->orderBy('email') + ->get(); if ($result->isEmpty()) { // Search by an alias @@ -39,7 +59,9 @@ // Search by an external email $ext_user_ids = UserSetting::where('key', 'external_email') - ->where('value', $search)->get()->pluck('user_id'); + ->where('value', $search) + ->get() + ->pluck('user_id'); $user_ids = $user_ids->merge($ext_user_ids)->unique(); @@ -50,19 +72,35 @@ if (!$user_ids->isEmpty()) { $result = User::withTrashed()->whereIn('id', $user_ids) - ->orderBy('email')->get(); + ->withEnvTenant() + ->whereNull('role') + ->orderBy('email') + ->get(); } } } elseif (is_numeric($search)) { // Search by user ID - if ($user = User::withTrashed()->find($search)) { + $user = User::withTrashed()->where('id', $search) + ->withEnvTenant() + ->whereNull('role') + ->first(); + + if ($user) { $result->push($user); } } elseif (!empty($search)) { // Search by domain - if ($domain = Domain::withTrashed()->where('namespace', $search)->first()) { - if ($wallet = $domain->wallet()) { - $result->push($wallet->owner()->withTrashed()->first()); + $domain = Domain::withTrashed()->where('namespace', $search) + ->withEnvTenant() + ->first(); + + if ($domain) { + if ( + ($wallet = $domain->wallet()) + && ($owner = $wallet->owner()->withTrashed()->withEnvTenant()->first()) + && empty($owner->role) + ) { + $result->push($owner); } } } @@ -93,9 +131,9 @@ */ public function reset2FA(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } @@ -113,6 +151,18 @@ ]); } + /** + * Create a new user record. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function store(Request $request) + { + return $this->errorResponse(404); + } + /** * Suspend the user * @@ -123,9 +173,9 @@ */ public function suspend(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } @@ -147,9 +197,9 @@ */ public function unsuspend(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } @@ -171,9 +221,9 @@ */ public function update(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php --- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -8,6 +8,7 @@ use App\Transaction; use App\Wallet; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; @@ -24,7 +25,7 @@ { $wallet = Wallet::find($id); - if (empty($wallet)) { + if (empty($wallet) || !Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(404); } @@ -60,7 +61,7 @@ { $wallet = Wallet::find($id); - if (empty($wallet)) { + if (empty($wallet) || !Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(404); } @@ -119,7 +120,7 @@ { $wallet = Wallet::find($id); - if (empty($wallet)) { + if (empty($wallet) || !Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(404); } @@ -127,7 +128,7 @@ if (empty($request->discount)) { $wallet->discount()->dissociate(); $wallet->save(); - } elseif ($discount = Discount::find($request->discount)) { + } elseif ($discount = Discount::withEnvTenant()->find($request->discount)) { $wallet->discount()->associate($discount); $wallet->save(); } diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -117,7 +117,7 @@ */ public function show($id) { - $domain = Domain::findOrFail($id); + $domain = Domain::withEnvTenant()->findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::guard()->user()->canRead($domain)) { diff --git a/src/app/Http/Controllers/API/V4/EntitlementsController.php b/src/app/Http/Controllers/API/V4/EntitlementsController.php deleted file mode 100644 --- a/src/app/Http/Controllers/API/V4/EntitlementsController.php +++ /dev/null @@ -1,97 +0,0 @@ -errorResponse(404); - } - - /** - * Remove the specified resource from storage. - * - * @param int $id - * - * @return \Illuminate\Http\JsonResponse - */ - public function destroy($id) - { - // TODO - return $this->errorResponse(404); - } - - /** - * Show the form for editing the specified resource. - * - * @param int $id - * - * @return \Illuminate\Http\JsonResponse - */ - public function edit($id) - { - // TODO - return $this->errorResponse(404); - } - - /** - * Display a listing of the resource. - * - * @return \Illuminate\Http\JsonResponse - */ - public function index() - { - // TODO - return $this->errorResponse(404); - } - - /** - * Store a newly created resource in storage. - * - * @param \Illuminate\Http\Request $request - * - * @return \Illuminate\Http\JsonResponse - */ - public function store(Request $request) - { - // TODO - return $this->errorResponse(404); - } - - /** - * Display the specified resource. - * - * @param int $id - * - * @return \Illuminate\Http\JsonResponse - */ - public function show($id) - { - // TODO - return $this->errorResponse(404); - } - - /** - * Update the specified resource in storage. - * - * @param \Illuminate\Http\Request $request - * @param int $id - * - * @return \Illuminate\Http\JsonResponse - */ - public function update(Request $request, $id) - { - // TODO - return $this->errorResponse(404); - } -} diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php --- a/src/app/Http/Controllers/API/V4/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/GroupsController.php @@ -32,7 +32,7 @@ */ public function destroy($id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); @@ -96,7 +96,7 @@ */ public function show($id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); @@ -123,7 +123,7 @@ */ public function status($id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); @@ -308,7 +308,7 @@ */ public function update(Request $request, $id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); diff --git a/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php b/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php @@ -0,0 +1,7 @@ +errorResponse(404); + } + + /** + * Remove the specified invitation. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function destroy($id) + { + $invitation = SignupInvitation::withUserTenant()->find($id); + + if (empty($invitation)) { + return $this->errorResponse(404); + } + + $invitation->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => trans('app.signup-invitation-delete-success'), + ]); + } + + /** + * Show the form for editing the specified resource. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function edit($id) + { + return $this->errorResponse(404); + } + + /** + * Display a listing of the resource. + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $pageSize = 10; + $search = request()->input('search'); + $page = intval(request()->input('page')) ?: 1; + $hasMore = false; + + $result = SignupInvitation::withUserTenant() + ->latest() + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)); + + if ($search) { + if (strpos($search, '@')) { + $result->where('email', $search); + } else { + $result->whereLike('email', $search); + } + } + + $result = $result->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + $result = $result->map(function ($invitation) { + return $this->invitationToArray($invitation); + }); + + return response()->json([ + 'status' => 'success', + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + 'page' => $page, + ]); + } + + /** + * Resend the specified invitation. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function resend($id) + { + $invitation = SignupInvitation::withUserTenant()->find($id); + + if (empty($invitation)) { + return $this->errorResponse(404); + } + + if ($invitation->isFailed() || $invitation->isSent()) { + // Note: The email sending job will be dispatched by the observer + $invitation->status = SignupInvitation::STATUS_NEW; + $invitation->save(); + } + + return response()->json([ + 'status' => 'success', + 'message' => trans('app.signup-invitation-resend-success'), + 'invitation' => $this->invitationToArray($invitation), + ]); + } + + /** + * Store a newly created resource in storage. + * + * @param \Illuminate\Http\Request $request + * + * @return \Illuminate\Http\JsonResponse + */ + public function store(Request $request) + { + $errors = []; + $invitations = []; + + if (!empty($request->file) && is_object($request->file)) { + // Expected a text/csv file with multiple email addresses + if (!$request->file->isValid()) { + $errors = ['file' => [$request->file->getErrorMessage()]]; + } else { + $fh = fopen($request->file->getPathname(), 'r'); + $line_number = 0; + $error = null; + + while ($line = fgetcsv($fh)) { + $line_number++; + + // @phpstan-ignore-next-line + if (count($line) >= 1 && $line[0]) { + $email = trim($line[0]); + + if (strpos($email, '@')) { + $v = Validator::make(['email' => $email], ['email' => 'email:filter|required']); + + if ($v->fails()) { + $args = ['email' => $email, 'line' => $line_number]; + $error = trans('app.signup-invitations-csv-invalid-email', $args); + break; + } + + $invitations[] = ['email' => $email]; + } + } + } + + fclose($fh); + + if ($error) { + $errors = ['file' => $error]; + } elseif (empty($invitations)) { + $errors = ['file' => trans('app.signup-invitations-csv-empty')]; + } + } + } else { + // Expected 'email' field with an email address + $v = Validator::make($request->all(), ['email' => 'email|required']); + + if ($v->fails()) { + $errors = $v->errors()->toArray(); + } else { + $invitations[] = ['email' => $request->email]; + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $count = 0; + foreach ($invitations as $idx => $invitation) { + SignupInvitation::create($invitation); + $count++; + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans_choice('app.signup-invitations-created', $count, ['count' => $count]), + 'count' => $count, + ]); + } + + /** + * Display the specified resource. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + return $this->errorResponse(404); + } + + /** + * Update the specified resource in storage. + * + * @param \Illuminate\Http\Request $request + * @param int $id + * + * @return \Illuminate\Http\JsonResponse + */ + public function update(Request $request, $id) + { + return $this->errorResponse(404); + } + + /** + * Convert an invitation object to an array for output + * + * @param \App\SignupInvitation $invitation The signup invitation object + * + * @return array + */ + protected static function invitationToArray(SignupInvitation $invitation): array + { + return [ + 'id' => $invitation->id, + 'email' => $invitation->email, + 'isNew' => $invitation->isNew(), + 'isSent' => $invitation->isSent(), + 'isFailed' => $invitation->isFailed(), + 'isCompleted' => $invitation->isCompleted(), + 'created' => $invitation->created_at->toDateTimeString(), + ]; + } +} diff --git a/src/app/Http/Controllers/API/V4/Reseller/PackagesController.php b/src/app/Http/Controllers/API/V4/Reseller/PackagesController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/PackagesController.php @@ -0,0 +1,7 @@ +find($id); if (empty($user)) { return $this->errorResponse(404); diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php --- a/src/app/Http/Controllers/Controller.php +++ b/src/app/Http/Controllers/Controller.php @@ -24,7 +24,7 @@ * * @return \Illuminate\Http\JsonResponse */ - protected function errorResponse(int $code, string $message = null, array $data = []) + public static function errorResponse(int $code, string $message = null, array $data = []) { $errors = [ 400 => "Bad request", diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php --- a/src/app/Http/Kernel.php +++ b/src/app/Http/Kernel.php @@ -64,6 +64,7 @@ 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + 'reseller' => \App\Http\Middleware\AuthenticateReseller::class, 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, @@ -80,11 +81,11 @@ \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\AuthenticateAdmin::class, + \App\Http\Middleware\AuthenticateReseller::class, \App\Http\Middleware\Authenticate::class, \Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Auth\Middleware\Authorize::class, - \App\Http\Middleware\AuthenticateAdmin::class, ]; /** diff --git a/src/app/Http/Middleware/AuthenticateAdmin.php b/src/app/Http/Middleware/AuthenticateAdmin.php --- a/src/app/Http/Middleware/AuthenticateAdmin.php +++ b/src/app/Http/Middleware/AuthenticateAdmin.php @@ -18,7 +18,7 @@ $user = auth()->user(); if (!$user) { - abort(403, "Unauthorized"); + abort(401, "Unauthorized"); } if ($user->role !== "admin") { diff --git a/src/app/Http/Middleware/AuthenticateAdmin.php b/src/app/Http/Middleware/AuthenticateReseller.php copy from src/app/Http/Middleware/AuthenticateAdmin.php copy to src/app/Http/Middleware/AuthenticateReseller.php --- a/src/app/Http/Middleware/AuthenticateAdmin.php +++ b/src/app/Http/Middleware/AuthenticateReseller.php @@ -4,7 +4,7 @@ use Closure; -class AuthenticateAdmin +class AuthenticateReseller { /** * Handle an incoming request. @@ -18,10 +18,14 @@ $user = auth()->user(); if (!$user) { + abort(401, "Unauthorized"); + } + + if ($user->role !== "reseller") { abort(403, "Unauthorized"); } - if ($user->role !== "admin") { + if ($user->tenant_id != \config('app.tenant_id')) { abort(403, "Unauthorized"); } diff --git a/src/app/Jobs/SignupInvitationEmail.php b/src/app/Jobs/SignupInvitationEmail.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/SignupInvitationEmail.php @@ -0,0 +1,75 @@ +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 @@ +invitation = $invitation; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $href = Utils::serviceUrl('/signup/invite/' . $this->invitation->id); + + $this->view('emails.html.signup_invitation') + ->text('emails.plain.signup_invitation') + ->subject(__('mail.signupinvitation-subject', ['site' => \config('app.name')])) + ->with([ + 'site' => \config('app.name'), + 'href' => $href, + ]); + + return $this; + } + + /** + * Render the mail template with fake data + * + * @param string $type Output format ('html' or 'text') + * + * @return string HTML or Plain Text output + */ + public static function fakeRender(string $type = 'html'): string + { + $invitation = new SI([ + 'email' => 'test@external.org', + ]); + + $invitation->id = Utils::uuidStr(); + + $mail = new self($invitation); + + return Helper::render($mail, $type); + } +} diff --git a/src/app/Observers/DiscountObserver.php b/src/app/Observers/DiscountObserver.php --- a/src/app/Observers/DiscountObserver.php +++ b/src/app/Observers/DiscountObserver.php @@ -25,5 +25,7 @@ break; } } + + $discount->tenant_id = \config('app.tenant_id'); } } diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -27,6 +27,8 @@ $domain->namespace = \strtolower($domain->namespace); $domain->status |= Domain::STATUS_NEW; + + $domain->tenant_id = \config('app.tenant_id'); } /** diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -123,6 +123,7 @@ return; } + $fee = 0; $cost = 0; $now = Carbon::now(); @@ -132,6 +133,7 @@ // just in case this had not been billed yet, ever $diffInMonths = $entitlement->updated_at->diffInMonths($now); $cost += (int) ($entitlement->cost * $discount * $diffInMonths); + $fee += (int) ($entitlement->fee * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); @@ -153,8 +155,18 @@ } $pricePerDay = $entitlement->cost / $daysInMonth; + $feePerDay = $entitlement->fee / $daysInMonth; $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); + $fee += (int) (round($feePerDay * $diffInDays, 0)); + + $profit = $cost - $fee; + + if ($profit != 0 && $owner->tenant && ($wallet = $owner->tenant->wallet()) { + $desc = "Charged user {$this->owner->email}"; + $method = $profit > 0 ? 'credit' : 'debit'; + $wallet->{$method}(abs($profit), $desc); + } if ($cost == 0) { return; diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php --- a/src/app/Observers/GroupObserver.php +++ b/src/app/Observers/GroupObserver.php @@ -25,6 +25,8 @@ } $group->status |= Group::STATUS_NEW | Group::STATUS_ACTIVE; + + $group->tenant_id = \config('app.tenant_id'); } /** diff --git a/src/app/Observers/PackageObserver.php b/src/app/Observers/PackageObserver.php --- a/src/app/Observers/PackageObserver.php +++ b/src/app/Observers/PackageObserver.php @@ -27,5 +27,7 @@ break; } } + + $package->tenant_id = \config('app.tenant_id'); } } diff --git a/src/app/Observers/PackageSkuObserver.php b/src/app/Observers/PackageSkuObserver.php --- a/src/app/Observers/PackageSkuObserver.php +++ b/src/app/Observers/PackageSkuObserver.php @@ -6,6 +6,25 @@ class PackageSkuObserver { + /** + * Handle the "creating" event on an PackageSku relation. + * + * Ensures that the entries belong to the same tenant. + * + * @param \App\PackageSku $packageSku The package-sku relation + * + * @return void + */ + public function creating(PackageSku $packageSku) + { + $package = $packageSku->package; + $sku = $packageSku->sku; + + if ($package->tenant_id != $sku->tenant_id) { + throw new \Exception("Package and SKU owned by different tenants"); + } + } + /** * Handle the "created" event on an PackageSku relation * diff --git a/src/app/Observers/PlanObserver.php b/src/app/Observers/PlanObserver.php --- a/src/app/Observers/PlanObserver.php +++ b/src/app/Observers/PlanObserver.php @@ -27,5 +27,7 @@ break; } } + + $plan->tenant_id = \config('app.tenant_id'); } } diff --git a/src/app/Observers/PlanPackageObserver.php b/src/app/Observers/PlanPackageObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/PlanPackageObserver.php @@ -0,0 +1,27 @@ +package; + $plan = $planPackage->plan; + + if ($package->tenant_id != $plan->tenant_id) { + throw new \Exception("Package and Plan owned by different tenants"); + } + } +} diff --git a/src/app/Observers/SignupInvitationObserver.php b/src/app/Observers/SignupInvitationObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/SignupInvitationObserver.php @@ -0,0 +1,65 @@ +{$invitation->getKeyName()} = $allegedly_unique; + break; + } + } + + $invitation->status = SI::STATUS_NEW; + + $invitation->tenant_id = \config('app.tenant_id'); + } + + /** + * Handle the invitation "created" event. + * + * @param \App\SignupInvitation $invitation The invitation object + * + * @return void + */ + public function created(SI $invitation) + { + \App\Jobs\SignupInvitationEmail::dispatch($invitation); + } + + /** + * Handle the invitation "updated" event. + * + * @param \App\SignupInvitation $invitation The invitation object + * + * @return void + */ + public function updated(SI $invitation) + { + $oldStatus = $invitation->getOriginal('status'); + + // Resend the invitation + if ( + $invitation->status == SI::STATUS_NEW + && ($oldStatus == SI::STATUS_FAILED || $oldStatus == SI::STATUS_SENT) + ) { + \App\Jobs\SignupInvitationEmail::dispatch($invitation); + } + } +} diff --git a/src/app/Observers/SkuObserver.php b/src/app/Observers/SkuObserver.php --- a/src/app/Observers/SkuObserver.php +++ b/src/app/Observers/SkuObserver.php @@ -22,5 +22,9 @@ break; } } + + $sku->tenant_id = \config('app.tenant_id'); + + // TODO: We should make sure that tenant_id + title is unique } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -38,7 +38,7 @@ // only users that are not imported get the benefit of the doubt. $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE; - // can't dispatch job here because it'll fail serialization + $user->tenant_id = \config('app.tenant_id'); } /** diff --git a/src/app/Package.php b/src/app/Package.php --- a/src/app/Package.php +++ b/src/app/Package.php @@ -21,6 +21,13 @@ * * Free package: mailbox + quota. * * Selecting a package will therefore create a set of entitlments from SKUs. + * + * @property string $description + * @property int $discount_rate + * @property string $id + * @property string $name + * @property ?int $tenant_id + * @property string $title */ class Package extends Model { @@ -69,7 +76,10 @@ return $costs; } - public function isDomain() + /** + * Checks whether the package contains a domain SKU. + */ + public function isDomain(): bool { foreach ($this->skus as $sku) { if ($sku->handler_class::entitleableClass() == \App\Domain::class) { @@ -94,4 +104,14 @@ ['qty'] ); } + + /** + * The tenant for this package. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } } diff --git a/src/app/Plan.php b/src/app/Plan.php --- a/src/app/Plan.php +++ b/src/app/Plan.php @@ -13,7 +13,16 @@ * A "Family Plan" as such may exist of "2 or more Kolab packages", * and apply a discount for the third and further Kolab packages. * + * @property string $description + * @property int $discount_qty + * @property int $discount_rate + * @property string $id + * @property string $name * @property \App\Package[] $packages + * @property datetime $promo_from + * @property datetime $promo_to + * @property ?int $tenant_id + * @property string $title */ class Plan extends Model { @@ -105,4 +114,14 @@ return false; } + + /** + * The tenant for this plan. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } } diff --git a/src/app/PlanPackage.php b/src/app/PlanPackage.php --- a/src/app/PlanPackage.php +++ b/src/app/PlanPackage.php @@ -15,6 +15,7 @@ * @property int $qty_max * @property int $qty_min * @property \App\Package $package + * @property \App\Plan $plan */ class PlanPackage extends Pivot { @@ -54,8 +55,23 @@ return $costs; } + /** + * The package in this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ public function package() { return $this->belongsTo('App\Package'); } + + /** + * The plan in this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function plan() + { + return $this->belongsTo('App\Plan'); + } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -34,7 +35,9 @@ \App\Package::observe(\App\Observers\PackageObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\Plan::observe(\App\Observers\PlanObserver::class); + \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); + \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\Sku::observe(\App\Observers\SkuObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); \App\User::observe(\App\Observers\UserObserver::class); @@ -57,5 +60,50 @@ $path = trim($path, '/\'"'); return ""; }); + + // Query builder 'withEnvTenant' macro + Builder::macro('withEnvTenant', function (string $table = null) { + $tenant_id = \config('app.tenant_id'); + + if ($tenant_id) { + /** @var Builder $this */ + return $this->where(($table ? "$table." : '') . 'tenant_id', $tenant_id); + } + + /** @var Builder $this */ + return $this->whereNull(($table ? "$table." : '') . 'tenant_id'); + }); + + // Query builder 'withUserTenant' macro + Builder::macro('withUserTenant', function (string $table = null) { + $tenant_id = auth()->user()->tenant_id; + + if ($tenant_id) { + /** @var Builder $this */ + return $this->where(($table ? "$table." : '') . 'tenant_id', $tenant_id); + } + + /** @var Builder $this */ + return $this->whereNull(($table ? "$table." : '') . 'tenant_id'); + }); + + // Query builder 'whereLike' mocro + Builder::macro('whereLike', function (string $column, string $search, int $mode = 0) { + $search = addcslashes($search, '%_'); + + switch ($mode) { + case 2: + $search .= '%'; + break; + case 1: + $search = '%' . $search; + break; + default: + $search = '%' . $search . '%'; + } + + /** @var Builder $this */ + return $this->where($column, 'like', $search); + }); } } diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -261,6 +261,8 @@ $refund['status'] = self::STATUS_PAID; $refund['amount'] = -1 * $amount; + // FIXME: Refunds/chargebacks are out of the reseller comissioning for now + $this->storePayment($refund, $wallet->id); } 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 @@ +status & self::STATUS_COMPLETED) > 0; + } + + /** + * Returns whether this invitation sending failed. + * + * @return bool + */ + public function isFailed(): bool + { + return ($this->status & self::STATUS_FAILED) > 0; + } + + /** + * Returns whether this invitation is new. + * + * @return bool + */ + public function isNew(): bool + { + return ($this->status & self::STATUS_NEW) > 0; + } + + /** + * Returns whether this invitation has been sent. + * + * @return bool + */ + public function isSent(): bool + { + return ($this->status & self::STATUS_SENT) > 0; + } + + /** + * The tenant for this invitation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } + + /** + * The account to which the invitation was used for. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo('App\User', 'user_id', 'id'); + } +} diff --git a/src/app/Sku.php b/src/app/Sku.php --- a/src/app/Sku.php +++ b/src/app/Sku.php @@ -7,6 +7,18 @@ /** * The eloquent definition of a Stock Keeping Unit (SKU). + * + * @property bool $active + * @property int $cost + * @property string $description + * @property int $fee The fee that the tenant pays to us + * @property string $handler_class + * @property string $id + * @property string $name + * @property string $period + * @property ?int $tenant_id + * @property string $title + * @property int $units_free */ class Sku extends Model { @@ -23,6 +35,7 @@ 'active', 'cost', 'description', + 'fee', 'handler_class', 'name', // persist for annual domain registration @@ -59,4 +72,14 @@ 'package_skus' )->using('App\PackageSku')->withPivot(['cost', 'qty']); } + + /** + * The tenant for this SKU. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } } diff --git a/src/app/Tenant.php b/src/app/Tenant.php new file mode 100644 --- /dev/null +++ b/src/app/Tenant.php @@ -0,0 +1,52 @@ +hasMany('App\Discount'); + } + + /** + * SignupInvitations assigned to this tenant. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function signupInvitations() + { + return $this->hasMany('App\SignupInvitation'); + } + + /* + * Returns the wallet of the tanant (reseller's wallet). + * + * @return ?\App\Wallet A wallet object + */ + public function wallet(): ?Wallet + { + \App\User::where('role', 'reseller')->where('tenant_id', $this->id)->first(); + + return $user ? $user->wallets->first() : null; + } +} diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -20,6 +20,7 @@ * @property int $id * @property string $password * @property int $status + * @property int $tenant_id */ class User extends Authenticatable implements JWTSubject { @@ -56,7 +57,7 @@ 'email', 'password', 'password_ldap', - 'status' + 'status', ]; /** @@ -125,6 +126,7 @@ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), + 'fee' => $sku->fee, 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] @@ -178,6 +180,7 @@ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); @@ -192,7 +195,7 @@ /** * Check if current user can delete another object. * - * @param \App\User|\App\Domain $object A user|domain object + * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ @@ -213,13 +216,13 @@ /** * Check if current user can read data of another object. * - * @param \App\User|\App\Domain|\App\Wallet $object A user|domain|wallet object + * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { - if ($this->role == "admin") { + if ($this->role == 'admin') { return true; } @@ -227,6 +230,18 @@ return true; } + if ($this->role == 'reseller') { + if ($object instanceof User && $object->role == 'admin') { + return false; + } + + if ($object instanceof Wallet && !empty($object->owner)) { + $object = $object->owner; + } + + return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; + } + if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } @@ -237,26 +252,38 @@ $wallet = $object->wallet(); - return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); + return $wallet && ($this->wallets->contains($wallet) || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * - * @param \App\User|\App\Domain $object A user|domain object + * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { - if (!method_exists($object, 'wallet')) { - return false; + if ($object instanceof User && $this->id == $object->id) { + return true; } - if ($object instanceof User && $this->id == $object->id) { + if ($this->role == 'admin') { return true; } + if ($this->role == 'reseller') { + if ($object instanceof User && $object->role == 'admin') { + return false; + } + + if ($object instanceof Wallet && !empty($object->owner)) { + $object = $object->owner; + } + + return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; + } + return $this->canDelete($object); } @@ -579,6 +606,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 @@ -363,7 +363,6 @@ $countries = include resource_path('countries.php'); $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); $sys_domain = \config('app.domain'); - $path = request()->path(); $opts = [ 'app.name', 'app.url', @@ -382,6 +381,8 @@ if ($req_domain == "admin.$sys_domain") { $env['jsapp'] = 'admin.js'; + } elseif ($req_domain == "reseller.$sys_domain") { + $env['jsapp'] = 'reseller.js'; } $env['paymentProvider'] = \config('services.payment_provider'); diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -76,6 +76,7 @@ return 0; } + $profit = 0; $charges = 0; $discount = $this->getDiscountRate(); @@ -101,8 +102,10 @@ $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); + $fee = (int) ($entitlement->fee * $diff); $charges += $cost; + $profit += $cost - $fee; // if we're in dry-run, you know... if (!$apply) { @@ -127,6 +130,16 @@ if ($apply) { $this->debit($charges, $entitlementTransactions); + + // Credit/debit the reseller + if ($profit != 0 && $this->owner->tenant) { + // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) + if ($wallet = $this->owner->tenant->wallet()) { + $desc = "Charged user {$this->owner->email}"; + $method = $profit > 0 ? 'credit' : 'debit'; + $wallet->{$method}(abs($profit), $desc); + } + } } DB::commit(); diff --git a/src/config/app.php b/src/config/app.php --- a/src/config/app.php +++ b/src/config/app.php @@ -65,6 +65,8 @@ 'theme' => env('APP_THEME', 'default'), + 'tenant_id' => env('APP_TENANT_ID', null), + /* |-------------------------------------------------------------------------- | Application Domain diff --git a/src/config/database.php b/src/config/database.php --- a/src/config/database.php +++ b/src/config/database.php @@ -62,6 +62,7 @@ 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, + 'timezone' => '+00:00', 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), diff --git a/src/database/migrations/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 @@ +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/2020_10_29_100000_add_beta_skus.php b/src/database/migrations/2020_10_29_100000_add_beta_skus.php --- a/src/database/migrations/2020_10_29_100000_add_beta_skus.php +++ b/src/database/migrations/2020_10_29_100000_add_beta_skus.php @@ -14,31 +14,7 @@ */ public function up() { - if (!\App\Sku::where('title', 'beta')->first()) { - \App\Sku::create([ - 'title' => 'beta', - 'name' => 'Beta program', - 'description' => 'Access to beta program subscriptions', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta', - 'active' => false, - ]); - } - - if (!\App\Sku::where('title', 'meet')->first()) { - \App\Sku::create([ - 'title' => 'meet', - 'name' => 'Video chat', - 'description' => 'Video conferencing tool', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta\Meet', - 'active' => true, - ]); - } + // empty } /** diff --git a/src/database/migrations/2020_12_28_140000_create_groups_table.php b/src/database/migrations/2020_12_28_140000_create_groups_table.php --- a/src/database/migrations/2020_12_28_140000_create_groups_table.php +++ b/src/database/migrations/2020_12_28_140000_create_groups_table.php @@ -28,19 +28,6 @@ $table->primary('id'); } ); - - if (!\App\Sku::where('title', 'group')->first()) { - \App\Sku::create([ - 'title' => 'group', - 'name' => 'Group', - 'description' => 'Distribution list', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Group', - 'active' => true, - ]); - } } /** diff --git a/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php b/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php --- a/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php +++ b/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php @@ -15,14 +15,20 @@ public function up() { $beta_sku = \App\Sku::where('title', 'beta')->first(); - $beta_sku->name = 'Private Beta (invitation only)'; - $beta_sku->description = 'Access to the private beta program subscriptions'; - $beta_sku->save(); + + if ($beta_sku) { + $beta_sku->name = 'Private Beta (invitation only)'; + $beta_sku->description = 'Access to the private beta program subscriptions'; + $beta_sku->save(); + } $meet_sku = \App\Sku::where('title', 'meet')->first(); - $meet_sku->name = 'Voice & Video Conferencing (public beta)'; - $meet_sku->handler_class = 'App\Handlers\Meet'; - $meet_sku->save(); + + if ($meet_sku) { + $meet_sku->name = 'Voice & Video Conferencing (public beta)'; + $meet_sku->handler_class = 'App\Handlers\Meet'; + $meet_sku->save(); + } } /** diff --git a/src/database/migrations/2021_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 @@ +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( + '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 @@ +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( + '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 @@ +string('id', 36); + $table->string('email'); + $table->smallInteger('status'); + $table->bigInteger('user_id')->nullable(); + $table->bigInteger('tenant_id')->unsigned()->nullable(); + $table->timestamps(); + + $table->primary('id'); + + $table->index('email'); + $table->index('created_at'); + + $table->foreign('tenant_id')->references('id')->on('tenants') + ->onUpdate('cascade')->onDelete('set null'); + $table->foreign('user_id')->references('id')->on('users') + ->onUpdate('cascade')->onDelete('set null'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('signup_invitations'); + } +} diff --git a/src/database/migrations/2021_04_09_100000_tenant_comissioning.php b/src/database/migrations/2021_04_09_100000_tenant_comissioning.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2021_04_09_100000_tenant_comissioning.php @@ -0,0 +1,71 @@ +bigInteger('tenant_id')->unsigned()->nullable(); + + $table->foreign('tenant_id')->references('id')->on('tenants') + ->onDelete('cascade')->onUpdate('cascade'); + } + ); + } + + // Add fee column + foreach (['entitlements', 'skus'] as $table) { + Schema::table( + $table, + function (Blueprint $table) { + $table->integer('fee')->nullable(); + } + ); + } + + // FIXME: Should we also have package_skus.fee ? + // We have package_skus.cost, but I think it is not used anywhere. + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + foreach (['plans', 'packages', 'skus'] as $table) { + Schema::table( + $table, + function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropColumn('tenant_id'); + } + ); + } + + foreach (['entitlements', 'skus'] as $table) { + Schema::table( + $table, + function (Blueprint $table) { + $table->dropColumn('fee'); + } + ); + } + } +} diff --git a/src/database/migrations/2021_05_07_150000_groups_add_tenant_id.php b/src/database/migrations/2021_05_07_150000_groups_add_tenant_id.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2021_05_07_150000_groups_add_tenant_id.php @@ -0,0 +1,42 @@ +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( + 'groups', + function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropColumn('tenant_id'); + } + ); + } +} diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php --- a/src/database/seeds/DatabaseSeeder.php +++ b/src/database/seeds/DatabaseSeeder.php @@ -14,6 +14,7 @@ { // Define seeders order $seeders = [ + 'TenantSeeder', 'DiscountSeeder', 'DomainSeeder', 'SkuSeeder', diff --git a/src/database/seeds/local/DomainSeeder.php b/src/database/seeds/local/DomainSeeder.php --- a/src/database/seeds/local/DomainSeeder.php +++ b/src/database/seeds/local/DomainSeeder.php @@ -63,5 +63,19 @@ ] ); } + + // example tenant domain, note that 'tenant_id' is not a fillable. + $domain = Domain::create( + [ + 'namespace' => 'example-tenant.dev-local', + 'status' => Domain::STATUS_CONFIRMED + Domain::STATUS_ACTIVE, + 'type' => Domain::TYPE_PUBLIC + ] + ); + + $tenant = \App\Tenant::where('title', 'Sample Tenant')->first(); + + $domain->tenant_id = $tenant->id; + $domain->save(); } } diff --git a/src/database/seeds/local/TenantSeeder.php b/src/database/seeds/local/TenantSeeder.php new file mode 100644 --- /dev/null +++ b/src/database/seeds/local/TenantSeeder.php @@ -0,0 +1,29 @@ + '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/phpstan.neon b/src/phpstan.neon --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -4,8 +4,12 @@ ignoreErrors: - '#Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable#' - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' + - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenant\(\)#' + - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withUserTenant\(\)#' - '#Call to an undefined method Tests\\Browser::#' level: 4 + parallel: + processTimeout: 300.0 paths: - app/ - tests/ diff --git a/src/resources/js/admin.js b/src/resources/js/admin/app.js rename from src/resources/js/admin.js rename to src/resources/js/admin/app.js --- a/src/resources/js/admin.js +++ b/src/resources/js/admin/app.js @@ -2,9 +2,9 @@ * Application code for the admin UI */ -import routes from './routes-admin.js' +import routes from './routes.js' window.routes = routes window.isAdmin = true -require('./app') +require('../app') diff --git a/src/resources/js/routes-admin.js b/src/resources/js/admin/routes.js rename from src/resources/js/routes-admin.js rename to src/resources/js/admin/routes.js --- a/src/resources/js/routes-admin.js +++ b/src/resources/js/admin/routes.js @@ -1,11 +1,11 @@ -import DashboardComponent from '../vue/Admin/Dashboard' -import DistlistComponent from '../vue/Admin/Distlist' -import DomainComponent from '../vue/Admin/Domain' -import LoginComponent from '../vue/Login' -import LogoutComponent from '../vue/Logout' -import PageComponent from '../vue/Page' -import StatsComponent from '../vue/Admin/Stats' -import UserComponent from '../vue/Admin/User' +import DashboardComponent from '../../vue/Admin/Dashboard' +import DistlistComponent from '../../vue/Admin/Distlist' +import DomainComponent from '../../vue/Admin/Domain' +import LoginComponent from '../../vue/Login' +import LogoutComponent from '../../vue/Logout' +import PageComponent from '../../vue/Page' +import StatsComponent from '../../vue/Admin/Stats' +import UserComponent from '../../vue/Admin/User' const routes = [ { diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -79,7 +79,7 @@ router: window.router, data() { return { - isAdmin: window.isAdmin, + isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] @@ -459,7 +459,7 @@ } if (input.length) { - // Create an error message\ + // Create an error message // API responses can use a string, array or object let msg_text = '' if ($.type(msg) !== 'string') { diff --git a/src/resources/js/reseller/app.js b/src/resources/js/reseller/app.js new file mode 100644 --- /dev/null +++ b/src/resources/js/reseller/app.js @@ -0,0 +1,10 @@ +/** + * Application code for the reseller UI + */ + +import routes from './routes.js' + +window.routes = routes +window.isReseller = true + +require('../app') diff --git a/src/resources/js/routes-admin.js b/src/resources/js/reseller/routes.js rename from src/resources/js/routes-admin.js rename to src/resources/js/reseller/routes.js --- a/src/resources/js/routes-admin.js +++ b/src/resources/js/reseller/routes.js @@ -1,11 +1,12 @@ -import DashboardComponent from '../vue/Admin/Dashboard' -import DistlistComponent from '../vue/Admin/Distlist' -import DomainComponent from '../vue/Admin/Domain' -import LoginComponent from '../vue/Login' -import LogoutComponent from '../vue/Logout' -import PageComponent from '../vue/Page' -import StatsComponent from '../vue/Admin/Stats' -import UserComponent from '../vue/Admin/User' +import DashboardComponent from '../../vue/Reseller/Dashboard' +import DistlistComponent from '../../vue/Admin/Distlist' +import DomainComponent from '../../vue/Admin/Domain' +import InvitationsComponent from '../../vue/Reseller/Invitations' +import LoginComponent from '../../vue/Login' +import LogoutComponent from '../../vue/Logout' +import PageComponent from '../../vue/Page' +import StatsComponent from '../../vue/Reseller/Stats' +import UserComponent from '../../vue/Admin/User' const routes = [ { @@ -40,6 +41,12 @@ name: 'logout', component: LogoutComponent }, + { + path: '/invitations', + name: 'invitations', + component: InvitationsComponent, + meta: { requiresAuth: true } + }, { path: '/stats', name: 'stats', diff --git a/src/resources/js/user.js b/src/resources/js/user/app.js rename from src/resources/js/user.js rename to src/resources/js/user/app.js --- a/src/resources/js/user.js +++ b/src/resources/js/user/app.js @@ -2,9 +2,10 @@ * Application code for the user UI */ -import routes from './routes-user.js' +import routes from './routes.js' window.routes = routes window.isAdmin = false +window.isReseller = false -require('./app') +require('../app') diff --git a/src/resources/js/routes-user.js b/src/resources/js/user/routes.js rename from src/resources/js/routes-user.js rename to src/resources/js/user/routes.js --- a/src/resources/js/routes-user.js +++ b/src/resources/js/user/routes.js @@ -1,24 +1,24 @@ -import DashboardComponent from '../vue/Dashboard' -import DistlistInfoComponent from '../vue/Distlist/Info' -import DistlistListComponent from '../vue/Distlist/List' -import DomainInfoComponent from '../vue/Domain/Info' -import DomainListComponent from '../vue/Domain/List' -import LoginComponent from '../vue/Login' -import LogoutComponent from '../vue/Logout' -import MeetComponent from '../vue/Rooms' -import PageComponent from '../vue/Page' -import PasswordResetComponent from '../vue/PasswordReset' -import SignupComponent from '../vue/Signup' -import UserInfoComponent from '../vue/User/Info' -import UserListComponent from '../vue/User/List' -import UserProfileComponent from '../vue/User/Profile' -import UserProfileDeleteComponent from '../vue/User/ProfileDelete' -import WalletComponent from '../vue/Wallet' +import DashboardComponent from '../../vue/Dashboard' +import DistlistInfoComponent from '../../vue/Distlist/Info' +import DistlistListComponent from '../../vue/Distlist/List' +import DomainInfoComponent from '../../vue/Domain/Info' +import DomainListComponent from '../../vue/Domain/List' +import LoginComponent from '../../vue/Login' +import LogoutComponent from '../../vue/Logout' +import MeetComponent from '../../vue/Rooms' +import PageComponent from '../../vue/Page' +import PasswordResetComponent from '../../vue/PasswordReset' +import SignupComponent from '../../vue/Signup' +import UserInfoComponent from '../../vue/User/Info' +import UserListComponent from '../../vue/User/List' +import UserProfileComponent from '../../vue/User/Profile' +import UserProfileDeleteComponent from '../../vue/User/ProfileDelete' +import WalletComponent from '../../vue/Wallet' // Here's a list of lazy-loaded components // Note: you can pack multiple components into the same chunk, webpackChunkName // is also used to get a sensible file name instead of numbers -const RoomComponent = () => import(/* webpackChunkName: "room" */ '../vue/Meet/Room.vue') +const RoomComponent = () => import(/* webpackChunkName: "room" */ '../../vue/Meet/Room.vue') const routes = [ { @@ -90,6 +90,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 @@ -57,6 +57,12 @@ 'search-foundxgroups' => ':x distribution lists have been found.', 'search-foundxusers' => ':x user accounts have been found.', + 'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.', + 'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.', + 'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.', + 'signup-invitation-delete-success' => 'Invitation deleted successfully.', + 'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.', + 'support-request-success' => 'Support request submitted successfully.', 'support-request-error' => 'Failed to submit the support request.', diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -75,6 +75,11 @@ 'signupcode-body1' => "This is your verification code for the :site registration process:", 'signupcode-body2' => "You can also click the link below to continue the registration process:", + 'signupinvitation-subject' => ":site Invitation", + 'signupinvitation-header' => "Hi,", + 'signupinvitation-body1' => "You have been invited to join :site. Click the link below to sign up.", + 'signupinvitation-body2' => "", + 'suspendeddebtor-subject' => ":site Account Suspended", 'suspendeddebtor-body' => "You have been behind on paying for your :site account " ."for over :days days. Your account has been suspended.", diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -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 @@ { "label": "explore", "location": "https://kolabnow.com/", - "admin": true + "admin": true, + "reseller": true }, { "label": "blog", "location": "https://blogs.kolabnow.com/", - "admin": true + "admin": true, + "reseller": true }, { "label": "support", "location": "/support", "page": "support", - "admin": true + "admin": true, + "reseller": true }, { "label": "tos", "location": "https://kolabnow.com/tos", - "footer": true + "footer": true, + "reseller": true } ], "faq": { diff --git a/src/resources/views/emails/html/signup_invitation.blade.php b/src/resources/views/emails/html/signup_invitation.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/signup_invitation.blade.php @@ -0,0 +1,18 @@ + + + + + + +

{{ __('mail.signupinvitation-header') }}

+ +

{{ __('mail.signupinvitation-body1', ['site' => $site]) }}

+ +

{!! $href !!}

+ +

{{ __('mail.signupinvitation-body2') }}

+ +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + 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 @@ diff --git a/src/resources/vue/Admin/Stats.vue b/src/resources/vue/Admin/Stats.vue --- a/src/resources/vue/Admin/Stats.vue +++ b/src/resources/vue/Admin/Stats.vue @@ -9,11 +9,12 @@ export default { data() { return { - charts: {} + charts: {}, + chartTypes: ['users', 'users-all', 'income', 'discounts'] } }, mounted() { - ['users', 'users-all', 'income', 'discounts'].forEach(chart => this.loadChart(chart)) + this.chartTypes.forEach(chart => this.loadChart(chart)) }, methods: { drawChart(name, data) { diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -23,7 +23,7 @@ -
+
@@ -43,8 +43,8 @@
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,27 @@ + + + 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 @@ + + + diff --git a/src/resources/vue/Reseller/Stats.vue b/src/resources/vue/Reseller/Stats.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Reseller/Stats.vue @@ -0,0 +1,18 @@ + + + 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 @@