diff --git a/src/app/Handlers/Groupware.php b/src/app/Handlers/Activesync.php copy from src/app/Handlers/Groupware.php copy to src/app/Handlers/Activesync.php --- a/src/app/Handlers/Groupware.php +++ b/src/app/Handlers/Activesync.php @@ -2,14 +2,14 @@ namespace App\Handlers; -class Groupware extends \App\Handlers\Base +class Activesync extends \App\Handlers\Base { public static function entitleableClass() { return \App\User::class; } - public static function preReq($entitlement, $user) + public static function preReq($entitlement, $object) { if (!$entitlement->sku->active) { \Log::error("Sku not active"); diff --git a/src/app/Handlers/Groupware.php b/src/app/Handlers/Auth2F.php copy from src/app/Handlers/Groupware.php copy to src/app/Handlers/Auth2F.php --- a/src/app/Handlers/Groupware.php +++ b/src/app/Handlers/Auth2F.php @@ -2,14 +2,14 @@ namespace App\Handlers; -class Groupware extends \App\Handlers\Base +class Auth2F extends \App\Handlers\Base { public static function entitleableClass() { return \App\User::class; } - public static function preReq($entitlement, $user) + public static function preReq($entitlement, $object) { if (!$entitlement->sku->active) { \Log::error("Sku not active"); diff --git a/src/app/Handlers/Base.php b/src/app/Handlers/Base.php --- a/src/app/Handlers/Base.php +++ b/src/app/Handlers/Base.php @@ -26,4 +26,15 @@ { // } + + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ + public static function priority(): int + { + return 0; + } } diff --git a/src/app/Handlers/Groupware.php b/src/app/Handlers/Groupware.php --- a/src/app/Handlers/Groupware.php +++ b/src/app/Handlers/Groupware.php @@ -18,4 +18,15 @@ return true; } + + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ + public static function priority(): int + { + return 80; + } } diff --git a/src/app/Handlers/Mailbox.php b/src/app/Handlers/Mailbox.php --- a/src/app/Handlers/Mailbox.php +++ b/src/app/Handlers/Mailbox.php @@ -36,4 +36,15 @@ */ return true; } + + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ + public static function priority(): int + { + return 100; + } } diff --git a/src/app/Handlers/Storage.php b/src/app/Handlers/Storage.php --- a/src/app/Handlers/Storage.php +++ b/src/app/Handlers/Storage.php @@ -14,8 +14,24 @@ public static function preReq($entitlement, $object) { + if (!$entitlement->sku->active) { + \Log::error("Sku not active"); + return false; + } + // TODO: The storage can not be modified to below what is already consumed. return true; } + + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ + public static function priority(): int + { + return 90; + } } diff --git a/src/app/Http/Controllers/API/SkusController.php b/src/app/Http/Controllers/API/SkusController.php --- a/src/app/Http/Controllers/API/SkusController.php +++ b/src/app/Http/Controllers/API/SkusController.php @@ -53,7 +53,7 @@ public function index() { $response = []; - $skus = Sku::select()->orderBy('title')->get(); + $skus = Sku::select()->get(); // Note: we do not limit the result to active SKUs only. // It's because we might need users assigned to old SKUs, @@ -65,6 +65,10 @@ } } + usort($response, function ($a, $b) { + return ($b['prio'] <=> $a['prio']); + }); + return response()->json($response); } @@ -137,6 +141,7 @@ $data['handler'] = $handler; $data['readonly'] = false; $data['enabled'] = false; + $data['prio'] = $sku->handler_class::priority(); // Use localized value, toArray() does not get them right $data['name'] = $sku->name; @@ -145,6 +150,14 @@ unset($data['handler_class']); switch ($handler) { + case 'activesync': + $data['required'] = ['groupware']; + break; + + case 'auth2f': + $data['forbidden'] = ['activesync']; + break; + case 'storage': // Quota range input $data['readonly'] = true; // only the checkbox will be disabled, not range diff --git a/src/database/seeds/SkuSeeder.php b/src/database/seeds/SkuSeeder.php --- a/src/database/seeds/SkuSeeder.php +++ b/src/database/seeds/SkuSeeder.php @@ -123,5 +123,31 @@ 'active' => false, ] ); + + Sku::create( + [ + 'title' => '2fa', + 'name' => '2-Factor Authentication', + 'description' => 'Two factor authentication for webmail and administration panel', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Auth2F', + 'active' => true, + ] + ); + + Sku::create( + [ + 'title' => 'activesync', + 'name' => 'Activesync', + 'description' => 'Mobile synchronization', + 'cost' => 100, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Activesync', + 'active' => true, + ] + ); } } diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -97,7 +97,12 @@ - + {{ sku.name }} @@ -235,6 +240,65 @@ } }) }, + onInputSku(e) { + let input = e.target + let sku = this.findSku(input.value) + let required = [] + + // We use 'readonly', not 'disabled', because we might want to handle + // input events. For example to display an error when someone clicks + // the locked input + if (input.readOnly) { + input.checked = !input.checked + // TODO: Display an alert explaining why it's locked + return + } + + // TODO: Following code might not work if we change definition of forbidden/required + // or we just need more sophisticated SKU dependency rules + + if (input.checked) { + // Check if a required SKU is selected, alert the user if not + (sku.required || []).forEach(title => { + this.skus.forEach(item => { + let checkbox + if (item.handler == title && (checkbox = $('#s' + item.id).find('input[type=checkbox]')[0])) { + if (!checkbox.checked) { + required.push(item.name) + } + } + }) + }) + + if (required.length) { + input.checked = false + return alert(sku.name + ' requires ' + required.join(', ') + '.') + } + } else { + // Uncheck all dependent SKUs, e.g. when unchecking Groupware we also uncheck Activesync + // TODO: Should we display an alert instead? + this.skus.forEach(item => { + if (item.required && item.required.indexOf(sku.handler) > -1) { + $('#s' + item.id).find('input[type=checkbox]').prop('checked', false) + } + }) + } + + // Uncheck+lock/unlock conflicting SKUs + (sku.forbidden || []).forEach(title => { + this.skus.forEach(item => { + let checkbox + if (item.handler == title && (checkbox = $('#s' + item.id).find('input[type=checkbox]')[0])) { + if (input.checked) { + checkbox.checked = false + checkbox.readOnly = true + } else { + checkbox.readOnly = false + } + } + }) + }) + }, selectPackage(e) { // Make sure there always is only one package selected $('#user-packages input').prop('checked', false) @@ -245,19 +309,20 @@ let value = input.val() let record = input.parents('tr').first() let sku_id = record.find('input[type=checkbox]').val() - let sku, i - - for (i = 0; i < this.skus.length; i++) { - if (this.skus[i].id == sku_id) { - sku = this.skus[i]; - } - } + let sku = this.findSku(sku_id) // Update the label input.prev().text(value + ' ' + sku.range.unit) // Update the price record.find('.price').text(this.$root.price(sku.cost * (value - sku.units_free)) + '/month') + }, + findSku(id) { + for (let i = 0; i < this.skus.length; i++) { + if (this.skus[i].id == id) { + return this.skus[i]; + } + } } } } diff --git a/src/tests/Browser.php b/src/tests/Browser.php --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -70,6 +70,32 @@ } /** + * Assert that the given element is readonly + */ + public function assertReadonly($selector) + { + $element = $this->resolver->findOrFail($selector); + $value = $element->getAttribute('readonly'); + + Assert::assertTrue($value == 'true', "Element [$selector] is not readonly"); + + return $this; + } + + /** + * Assert that the given element is not readonly + */ + public function assertNotReadonly($selector) + { + $element = $this->resolver->findOrFail($selector); + $value = $element->getAttribute('readonly'); + + Assert::assertTrue($value != 'true', "Element [$selector] is not readonly"); + + return $this; + } + + /** * Assert that the given element contains specified text, * no matter it's displayed or not. */ 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 @@ -39,13 +39,7 @@ UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); - Sku::where('title', 'test')->delete(); - $storage = Sku::where('title', 'storage')->first(); - Entitlement::where([ - ['sku_id', $storage->id], - ['entitleable_id', $john->id], - ['cost', 25] - ])->delete(); + Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); } /** @@ -60,13 +54,7 @@ UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); - Sku::where('title', 'test')->delete(); - $storage = Sku::where('title', 'storage')->first(); - Entitlement::where([ - ['sku_id', $storage->id], - ['entitleable_id', $john->id], - ['cost', 25] - ])->delete(); + Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); parent::tearDown(); } @@ -127,17 +115,6 @@ */ public function testInfo(): void { - Sku::create([ - 'title' => 'test', - 'name' => 'Test SKU', - 'description' => 'The SKU for testing', - 'cost' => 666, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Groupware', - 'active' => true, - ]); - $this->browse(function (Browser $browser) { $browser->on(new UserList()) ->click('@table tr:nth-child(2) a') @@ -241,48 +218,57 @@ $browser->assertSeeIn('div.row:nth-child(8) label', 'Subscriptions') ->assertVisible('@skus.row:nth-child(8)') ->with('@skus', function ($browser) { - $browser->assertElementsCount('tbody tr', 4) - // groupware SKU - ->assertSeeIn('tbody tr:nth-child(1) td.name', 'Groupware Features') - ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,55 CHF/month') + $browser->assertElementsCount('tbody tr', 5) + // Mailbox SKU + ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox') + ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,44 CHF/month') ->assertChecked('tbody tr:nth-child(1) td.selection input') - ->assertEnabled('tbody tr:nth-child(1) td.selection input') + ->assertDisabled('tbody tr:nth-child(1) td.selection input') ->assertTip( 'tbody tr:nth-child(1) td.buttons button', - 'Groupware functions like Calendar, Tasks, Notes, etc.' + 'Just a mailbox' ) - // Mailbox SKU - ->assertSeeIn('tbody tr:nth-child(2) td.name', 'User Mailbox') - ->assertSeeIn('tbody tr:nth-child(2) td.price', '4,44 CHF/month') + // Storage SKU + ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota') + ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(2) td.selection input') ->assertDisabled('tbody tr:nth-child(2) td.selection input') ->assertTip( 'tbody tr:nth-child(2) td.buttons button', - 'Just a mailbox' + 'Some wiggle room' ) - // Storage SKU - ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Storage Quota') - ->assertSeeIn('tr:nth-child(3) td.price', '0,00 CHF/month') + ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) { + $browser->assertQuotaValue(2)->setQuotaValue(3); + }) + ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month') + // groupware SKU + ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features') + ->assertSeeIn('tbody tr:nth-child(3) td.price', '5,55 CHF/month') ->assertChecked('tbody tr:nth-child(3) td.selection input') - ->assertDisabled('tbody tr:nth-child(3) td.selection input') + ->assertEnabled('tbody tr:nth-child(3) td.selection input') ->assertTip( 'tbody tr:nth-child(3) td.buttons button', - 'Some wiggle room' + 'Groupware functions like Calendar, Tasks, Notes, etc.' ) - ->with(new QuotaInput('tbody tr:nth-child(3) .range-input'), function ($browser) { - $browser->assertQuotaValue(2)->setQuotaValue(3); - }) - ->assertSeeIn('tr:nth-child(3) td.price', '0,25 CHF/month') - // Test SKU - ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Test SKU') - ->assertSeeIn('tbody tr:nth-child(4) td.price', '6,66 CHF/month') + // 2FA SKU + ->assertSeeIn('tbody tr:nth-child(4) td.name', '2-Factor Authentication') + ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(4) td.selection input') ->assertEnabled('tbody tr:nth-child(4) td.selection input') ->assertTip( 'tbody tr:nth-child(4) td.buttons button', - 'The SKU for testing' + 'Two factor authentication for webmail and administration panel' ) - ->click('tbody tr:nth-child(4) td.selection input'); + // ActiveSync SKU + ->assertSeeIn('tbody tr:nth-child(5) td.name', 'Activesync') + ->assertSeeIn('tbody tr:nth-child(5) td.price', '1,00 CHF/month') + ->assertNotChecked('tbody tr:nth-child(5) td.selection input') + ->assertEnabled('tbody tr:nth-child(5) td.selection input') + ->assertTip( + 'tbody tr:nth-child(5) td.buttons button', + 'Mobile synchronization' + ) + ->click('tbody tr:nth-child(5) td.selection input'); }) ->click('button[type=submit]'); }) @@ -292,7 +278,34 @@ ->closeToast(); }); - $this->assertUserEntitlements($john, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'test']); + $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage']; + $this->assertUserEntitlements($john, $expected); + + // Test subscriptions interaction + $browser->with('@form', function (Browser $browser) { + $browser->with('@skus', function ($browser) { + // Uncheck 'groupware', expect activesync unchecked + $browser->click('@sku-input-groupware') + ->assertNotChecked('@sku-input-groupware') + ->assertNotChecked('@sku-input-activesync') + ->assertEnabled('@sku-input-activesync') + ->assertNotReadonly('@sku-input-activesync') + // Check 'activesync', expect an alert + ->click('@sku-input-activesync') + ->assertDialogOpened('Activesync requires Groupware Features.') + ->acceptDialog() + ->assertNotChecked('@sku-input-activesync') + // Check '2FA', expect 'activesync' unchecked and readonly + ->click('@sku-input-2fa') + ->assertChecked('@sku-input-2fa') + ->assertNotChecked('@sku-input-activesync') + ->assertReadonly('@sku-input-activesync') + // Uncheck '2FA' + ->click('@sku-input-2fa') + ->assertNotChecked('@sku-input-2fa') + ->assertNotReadonly('@sku-input-activesync'); + }); + }); }); } diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php --- a/src/tests/Feature/Controller/SkusTest.php +++ b/src/tests/Feature/Controller/SkusTest.php @@ -18,25 +18,26 @@ $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); - $domain_sku = Sku::where('title', 'domain')->first(); + $sku = Sku::where('title', 'mailbox')->first(); $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(7, $json); + $this->assertCount(9, $json); - $this->assertSame($domain_sku->id, $json[0]['id']); - $this->assertSame($domain_sku->title, $json[0]['title']); - $this->assertSame($domain_sku->name, $json[0]['name']); - $this->assertSame($domain_sku->description, $json[0]['description']); - $this->assertSame($domain_sku->cost, $json[0]['cost']); - $this->assertSame($domain_sku->units_free, $json[0]['units_free']); - $this->assertSame($domain_sku->period, $json[0]['period']); - $this->assertSame($domain_sku->active, $json[0]['active']); - $this->assertSame('domain', $json[0]['type']); - $this->assertSame('domain', $json[0]['handler']); + $this->assertSame(100, $json[0]['prio']); + $this->assertSame($sku->id, $json[0]['id']); + $this->assertSame($sku->title, $json[0]['title']); + $this->assertSame($sku->name, $json[0]['name']); + $this->assertSame($sku->description, $json[0]['description']); + $this->assertSame($sku->cost, $json[0]['cost']); + $this->assertSame($sku->units_free, $json[0]['units_free']); + $this->assertSame($sku->period, $json[0]['period']); + $this->assertSame($sku->active, $json[0]['active']); + $this->assertSame('user', $json[0]['type']); + $this->assertSame('mailbox', $json[0]['handler']); } /**