Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117879973
D4707.1775344381.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
18 KB
Referenced Files
None
Subscribers
None
D4707.1775344381.diff
View Options
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
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 11:13 PM (17 h, 11 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18815961
Default Alt Text
D4707.1775344381.diff (18 KB)
Attached To
Mode
D4707: CLI improvements for reseller system setup
Attached
Detach File
Event Timeline