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 @@ + 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/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 @@ +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 @@ +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 @@ +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 @@ +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'); + + while ($line = fgetcsv($fh)) { + if (count($line) >= 1) { + if ($line[0] && strpos($line[0], '@')) { + $email = trim($line[0]); + $v = Validator::make(['email' => $email], ['email' => 'email|required']); + + if (!$v->fails()) { + $invitations[] = ['email' => $email]; + } + } + } + } + + fclose($fh); + + if (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 @@ +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 @@ +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 @@ +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 @@ +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/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/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 ""; }); + + // 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,117 @@ + 'array']; + + /** + * 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 @@ +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 @@ +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 @@ +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 @@ +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,50 @@ +string('id', 36); + $table->string('email'); + $table->smallInteger('status'); + // $table->text('data')->nullable(); + $table->bigInteger('user_id')->nullable(); + $table->bigInteger('tenant_id')->unsigned()->nullable(); + $table->timestamps(); + + $table->primary('id'); + + $table->index('email'); + $table->index(['created_at', 'status']); + + $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 @@ + '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,11 @@ '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-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 @@ + + + + + + +

{{ __('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/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,284 @@ + + + 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 @@