Page MenuHomePhorge

D4707.1774886035.diff
No OneTemporary

Authored By
Unknown
Size
18 KB
Referenced Files
None
Subscribers
None

D4707.1774886035.diff

diff --git a/doc/RESELLER.md b/doc/RESELLER.md
--- a/doc/RESELLER.md
+++ b/doc/RESELLER.md
@@ -21,7 +21,41 @@
## Creating tenant/resellers/domains
-TODO: How to create tenant, resellers, public domains?
+1. Create a new reseller user
+
+```
+php artisan user:create admin@reseller.kolab.io --role=reseller
+```
+
+2. Create a tenant
+
+```
+php artisan tenant:create admin@reseller.kolab.io --title="Reseller Company"
+```
+
+3. Create a public domain (for customer signups) - this should be executed
+on the tenant system.
+
+```
+php artisan scalpel:domain:create --namespace=reseller.kolab.io --type=1 --status=18
+```
+
+4. List all tenants in the system
+
+```
+php artisan tenants --attr=title
+```
+
+5. Managing tenant settings
+
+```
+php artisan tenant:list-settings
+php artisan scalpel:tenant-setting:create --tenant_id=<tenant-id> --key=mail.sender.address --value=noreply@reseller.kolab.io
+php artisan scalpel:tenant-setting:update <setting-id> --value=noreply@reseller.kolab.io
+```
+For proper operation some settings need to be set for a tenant. They include:
+`app.public_url`, `app.url`, `app.name`, `app.support_url`,
+`mail.sender.address`, `mail.sender.name`, `mail.replyto.address`, `mail.replyto.name`.
## Plans and Packages
@@ -30,12 +64,29 @@
to define fees and cost for these new SKUs. Also maybe he does not need all of the plans/packages
or wants some new ones?
-TODO: How? With deployment seeder or CLI commands, admin UI?
+The commands below need to be executed on the tenant system.
+1. Listing plans/packages
-## Fees
+```
+php artisan plan:packages
+php artisan package:skus
+```
+
+2. Listing all SKUs
+
+```
+php artisan skus --attr=title --attr=cost --attr=fee
+```
+
+3. Modifying SKU
-TODO: How to set a fee? With deployment seeder or CLI commands, admin UI?
+```
+php artisan scalpel:sku:update <sku-id> --cost=1000 --fee=900
+```
+
+
+## Fees
Every SKU has a cost and fee defined. Both are monetary values (not percents).
Cost is what a customer is paying. Fee is what the system owner gets.
@@ -51,5 +102,3 @@
Fees are getting applied to the tenant's wallet. Note that currently we're using
wallet of the first tenant's reseller user (see `Tenant::wallet()`). I.e. it will have to change if we
wanted to allow tenants to have more than one staff member.
-
-TODO: Examples?
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
@@ -30,6 +30,40 @@
protected $dangerous = false;
+ /**
+ * Apply current tenant context to the query
+ *
+ * @param mixed $object The model class object
+ *
+ * @return mixed The model class object
+ */
+ protected function applyTenant($object)
+ {
+ if ($this->commandPrefix == 'scalpel') {
+ return $object;
+ }
+
+ $modelsWithOwner = [
+ \App\Wallet::class,
+ ];
+
+ $tenantId = \config('app.tenant_id');
+
+ // Add tenant filter
+ if (in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($object::class))) {
+ $object = $object->withEnvTenantContext();
+ } elseif (in_array($object::class, $modelsWithOwner)) {
+ $object = $object->whereExists(function ($query) use ($tenantId) {
+ $query->select(DB::raw(1))
+ ->from('users')
+ ->whereRaw('wallets.user_id = users.id')
+ ->whereRaw('users.tenant_id ' . ($tenantId ? "= $tenantId" : 'is null'));
+ });
+ }
+
+ return $object;
+ }
+
/**
* Shortcut to creating a progress bar of a particular format with a particular message.
*
@@ -123,29 +157,7 @@
$model = new $objectClass();
}
- if ($this->commandPrefix == 'scalpel') {
- return $model;
- }
-
- $modelsWithOwner = [
- \App\Wallet::class,
- ];
-
- $tenantId = \config('app.tenant_id');
-
- // Add tenant filter
- if (in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($objectClass))) {
- $model = $model->withEnvTenantContext();
- } elseif (in_array($objectClass, $modelsWithOwner)) {
- $model = $model->whereExists(function ($query) use ($tenantId) {
- $query->select(DB::raw(1))
- ->from('users')
- ->whereRaw('wallets.user_id = users.id')
- ->whereRaw('users.tenant_id ' . ($tenantId ? "= $tenantId" : 'is null'));
- });
- }
-
- return $model;
+ return $this->applyTenant($model);
}
/**
diff --git a/src/app/Console/Commands/Scalpel/Sku/UpdateCommand.php b/src/app/Console/Commands/Scalpel/Sku/UpdateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/Sku/UpdateCommand.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\Sku;
+
+use App\Console\ObjectUpdateCommand;
+
+class UpdateCommand extends ObjectUpdateCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\Sku::class;
+ protected $objectName = 'sku';
+ protected $objectTitle = null; // SKU title is not unique
+}
diff --git a/src/app/Console/Commands/SkusCommand.php b/src/app/Console/Commands/SkusCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/SkusCommand.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Console\ObjectListCommand;
+
+class SkusCommand extends ObjectListCommand
+{
+ protected $objectClass = \App\Sku::class;
+ protected $objectName = 'sku';
+ protected $objectTitle = 'title';
+}
diff --git a/src/app/Console/Commands/TenantsCommand.php b/src/app/Console/Commands/TenantsCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/TenantsCommand.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Console\ObjectListCommand;
+
+class TenantsCommand extends ObjectListCommand
+{
+ protected $objectClass = \App\Tenant::class;
+ protected $objectName = 'tenant';
+ protected $objectTitle = 'title';
+}
diff --git a/src/app/Console/Commands/User/CreateCommand.php b/src/app/Console/Commands/User/CreateCommand.php
--- a/src/app/Console/Commands/User/CreateCommand.php
+++ b/src/app/Console/Commands/User/CreateCommand.php
@@ -2,8 +2,11 @@
namespace App\Console\Commands\User;
-use Illuminate\Support\Facades\DB;
use App\Http\Controllers\API\V4\UsersController;
+use App\Rules\ExternalEmail;
+use App\User;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
class CreateCommand extends \App\Console\Command
{
@@ -33,46 +36,61 @@
$password = $this->option('password');
$role = $this->option('role');
- list($local, $domainName) = explode('@', $email, 2);
+ $existingDeletedUser = null;
+ $packagesToAssign = [];
- $domain = $this->getDomain($domainName);
+ if ($role === User::ROLE_ADMIN || $role === User::ROLE_RESELLER) {
+ if ($error = $this->validateUserWithRole($email)) {
+ $this->error($error);
+ return 1;
+ }
- if (!$domain) {
- $this->error("No such domain {$domainName}.");
- return 1;
- }
+ // TODO: Assigning user to an existing account
+ // TODO: Making him an operator of the reseller wallet
+ } else {
+ list($local, $domainName) = explode('@', $email, 2);
- if ($domain->isPublic()) {
- $this->error("Domain {$domainName} is public.");
- return 1;
- }
+ $domain = $this->getDomain($domainName);
- $owner = $domain->wallet()->owner;
- $existingDeletedUser = null;
+ if (!$domain) {
+ $this->error("No such domain {$domainName}.");
+ return 1;
+ }
- // Validate email address
- if ($error = UsersController::validateEmail($email, $owner, $existingDeletedUser)) {
- $this->error("{$email}: {$error}");
- return 1;
- }
+ if ($domain->isPublic()) {
+ $this->error("Domain {$domainName} is public.");
+ return 1;
+ }
- if (!$password) {
- $password = \App\Utils::generatePassphrase();
- }
+ $owner = $domain->wallet()->owner;
- $packagesToAssign = [];
- foreach ($packages as $package) {
- $userPackage = $this->getObject(\App\Package::class, $package, 'title', false);
- if (!$userPackage) {
- $this->error("Invalid package: {$package}");
+ // Validate email address
+ if ($error = UsersController::validateEmail($email, $owner, $existingDeletedUser)) {
+ $this->error("{$email}: {$error}");
return 1;
}
- $packagesToAssign[] = $userPackage;
+
+ foreach ($packages as $package) {
+ $userPackage = $this->getObject(\App\Package::class, $package, 'title', false);
+ if (!$userPackage) {
+ $this->error("Invalid package: {$package}");
+ return 1;
+ }
+ $packagesToAssign[] = $userPackage;
+ }
+ }
+
+ if (!$password) {
+ $password = \App\Utils::generatePassphrase();
}
- //TODO we need a central location for role validation
- if ($role && $role != "admin" && $role != "reseller") {
- $this->error("Tried to set an invalid role: {$role}");
+ try {
+ $user = new \App\User();
+ $user->email = $email;
+ $user->password = $password;
+ $user->role = $role;
+ } catch (\Exception $e) {
+ $this->error($e->getMessage());
return 1;
}
@@ -83,21 +101,52 @@
$existingDeletedUser->forceDelete();
}
- $user = \App\User::create(
- [
- 'email' => $email,
- 'password' => $password
- ]
- );
- $user->role = $role;
$user->save();
+ if (empty($owner)) {
+ $owner = $user;
+ }
+
foreach ($packagesToAssign as $package) {
- $user->assignPackage($package);
+ $owner->assignPackage($package, $user);
}
DB::commit();
$this->info((string) $user->id);
}
+
+ /**
+ * Validate email address for a new admin/reseller user
+ *
+ * @param string $email Email address
+ *
+ * @return ?string Error message
+ */
+ protected function validateUserWithRole($email): ?string
+ {
+ // Validate the email address (basicly just the syntax)
+ $v = Validator::make(
+ ['email' => $email],
+ ['email' => ['required', new ExternalEmail()]]
+ );
+
+ if ($v->fails()) {
+ return $v->errors()->toArray()['email'][0];
+ }
+
+ // Check if an email is already taken
+ if (
+ User::emailExists($email, true)
+ || User::aliasExists($email)
+ || \App\Group::emailExists($email, true)
+ || \App\Resource::emailExists($email, true)
+ || \App\SharedFolder::emailExists($email, true)
+ || \App\SharedFolder::aliasExists($email)
+ ) {
+ return "Email address is already in use";
+ }
+
+ return null;
+ }
}
diff --git a/src/app/Console/ObjectListCommand.php b/src/app/Console/ObjectListCommand.php
--- a/src/app/Console/ObjectListCommand.php
+++ b/src/app/Console/ObjectListCommand.php
@@ -43,6 +43,8 @@
$objects = new $this->objectClass();
}
+ $objects = $this->applyTenant($objects);
+
foreach ($this->option('filter') as $filter) {
$objects = $this->applyFilter($objects, $filter);
}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -22,13 +22,13 @@
/**
* The eloquent definition of a User.
*
- * @property string $email
- * @property int $id
- * @property string $password
- * @property string $password_ldap
- * @property string $role
- * @property int $status
- * @property int $tenant_id
+ * @property string $email
+ * @property int $id
+ * @property string $password
+ * @property string $password_ldap
+ * @property ?string $role
+ * @property int $status
+ * @property int $tenant_id
*/
class User extends Authenticatable
{
@@ -61,6 +61,9 @@
// a restricted user
public const STATUS_RESTRICTED = 1 << 7;
+ public const ROLE_ADMIN = 'admin';
+ public const ROLE_RESELLER = 'reseller';
+
/** @var int The allowed states for this object used in StatusPropertyTrait */
private int $allowed_states = self::STATUS_NEW |
self::STATUS_ACTIVE |
@@ -90,7 +93,8 @@
/** @var array<int, string> The attributes that can be null */
protected $nullable = [
'password',
- 'password_ldap'
+ 'password_ldap',
+ 'role',
];
/** @var array<string, string> The attributes that should be cast */
@@ -645,6 +649,20 @@
$this->setPasswordAttribute($password);
}
+ /**
+ * User role mutator
+ *
+ * @param ?string $role The user role
+ */
+ public function setRoleAttribute($role)
+ {
+ if ($role !== null && !in_array($role, [self::ROLE_ADMIN, self::ROLE_RESELLER])) {
+ throw new \Exception("Invalid role: {$role}");
+ }
+
+ $this->attributes['role'] = $role;
+ }
+
/**
* Suspend all users/domains/groups in this account.
*/
diff --git a/src/tests/Feature/Console/Tenant/CreateTest.php b/src/tests/Feature/Console/Tenant/CreateTest.php
--- a/src/tests/Feature/Console/Tenant/CreateTest.php
+++ b/src/tests/Feature/Console/Tenant/CreateTest.php
@@ -58,7 +58,7 @@
$this->assertEmpty($user->role);
$this->assertEquals($user->tenant_id, \config('app.tenant_id'));
- // User not existing
+ // Existing user
$code = \Artisan::call("tenant:create {$user->email} --title=\"Test Tenant\"");
$output = trim(\Artisan::output());
$this->assertSame(0, $code);
diff --git a/src/tests/Feature/Console/User/CreateTest.php b/src/tests/Feature/Console/User/CreateTest.php
--- a/src/tests/Feature/Console/User/CreateTest.php
+++ b/src/tests/Feature/Console/User/CreateTest.php
@@ -17,6 +17,7 @@
$this->deleteTestUser('user@kolab.org');
$this->deleteTestUser('admin@kolab.org');
+ $this->deleteTestUser('reseller@unknown.domain.tld');
}
/**
@@ -26,6 +27,7 @@
{
$this->deleteTestUser('user@kolab.org');
$this->deleteTestUser('admin@kolab.org');
+ $this->deleteTestUser('reseller@unknown.domain.tld');
parent::tearDown();
}
@@ -40,6 +42,18 @@
// Warning: We're not using artisan() here, as this will not
// allow us to test "empty output" cases
+ // Invalid email
+ $code = \Artisan::call("user:create jack..test@kolab.org");
+ $output = trim(\Artisan::output());
+ $this->assertSame(1, $code);
+ $this->assertSame("jack..test@kolab.org: The specified email is invalid.", $output);
+
+ // Non-existing domain
+ $code = \Artisan::call("user:create jack@kolab");
+ $output = trim(\Artisan::output());
+ $this->assertSame(1, $code);
+ $this->assertSame("No such domain kolab.", $output);
+
// Existing email
$code = \Artisan::call("user:create jack@kolab.org");
$output = trim(\Artisan::output());
@@ -58,19 +72,45 @@
$this->assertSame(1, $code);
$this->assertSame("Domain kolabnow.com is public.", $output);
- // Valid
- $code = \Artisan::call("user:create user@kolab.org");
+ // Valid (user)
+ $code = \Artisan::call("user:create user@kolab.org --package=kolab");
$output = trim(\Artisan::output());
$user = User::where('email', 'user@kolab.org')->first();
$this->assertSame(0, $code);
$this->assertEquals($user->id, $output);
+ $this->assertSame(1, $user->countEntitlementsBySku('mailbox'));
+ $this->assertSame(1, $user->countEntitlementsBySku('groupware'));
+ $this->assertSame(5, $user->countEntitlementsBySku('storage'));
- // Valid
- $code = \Artisan::call("user:create admin@kolab.org --package=kolab --role=admin --password=simple123");
+ // Valid (admin)
+ $code = \Artisan::call("user:create admin@kolab.org --role=admin --password=simple123");
$output = trim(\Artisan::output());
$user = User::where('email', 'admin@kolab.org')->first();
$this->assertSame(0, $code);
$this->assertEquals($user->id, $output);
- $this->assertEquals($user->role, "admin");
+ $this->assertEquals($user->role, User::ROLE_ADMIN);
+
+ // Valid (reseller)
+ $code = \Artisan::call("user:create reseller@unknown.domain.tld --role=reseller --password=simple123");
+ $output = trim(\Artisan::output());
+ $user = User::where('email', 'reseller@unknown.domain.tld')->first();
+ $this->assertSame(0, $code);
+ $this->assertEquals($user->id, $output);
+ $this->assertEquals($user->role, User::ROLE_RESELLER);
+
+ // Invalid role
+ $code = \Artisan::call("user:create unknwon@kolab.org --role=unknown");
+ $output = trim(\Artisan::output());
+ $this->assertSame(1, $code);
+ $this->assertSame("Invalid role: unknown", $output);
+
+ // Existing email (but with role-reseller)
+ $code = \Artisan::call("user:create jack@kolab.org --role=reseller");
+ $output = trim(\Artisan::output());
+ $user = User::where('email', 'reseller@unknown.domain.tld')->first();
+ $this->assertSame(1, $code);
+ $this->assertEquals("Email address is already in use", $output);
+
+ // TODO: Test a case where deleted user exists
}
}
diff --git a/src/tests/Unit/UserTest.php b/src/tests/Unit/UserTest.php
--- a/src/tests/Unit/UserTest.php
+++ b/src/tests/Unit/UserTest.php
@@ -65,6 +65,26 @@
$ldapUser->delete();
}
+ /**
+ * Test User role mutator
+ */
+ public function testSetRoleAttribute(): void
+ {
+ $user = new User(['email' => 'user@email.com']);
+
+ $user->role = User::ROLE_ADMIN;
+ $this->assertSame(User::ROLE_ADMIN, $user->role);
+
+ $user->role = User::ROLE_RESELLER;
+ $this->assertSame(User::ROLE_RESELLER, $user->role);
+
+ $user->role = null;
+ $this->assertSame(null, $user->role);
+
+ $this->expectException(\Exception::class);
+ $user->role = 'unknown';
+ }
+
/**
* Test basic User funtionality
*/

File Metadata

Mime Type
text/plain
Expires
Mon, Mar 30, 3:53 PM (4 d, 17 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18815961
Default Alt Text
D4707.1774886035.diff (18 KB)

Event Timeline