diff --git a/src/.gitignore b/src/.gitignore --- a/src/.gitignore +++ b/src/.gitignore @@ -3,10 +3,11 @@ package-lock.json public/css/app.css public/hot -public/js/app.js +public/js/*.js public/storage/ storage/*.key storage/export/ +tests/report/ vendor .env .env.backup diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php --- a/src/app/Auth/SecondFactor.php +++ b/src/app/Auth/SecondFactor.php @@ -227,8 +227,6 @@ */ public function read($key) { - \Log::debug(__METHOD__ . ' ' . $key); - if (!isset($this->cache[$key])) { $factors = $this->getFactors(); $this->cache[$key] = isset($factors[$key]) ? $factors[$key] : null; diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -430,6 +430,10 @@ if (!in_array("groupware", $roles)) { $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; } + + if (empty($entry['nsroledn'])) { + unset($entry['nsroledn']); + } } /** diff --git a/src/app/Domain.php b/src/app/Domain.php --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -356,16 +356,9 @@ throw new \Exception("Failed to get DNS record for {$this->namespace}"); } - // It may happen that result contains other domains depending on the host - // DNS setup - $hosts = array_map( - function ($record) { - return $record['host']; - }, - $records - ); - - if (in_array($this->namespace, $hosts)) { + // It may happen that result contains other domains depending on the host DNS setup + // that's why in_array() and not just !empty() + if (in_array($this->namespace, array_column($records, 'host'))) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); @@ -380,8 +373,11 @@ * * @return \App\Wallet A wallet object */ - public function wallet(): Wallet + public function wallet(): ?Wallet { - return $this->entitlement()->first()->wallet; + // Note: Not all domains have a entitlement/wallet + $entitlement = $this->entitlement()->first(); + + return $entitlement ? $entitlement->wallet : null; } } diff --git a/src/app/Handlers/Activesync.php b/src/app/Handlers/Activesync.php --- a/src/app/Handlers/Activesync.php +++ b/src/app/Handlers/Activesync.php @@ -18,4 +18,9 @@ return true; } + + public static function priority(): int + { + return 70; + } } diff --git a/src/app/Handlers/Auth2F.php b/src/app/Handlers/Auth2F.php --- a/src/app/Handlers/Auth2F.php +++ b/src/app/Handlers/Auth2F.php @@ -18,4 +18,9 @@ return true; } + + public static function priority(): int + { + return 60; + } } diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -2,16 +2,62 @@ namespace App\Http\Controllers\API\V4\Admin; +use App\Domain; +use App\User; +use App\UserSetting; + class UsersController extends \App\Http\Controllers\API\V4\UsersController { + /** + * Searching of user accounts. + * + * @return \Illuminate\Http\JsonResponse + */ public function index() { - $result = \App\User::orderBy('email')->get()->map(function ($user) { + $search = trim(request()->input('search')); + $result = collect([]); + + if (strpos($search, '@')) { + // Search by email + if ($user = User::findByEmail($search, false)) { + $result->push($user); + } else { + // Search by an external email + // TODO: This is not optimal (external email should be in users table) + $user_ids = UserSetting::where('key', 'external_email')->where('value', $search) + ->get()->pluck('user_id'); + + // TODO: Sort order + $result = User::find($user_ids); + } + } elseif (is_numeric($search)) { + // Search by user ID + if ($user = User::find($search)) { + $result->push($user); + } + } elseif (!empty($search)) { + // Search by domain + if ($domain = Domain::where('namespace', $search)->first()) { + if ($wallet = $domain->wallet()) { + $result->push($wallet->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); } } diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -355,11 +355,12 @@ * Helper to find user by email address, whether it is * main email address, alias or external email * - * @param string $email Email address + * @param string $email Email address + * @param bool $external Search also by an external email * * @return \App\User User model object if found */ - public static function findByEmail(string $email): ?User + public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -14,6 +14,7 @@ faLock, faKey, faPlus, + faSearch, faSignInAlt, faSyncAlt, faTrashAlt, @@ -32,6 +33,7 @@ faLock, faKey, faPlus, + faSearch, faSignInAlt, faSquare, faSyncAlt, diff --git a/src/resources/js/routes-admin.js b/src/resources/js/routes-admin.js --- a/src/resources/js/routes-admin.js +++ b/src/resources/js/routes-admin.js @@ -8,6 +8,7 @@ import LoginComponent from '../vue/Login' import LogoutComponent from '../vue/Logout' import PasswordResetComponent from '../vue/PasswordReset' +import UserComponent from '../vue/Admin/User' import store from './store' @@ -38,6 +39,12 @@ component: PasswordResetComponent }, { + path: '/user/:user', + name: 'user', + component: UserComponent, + meta: { requiresAuth: true } + }, + { name: '404', path: '*', component: Error404Component 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 @@ -24,4 +24,6 @@ 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', + + 'search-foundxusers' => ':x user accounts have been found.', ]; 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,5 +1,36 @@ @@ -8,25 +39,45 @@ export default { data() { return { - isReady: true + search: '', + users: [] } }, mounted() { const authInfo = this.$store.state.isLoggedIn ? this.$store.state.authInfo : null if (authInfo) { - + $('#search-box input').focus() } else { this.$root.startLoading() axios.get('/api/auth/info') .then(response => { this.$store.state.authInfo = response.data this.$root.stopLoading() + setTimeout(() => { $('#search-box input').focus() }, 10) }) .catch(this.$root.errorHandler) } }, methods: { + searchUser() { + this.users = [] + + axios.get('/api/v4/users', { params: { search: this.search } }) + .then(response => { + if (response.data.count == 1) { + this.$router.push({ name: 'user', params: { user: response.data.list[0].id } }) + return + } + + if (response.data.message) { + this.$toastr('info', response.data.message) + } + + this.users = response.data.list + }) + .catch(this.$root.errorHandler) + } } } diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Admin/User.vue @@ -0,0 +1,107 @@ + + + diff --git a/src/tests/Browser/Admin/DashboardTest.php b/src/tests/Browser/Admin/DashboardTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Admin/DashboardTest.php @@ -0,0 +1,77 @@ +getTestUser('jack@kolab.org'); + $jack->setSetting('external_email', null); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $jack = $this->getTestUser('jack@kolab.org'); + $jack->setSetting('external_email', null); + + parent::tearDown(); + } + + /** + * Test user search + */ + public function testSearch(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true) + ->on(new Dashboard()) + ->assertFocused('@search input') + ->assertMissing('@search table'); + + // Test search with no results + $browser->type('@search input', 'unknown') + ->click('@search form button') + ->assertToast(Toast::TYPE_INFO, '', '0 user accounts have been found.') + ->assertMissing('@search table'); + + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $jack->setSetting('external_email', 'john.doe.external@gmail.com'); + + // Test search with multiple results + $browser->type('@search input', 'john.doe.external@gmail.com') + ->click('@search form button') + ->assertToast(Toast::TYPE_INFO, '', '2 user accounts have been found.') + ->whenAvailable('@search table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 2); + // TODO: Assert table content + }); + + // Test search with single record result -> redirect to user page + $browser->type('@search input', 'kolab.org') + ->click('@search form button') + ->assertMissing('@search table') + ->waitForLocation('/user/' . $john->id) + ->waitFor('#user-info') + ->assertVisible('#user-info .card-title', $john->email); + }); + } +} diff --git a/src/tests/Browser/Admin/LogonTest.php b/src/tests/Browser/Admin/LogonTest.php --- a/src/tests/Browser/Admin/LogonTest.php +++ b/src/tests/Browser/Admin/LogonTest.php @@ -18,11 +18,7 @@ public function setUp(): void { parent::setUp(); - - // This will set baseURL for all tests in this file - // If we wanted to visit both user and admin in one test - // we can also just call visit() with full url - Browser::$baseUrl = str_replace('//', '//admin.', \config('app.url')); + self::useAdminUrl(); } /** diff --git a/src/tests/Browser/Pages/Dashboard.php b/src/tests/Browser/Pages/Dashboard.php --- a/src/tests/Browser/Pages/Dashboard.php +++ b/src/tests/Browser/Pages/Dashboard.php @@ -52,6 +52,7 @@ '@app' => '#app', '@links' => '#dashboard-nav', '@status' => '#status-box', + '@search' => '#search-box', ]; } } 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 @@ -261,25 +261,25 @@ 'tbody tr:nth-child(3) td.buttons button', 'Groupware functions like Calendar, Tasks, Notes, etc.' ) - // 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') + // ActiveSync SKU + ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync') + ->assertSeeIn('tbody tr:nth-child(4) td.price', '1,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', - 'Two factor authentication for webmail and administration panel' + 'Mobile synchronization' ) - // ActiveSync SKU - ->assertSeeIn('tbody tr:nth-child(5) td.name', 'Activesync') - ->assertSeeIn('tbody tr:nth-child(5) td.price', '1,00 CHF/month') + // 2FA SKU + ->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication') + ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,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' + 'Two factor authentication for webmail and administration panel' ) - ->click('tbody tr:nth-child(5) td.selection input'); + ->click('tbody tr:nth-child(4) td.selection input'); }) ->assertMissing('@skus table + .hint') ->click('button[type=submit]'); @@ -561,10 +561,10 @@ ->assertSeeIn('tr:nth-child(2) td.price', '21,56 CHF/month¹') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,99 CHF/month¹') - // 2FA SKU - ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹') // ActiveSync SKU - ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,90 CHF/month¹'); + ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,90 CHF/month¹') + // 2FA SKU + ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -2,8 +2,6 @@ namespace Tests\Feature\Controller\Admin; -use App\Domain; -use App\User; use Tests\TestCase; class UsersTest extends TestCase @@ -14,11 +12,10 @@ public function setUp(): void { parent::setUp(); + self::useAdminUrl(); - // This will set base URL for all tests in this file - // If we wanted to access both user and admin in one test - // we can also just call post/get/whatever with full url - \config(['app.url' => str_replace('//', '//admin.', \config('app.url'))]); + $jack = $this->getTestUser('jack@kolab.org'); + $jack->setSetting('external_email', null); } /** @@ -26,24 +23,101 @@ */ public function tearDown(): void { + $jack = $this->getTestUser('jack@kolab.org'); + $jack->setSetting('external_email', null); + parent::tearDown(); } /** - * Test (/api/v4/index) + * Test users searching (/api/v4/users) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + // Non-admin user $response = $this->actingAs($user)->get("api/v4/users"); $response->assertStatus(403); + // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/users"); $response->assertStatus(200); - // TODO: Test the response - $this->markTestIncomplete(); + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search with no matches expected + $response = $this->actingAs($admin)->get("api/v4/users?search=abcd1234efgh5678"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search by domain + $response = $this->actingAs($admin)->get("api/v4/users?search=kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($user->id, $json['list'][0]['id']); + $this->assertSame($user->email, $json['list'][0]['email']); + + // Search by user ID + $response = $this->actingAs($admin)->get("api/v4/users?search={$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($user->id, $json['list'][0]['id']); + $this->assertSame($user->email, $json['list'][0]['email']); + + // Search by email (primary) + $response = $this->actingAs($admin)->get("api/v4/users?search=john@kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($user->id, $json['list'][0]['id']); + $this->assertSame($user->email, $json['list'][0]['email']); + + // Search by email (alias) + $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe@kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($user->id, $json['list'][0]['id']); + $this->assertSame($user->email, $json['list'][0]['email']); + + // Search by email (external), expect two users in a result + $jack = $this->getTestUser('jack@kolab.org'); + $jack->setSetting('external_email', 'john.doe.external@gmail.com'); + + $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe.external@gmail.com"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertCount(2, $json['list']); + + $emails = array_column($json['list'], 'email'); + + $this->assertContains($user->email, $emails); + $this->assertContains($jack->email, $emails); } } diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -16,4 +16,15 @@ $entitlement->save(); } } + + /** + * Set baseURL to the admin UI location + */ + protected static function useAdminUrl(): void + { + // This will set base URL for all tests in a file. + // If we wanted to access both user and admin in one test + // we can also just call post/get/whatever with full url + \config(['app.url' => str_replace('//', '//admin.', \config('app.url'))]); + } } diff --git a/src/tests/TestCaseDusk.php b/src/tests/TestCaseDusk.php --- a/src/tests/TestCaseDusk.php +++ b/src/tests/TestCaseDusk.php @@ -33,7 +33,6 @@ '--lang=en_US', '--disable-gpu', '--headless', - '--window-size=1280,720', ]); // For file download handling @@ -57,7 +56,7 @@ $options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]); $options->addArguments(['--window-size=800,640']); } else { - $options->addArguments(['--window-size=1280,720']); + $options->addArguments(['--window-size=2560,1440']); } // Make sure downloads dir exists and is empty @@ -85,4 +84,15 @@ { return new Browser($driver); } + + /** + * Set baseURL to the admin UI location + */ + protected static function useAdminUrl(): void + { + // This will set baseURL for all tests in this file + // If we wanted to visit both user and admin in one test + // we can also just call visit() with full url + Browser::$baseUrl = str_replace('//', '//admin.', \config('app.url')); + } }