diff --git a/src/app/Backends/Storage.php b/src/app/Backends/Storage.php --- a/src/app/Backends/Storage.php +++ b/src/app/Backends/Storage.php @@ -64,7 +64,6 @@ public static function fileDownload(Item $file): StreamedResponse { $response = new StreamedResponse(); - $props = $file->getProperties(['name', 'size', 'mimetype']); // Prepare the file name for the Content-Disposition header diff --git a/src/app/Console/Commands/User/RestrictCommand.php b/src/app/Console/Commands/User/RestrictCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/User/RestrictCommand.php @@ -0,0 +1,39 @@ +getUser($this->argument('user')); + + if (!$user) { + $this->error('User not found.'); + return 1; + } + + $user->restrict(); + } +} diff --git a/src/app/Console/Commands/User/UnrestrictCommand.php b/src/app/Console/Commands/User/UnrestrictCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/User/UnrestrictCommand.php @@ -0,0 +1,39 @@ +getUser($this->argument('user')); + + if (!$user) { + $this->error('User not found.'); + return 1; + } + + $user->unrestrict(); + } +} 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 @@ -315,7 +315,6 @@ if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain_name, - 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); } @@ -324,6 +323,7 @@ $user = User::create([ 'email' => $login . '@' . $domain_name, 'password' => $request->password, + 'status' => User::STATUS_RESTRICTED, ]); if (!empty($discount)) { diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -249,6 +249,7 @@ $user = User::create([ 'email' => $request->email, 'password' => $request->password, + 'status' => $owner->isRestricted() ? User::STATUS_RESTRICTED : 0, ]); $this->activatePassCode($user); diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php --- a/src/app/Observers/WalletObserver.php +++ b/src/app/Observers/WalletObserver.php @@ -2,6 +2,7 @@ namespace App\Observers; +use App\User; use App\Wallet; /** @@ -107,5 +108,16 @@ } } } + + // Remove RESTRICTED flag from the wallet owner and all users in the wallet + if ($wallet->balance > $wallet->getOriginal('balance') && $wallet->owner && $wallet->owner->isRestricted()) { +\Log::info('>>>>>>>>>>'); + User::whereIn('id', $wallet->entitlements()->select('entitleable_id')->where('entitleable_type', User::class)) + ->each(function ($user) { + $user->unrestrict(); + }); + + $wallet->owner->unrestrict(); + } } } diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -58,6 +58,8 @@ public const STATUS_IMAP_READY = 1 << 5; // user in "limited feature-set" state public const STATUS_DEGRADED = 1 << 6; + // a restricted user + public const STATUS_RESTRICTED = 1 << 7; /** @var array The attributes that are mass assignable */ protected $fillable = [ @@ -154,7 +156,7 @@ */ public function canDelete($object): bool { - if (!method_exists($object, 'wallet')) { + if (!is_object($object) || !method_exists($object, 'wallet')) { return false; } @@ -393,6 +395,16 @@ return false; } + /** + * Returns whether this user is restricted. + * + * @return bool + */ + public function isRestricted(): bool + { + return ($this->status & self::STATUS_RESTRICTED) > 0; + } + /** * A shortcut to get the user name. * @@ -423,6 +435,21 @@ return $this->hasMany(UserPassword::class); } + /** + * Restrict this user. + * + * @return void + */ + public function restrict(): void + { + if ($this->isRestricted()) { + return; + } + + $this->status |= User::STATUS_RESTRICTED; + $this->save(); + } + /** * Return resources controlled by the current user. * @@ -517,6 +544,21 @@ $this->save(); } + /** + * Un-restrict this user. + * + * @return void + */ + public function unrestrict(): void + { + if (!$this->isRestricted()) { + return; + } + + $this->status ^= User::STATUS_RESTRICTED; + $this->save(); + } + /** * Return users controlled by the current user. * @@ -596,6 +638,7 @@ self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, self::STATUS_DEGRADED, + self::STATUS_RESTRICTED, ]; foreach ($allowed_values as $value) { diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -37,7 +37,7 @@ }, "require-dev": { "code-lts/doctum": "^5.5.1", - "laravel/dusk": "~6.22.0", + "laravel/dusk": "~7.5.0", "nunomaduro/larastan": "^2.0", "phpstan/phpstan": "^1.4", "phpunit/phpunit": "^9", diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -424,6 +424,7 @@ 'verify-domain' => "Verify domain", 'degraded' => "Degraded", 'deleted' => "Deleted", + 'restricted' => "Restricted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -26,6 +26,7 @@
{{ $root.statusText(user) }} + {{ $t('status.restricted') }}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -107,6 +107,7 @@ ->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})") ->assertSeeIn('.row:nth-child(3) label', 'Status') ->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active') + ->assertDontSeeIn('.row:nth-child(3) #status', 'Restricted') ->assertSeeIn('.row:nth-child(4) label', 'First Name') ->assertSeeIn('.row:nth-child(4) #first_name', 'Jack') ->assertSeeIn('.row:nth-child(5) label', 'Last Name') @@ -562,9 +563,11 @@ $user = $this->getTestUser('userstest1@kolabnow.com'); $sku2fa = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $user->assignSku($sku2fa); + $user->restrict(); SecondFactor::seed('userstest1@kolabnow.com'); $browser->visit(new UserPage($user->id)) + ->assertSeeIn('@user-info #status', 'Restricted') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) { $browser->waitFor('#reset2fa') diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -330,7 +330,8 @@ ->assertMissing('#password_confirmation') ->waitFor('#password-link code') ->assertSeeIn('#password-link code', $link) - ->assertSeeIn('#password-link div.form-text', "Press Submit to activate the link"); + ->assertSeeIn('#password-link div.form-text', "Press Submit to activate the link") + ->pause(100); // Test copy to clipboard /* TODO: Figure out how to give permission to do this operation diff --git a/src/tests/Feature/Console/User/RestrictTest.php b/src/tests/Feature/Console/User/RestrictTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/User/RestrictTest.php @@ -0,0 +1,58 @@ +deleteTestUser('user-restrict-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user-restrict-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing user + $code = \Artisan::call("user:restrict unknown@unknown.org"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + // Create a user account for degrade + $user = $this->getTestUser('user-restrict-test@kolabnow.com'); + + $this->assertFalse($user->isRestricted()); + + $code = \Artisan::call("user:restrict {$user->email}"); + $output = trim(\Artisan::output()); + + $user->refresh(); + + $this->assertTrue($user->isRestricted()); + $this->assertSame('', $output); + $this->assertSame(0, $code); + } +} diff --git a/src/tests/Feature/Console/User/ResyncTest.php b/src/tests/Feature/Console/User/ResyncTest.php --- a/src/tests/Feature/Console/User/ResyncTest.php +++ b/src/tests/Feature/Console/User/ResyncTest.php @@ -91,7 +91,7 @@ ]); // Remove all deleted users except one, to not interfere - User::withTrashed()->whereNotIn('id', [$user->id])->forceDelete(); + User::withTrashed()->whereNotNull('deleted_at')->whereNotIn('id', [$user->id])->forceDelete(); // Test run for all deleted users $code = \Artisan::call("user:resync --deleted-only"); diff --git a/src/tests/Feature/Console/User/UnrestrictTest.php b/src/tests/Feature/Console/User/UnrestrictTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/User/UnrestrictTest.php @@ -0,0 +1,57 @@ +deleteTestUser('user-restrict-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user-restrict-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing user + $code = \Artisan::call("user:unrestrict unknown@unknown.org"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + $user = $this->getTestUser('user-restrict-test@kolabnow.com', ['status' => \App\User::STATUS_RESTRICTED]); + + $this->assertTrue($user->isRestricted()); + + $code = \Artisan::call("user:unrestrict {$user->email}"); + $output = trim(\Artisan::output()); + + $user->refresh(); + + $this->assertFalse($user->isRestricted()); + $this->assertSame('', $output); + $this->assertSame(0, $code); + } +} diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -614,6 +614,7 @@ $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); + $this->assertTrue($user->isRestricted()); // Check if the code has been updated and soft-deleted $this->assertTrue($code->trashed()); @@ -746,6 +747,7 @@ $user = User::where('email', $login . '@' . $domain)->first(); $this->assertNotEmpty($user); + $this->assertTrue($user->isRestricted()); // Check user settings $this->assertSame($user_data['email'], $user->getSetting('external_email')); diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -893,6 +893,7 @@ $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); + $this->assertFalse($user->isRestricted()); /** @var \App\UserAlias[] $aliases */ $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); @@ -963,6 +964,29 @@ $response = $this->actingAs($user)->post("/api/v4/users", []); $response->assertStatus(403); + + // Test that creating a user in a restricted account creates a restricted user + $package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $domain = $this->getTestDomain( + 'userscontroller.com', + ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] + ); + $domain->assignPackage($package_domain, $owner); + $owner->restrict(); + + $post = [ + 'password' => 'simple123', + 'password_confirmation' => 'simple123', + 'email' => 'UsersControllerTest2@userscontroller.com', + 'package' => $package_kolab->id, + ]; + + $response = $this->actingAs($owner)->post("/api/v4/users", $post); + $response->assertStatus(200); + + $user = User::where('email', 'UsersControllerTest1@userscontroller.com')->first(); + $this->assertTrue($user->isRestricted()); } /** diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1151,6 +1151,42 @@ ); } + /** + * Test user account restrict() and unrestrict() + */ + public function testRestrictAndUnrestrict(): void + { + Queue::fake(); + + // Test an account with users, domain + $user = $this->getTestUser('UserAccountA@UserAccount.com'); + + $this->assertFalse($user->isRestricted()); + + $user->restrict(); + + $this->assertTrue($user->fresh()->isRestricted()); + + Queue::assertPushed( + \App\Jobs\User\UpdateJob::class, + function ($job) use ($user) { + return TestCase::getObjectProperty($job, 'userId') == $user->id; + }); + + Queue::fake(); // reset queue state + + $user->refresh(); + $user->unrestrict(); + + $this->assertFalse($user->fresh()->isRestricted()); + + Queue::assertPushed( + \App\Jobs\User\UpdateJob::class, + function ($job) use ($user) { + return TestCase::getObjectProperty($job, 'userId') == $user->id; + }); + } + /** * Tests for AliasesTrait::setAliases() */ diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -86,6 +86,35 @@ $this->assertFalse($user->isDegraded()); $this->assertNull($wallet->getSetting('balance_negative_since')); + // Test un-restricting users on balance change + $this->deleteTestUser('UserWallet1@UserWallet.com'); + $owner = $this->getTestUser('UserWallet1@UserWallet.com'); + $user1 = $this->getTestUser('UserWallet2@UserWallet.com'); + $user2 = $this->getTestUser('UserWallet3@UserWallet.com'); + $package = Package::withEnvTenantContext()->where('title', 'lite')->first(); + $owner->assignPackage($package, $user1); + $owner->assignPackage($package, $user2); + $wallet = $owner->wallets()->first(); + + $owner->restrict(); + $user1->restrict(); + $user2->restrict(); + + $this->assertTrue($owner->isRestricted()); + $this->assertTrue($user1->isRestricted()); + $this->assertTrue($user2->isRestricted()); + + Queue::fake(); + + $wallet->balance = 100; + $wallet->save(); + + $this->assertFalse($owner->fresh()->isRestricted()); + $this->assertFalse($user1->fresh()->isRestricted()); + $this->assertFalse($user2->fresh()->isRestricted()); + + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); + // TODO: Test group account and unsuspending domain/members/groups } 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 @@ -78,6 +78,7 @@ User::STATUS_IMAP_READY, User::STATUS_LDAP_READY, User::STATUS_DEGRADED, + User::STATUS_RESTRICTED, ]; $users = \App\Utils::powerSet($statuses); @@ -97,6 +98,7 @@ $this->assertTrue($user->isLdapReady() === in_array(User::STATUS_LDAP_READY, $user_statuses)); $this->assertTrue($user->isImapReady() === in_array(User::STATUS_IMAP_READY, $user_statuses)); $this->assertTrue($user->isDegraded() === in_array(User::STATUS_DEGRADED, $user_statuses)); + $this->assertTrue($user->isRestricted() === in_array(User::STATUS_RESTRICTED, $user_statuses)); } }