diff --git a/src/public/favicon.ico b/src/public/favicon.ico deleted file mode 100644 index e69de29b..00000000 diff --git a/src/public/images/favicon.ico b/src/public/images/favicon.ico new file mode 100644 index 00000000..55221672 Binary files /dev/null and b/src/public/images/favicon.ico differ diff --git a/src/resources/js/app.js b/src/resources/js/app.js index a208cba6..de67a1a2 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,319 +1,324 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import store from './store' const loader = '
Loading
' const app = new Vue({ el: '#app', components: { AppComponent, MenuComponent, }, store, router: window.router, data() { return { isLoading: true, isAdmin: window.isAdmin } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(token, dashboard) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') localStorage.setItem('token', token) axios.defaults.headers.common.Authorization = 'Bearer ' + token if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null }, // Set user state to "not logged in" logoutUser() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization this.$router.push({ name: 'login' }) }, // Display "loading" overlay inside of the specified element addLoader(elem) { $(elem).css({position: 'relative'}).append($(loader).addClass('small')) }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element let loading = $('#app > .app-loader').show() if (!loading.length) { $('#app').append($(loader)) } }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').fadeOut() this.isLoading = false }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#error-page').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { this.logoutUser() } else { this.errorPage(error.response.status, error.response.statusText) } }, downloadFile(url) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then (response => { const link = document.createElement('a') const contentDisposition = response.headers['content-disposition'] let filename = 'unknown' if (contentDisposition) { const match = contentDisposition.match(/filename="(.+)"/); if (match.length === 2) { filename = match[1]; } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) }, price(price, currency) { return (price/100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, units = 1, discount) { let index = '' if (units < 0) { units = 1 } if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost * units) + '/month' + index }, + clickRecord(event) { + if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { + $(event.target).closest('tr').find('a')[0].click() + } + }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return 'Deleted' } if (domain.isSuspended) { return 'Suspended' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'Not Ready' } return 'Active' }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return 'Deleted' } if (user.isSuspended) { return 'Suspended' } if (!user.isImapReady || !user.isLdapReady) { return 'Not Ready' } return 'Active' } } }) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { let error_msg let status = error.response ? error.response.status : 200 if (error.response && status == 422) { error_msg = "Form validation error" const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(error.response.data.errors || {}, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message\ // API responses can use a string, array or object let msg_text = '' if ($.type(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget input.children(':not(:first-child)').each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || "Server Error") // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/js/bootstrap.js b/src/resources/js/bootstrap.js index a1cd2798..00413a36 100644 --- a/src/resources/js/bootstrap.js +++ b/src/resources/js/bootstrap.js @@ -1,86 +1,92 @@ /** * We'll load jQuery and the Bootstrap jQuery plugin which provides support * for JavaScript based Bootstrap features such as modals and tabs. This * code may be modified to fit the specific needs of your application. */ window.Popper = require('popper.js').default window.$ = window.jQuery = require('jquery') require('bootstrap') /** * We'll load Vue, VueRouter and global components */ import FontAwesomeIcon from './fontawesome' import VueRouter from 'vue-router' import Toast from '../vue/Widgets/Toast' import store from './store' window.Vue = require('vue') Vue.component('svg-icon', FontAwesomeIcon) const vTooltip = (el, binding) => { const t = [] if (binding.modifiers.focus) t.push('focus') if (binding.modifiers.hover) t.push('hover') if (binding.modifiers.click) t.push('click') if (!t.length) t.push('hover') $(el).tooltip({ title: binding.value, placement: binding.arg || 'top', trigger: t.join(' '), html: !!binding.modifiers.html, }); } Vue.directive('tooltip', { bind: vTooltip, update: vTooltip, unbind (el) { $(el).tooltip('dispose') } }) Vue.use(Toast) Vue.use(VueRouter) window.router = new VueRouter({ mode: 'history', - routes: window.routes + routes: window.routes, + scrollBehavior (to, from, savedPosition) { + // Scroll the page to top, but not on Back action + return savedPosition || { x: 0, y: 0 } + } }) router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } next() }) router.afterEach((to, from) => { - // Remove the (old) error page when changing a page - $('#error-page').remove() + // When changing a page remove old: + // - error page + // - modal backdrop + $('#error-page,.modal-backdrop.show').remove() }) /** * We'll load the axios HTTP library which allows us to easily issue requests * to our Laravel back-end. This library automatically handles sending the * CSRF token as a header based on the value of the "XSRF" token cookie. */ window.axios = require('axios') axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss index 6af13730..f77452a7 100644 --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -1,280 +1,329 @@ -// Fonts - -// Variables @import 'variables'; - -// Bootstrap -@import '~bootstrap/scss/bootstrap'; - +@import 'bootstrap'; @import 'menu'; @import 'toast'; @import 'forms'; html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } #error-page { position: absolute; top: 0; height: 100%; width: 100%; align-items: center; display: flex; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; } tbody:not(:empty) + & { display: none; } } table { td.buttons, td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } th.price, td.price { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } + + button { + line-height: 1; + } } .btn-action { line-height: 1; padding: 0; } } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-ico { font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; margin-bottom: 1rem; border-radius: 50%; } } .plan-description { & > ul { padding-left: 1.2em; &:last-child { margin-bottom: 0; } } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } -// Bootstrap style fix -.btn-link { - border: 0; -} +// Various improvements for mobile +@include media-breakpoint-down(sm) { + .card { + border: 0; + } -.table thead th { - border: 0; -} + .card-body { + padding: 0.5rem 0; + } + + .form-group { + margin-bottom: 0.5rem; + } + + .tab-content { + margin-top: 0.5rem; + } + + .col-form-label { + color: #666; + font-size: 95%; + } + + .form-group.plaintext .col-form-label { + padding-bottom: 0; + } + + form.read-only.short label { + width: 35%; -small { - font-size: 0.875em; + & + * { + width: 65%; + } + } + + #app > div.container { + margin-bottom: 1rem; + margin-top: 1rem; + } + + #header-menu-navbar { + padding: 0; + } + + #dashboard-nav > a { + width: 135px; + } + + .table-sm:not(.form-list) { + tbody td { + padding: 0.75rem 0.5rem; + + svg { + vertical-align: -0.175em; + } + + & > svg { + font-size: 125%; + margin-right: 0.25rem; + } + } + } } diff --git a/src/resources/sass/bootstrap.scss b/src/resources/sass/bootstrap.scss new file mode 100644 index 00000000..444260d9 --- /dev/null +++ b/src/resources/sass/bootstrap.scss @@ -0,0 +1,15 @@ +// Bootstrap +@import '~bootstrap/scss/bootstrap'; + +// Bootstrap style fixes +.btn-link { + border: 0; +} + +.table thead th { + border: 0; +} + +small { + font-size: 0.875em; +} diff --git a/src/resources/sass/forms.scss b/src/resources/sass/forms.scss index 5bc8b0cc..5690b8ed 100644 --- a/src/resources/sass/forms.scss +++ b/src/resources/sass/forms.scss @@ -1,44 +1,47 @@ - .list-input { & > div { &:not(:last-child) { margin-bottom: -1px; input, a.btn { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } &:not(:first-child) { input, a.btn { border-top-right-radius: 0; border-top-left-radius: 0; } } } input.is-invalid { z-index: 2; } + + .btn svg { + vertical-align: middle; + } } .range-input { display: flex; label { margin-right: 0.5em; } } .form-control-plaintext .btn-sm { margin-top: -0.25rem; } form.read-only { .row { margin-bottom: 0; } } diff --git a/src/resources/sass/menu.scss b/src/resources/sass/menu.scss index 2ed5e926..25c99965 100644 --- a/src/resources/sass/menu.scss +++ b/src/resources/sass/menu.scss @@ -1,140 +1,150 @@ #header-menu { background-color: $menu-bg-color; padding: 0; line-height: 85px; .navbar-brand { padding: 0; outline: 0; > img { display: inline; vertical-align: middle; } } .nav-link { color: #202020; line-height: 85px; padding: 0 0 0 25px; background: transparent; &:focus { text-decoration: underline; outline: 0; } &:hover { color: $main-color; text-decoration: underline; } &.active:not(.menulogin) { font-weight: bold; } } } #footer-menu { background-color: $main-color; height: 100px; .navbar-brand { margin: 0; img { width: 170px; } } .footer { text-align: right; color: #fff; font-size: 0.75rem; padding: 0 0.5rem; } } @include media-breakpoint-up(lg) { #header-menu { a.menulogin { text-transform: uppercase; border: 2px solid $main-color; border-radius: 21px; line-height: 21px; letter-spacing: 1px; padding: 6px 34px; margin: 25px 0 25px 25px; &:focus, &:hover { text-decoration: none; background-color: $main-color; color: #fff; font-weight: normal; } } } #footer-menu { .navbar { flex-direction: column; align-items: flex-end; } } .navbar { .navbar { justify-content: flex-end; } } } @include media-breakpoint-down(md) { #header-menu { .navbar-nav { padding-bottom: 1em; } .nav-link { line-height: 45px; padding: 0; } } #footer-menu { .navbar-nav { display: none; } .container { flex-wrap: nowrap; } } } @include media-breakpoint-down(sm) { #header-menu { padding: 0 1em; + + .navbar-nav { + display: block; + width: 100%; + padding: 0; + + li { + border-top: 1px solid #eee; + } + } } #footer-menu { height: 80px; .container { flex-direction: column; } #footer-company { display: none; } } } @media (max-width: 340px) { #header-menu { .navbar-brand img { width: 160px; } } } diff --git a/src/resources/sass/toast.scss b/src/resources/sass/toast.scss index fcb15ace..dd832111 100644 --- a/src/resources/sass/toast.scss +++ b/src/resources/sass/toast.scss @@ -1,46 +1,55 @@ .toast-container { position: fixed; bottom: 0; right: 0; margin: 0.5rem; width: 320px; z-index: 1055; // above Bootstrap's modal backdrop and dialogs + + @media (max-width: 375px) { + left: 0; + width: auto; + } } .toast { background-color: rgba(52, 58, 64, 0.95); &:not(:last-child) { margin-bottom: 0.3rem; } + + @media (max-width: 375px) { + max-width: 100%; + } } .toast-header { background-color: #343a40; border-color: #555; color: #fff; strong { flex: 1; } svg { font-size: 1.2em; margin-right: 0.5rem; } button.close { color: #eee; opacity: 1 !important; text-shadow: none; font-size: 1.2rem; &:hover { color: #fff; } } } .toast-body { color: #fff; } diff --git a/src/resources/views/layouts/app.blade.php b/src/resources/views/layouts/app.blade.php index 97463117..11e14c40 100644 --- a/src/resources/views/layouts/app.blade.php +++ b/src/resources/views/layouts/app.blade.php @@ -1,22 +1,23 @@ {{ config('app.name') }} -- @yield('title') {{-- TODO: PWA disabled for now: @laravelPWA --}} +
@yield('content')
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index d4514194..e2678d28 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,597 +1,597 @@ diff --git a/src/resources/vue/Domain/List.vue b/src/resources/vue/Domain/List.vue index 8c820780..876e8711 100644 --- a/src/resources/vue/Domain/List.vue +++ b/src/resources/vue/Domain/List.vue @@ -1,45 +1,45 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index c30bf1dd..6831e4fd 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,366 +1,370 @@ diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue index 16968c84..02dc12bf 100644 --- a/src/resources/vue/User/List.vue +++ b/src/resources/vue/User/List.vue @@ -1,123 +1,123 @@ diff --git a/src/resources/vue/Widgets/Status.vue b/src/resources/vue/Widgets/Status.vue index ec3ed2bd..2549569f 100644 --- a/src/resources/vue/Widgets/Status.vue +++ b/src/resources/vue/Widgets/Status.vue @@ -1,177 +1,177 @@ diff --git a/src/tests/Browser/Admin/LogonTest.php b/src/tests/Browser/Admin/LogonTest.php index b221169d..b258c23b 100644 --- a/src/tests/Browser/Admin/LogonTest.php +++ b/src/tests/Browser/Admin/LogonTest.php @@ -1,145 +1,145 @@ browse(function (Browser $browser) { $browser->visit(new Home()) ->with(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }) ->assertMissing('@second-factor-input') ->assertMissing('@forgot-password'); }); } /** * Test redirect to /login if user is unauthenticated */ public function testLogonRedirect(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'wrong') // Error message ->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.') // Checks if we're still on the logon page ->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); }) ->assertUser('jeroen@jeroen.jeroen'); // Test that visiting '/' with logged in user does not open logon form // but "redirects" to the dashboard $browser->visit('/')->on(new Dashboard()); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { - $browser->click('.link-logout'); + $browser->clickMenuItem('logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } } diff --git a/src/tests/Browser/Components/Menu.php b/src/tests/Browser/Components/Menu.php index e19139a4..544e4c38 100644 --- a/src/tests/Browser/Components/Menu.php +++ b/src/tests/Browser/Components/Menu.php @@ -1,94 +1,113 @@ mode = $mode; } /** * Get the root selector for the component. * * @return string */ public function selector() { return '#' . $this->mode . '-menu'; } /** * Assert that the browser page contains the component. * * @param \Laravel\Dusk\Browser $browser * * @return void */ public function assert($browser) { $browser->assertVisible($this->selector()); } /** * Assert that menu contains only specified menu items. * * @param \Laravel\Dusk\Browser $browser * @param array $items List of menu items + * @param string $active Expected active item * * @return void */ - public function assertMenuItems($browser, array $items) + public function assertMenuItems($browser, array $items, string $active = null) { - // TODO: On mobile the links will not be visible + // On mobile the links are not visible, show them first (wait for transition) + if ($browser->isPhone()) { + $browser->click('@toggler')->waitFor('.navbar-collapse.show'); + } foreach ($items as $item) { $browser->assertVisible('.link-' . $item); } // Check number of items, to make sure there's no extra items PHPUnit::assertCount(count($items), $browser->elements('li')); + + if ($active) { + $browser->assertPresent(".link-{$active}.active"); + } + + if ($browser->isPhone()) { + $browser->click('@toggler')->waitUntilMissing('.navbar-collapse.show'); + } } /** - * Assert that specified menu item is active + * Click menu link. * - * @param \Laravel\Dusk\Browser $browser - * @param string $item Menu item name + * @param \Laravel\Dusk\Browser $browser The browser object + * @param string $name Menu item name * * @return void */ - public function assertActiveItem($browser, string $item) + public function clickMenuItem($browser, string $name) { - // TODO: On mobile the links will not be visible + // On mobile the links are not visible, show them first (wait for transition) + if ($browser->isPhone()) { + $browser->click('@toggler')->waitFor('.navbar-collapse.show'); + } - $browser->assertVisible(".link-{$item}.active"); + $browser->click('.link-' . $name); + + if ($browser->isPhone()) { + $browser->waitUntilMissing('.navbar-collapse.show'); + } } /** * Get the element shortcuts for the component. * * @return array */ public function elements() { $selector = $this->selector(); return [ '@list' => ".navbar-nav", '@brand' => ".navbar-brand", '@toggler' => ".navbar-toggler", ]; } } diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php index 89fa7d0a..1d4eb6df 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,196 +1,207 @@ browse(function (Browser $browser) { $browser->visit(new Home()) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); - }) - ->within(new Menu('footer'), function ($browser) { + }); + + if ($browser->isDesktop()) { + $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'webmail']); }); + } else { + $browser->assertMissing('#footer-menu .navbar-nav'); + } }); } /** * Test redirect to /login if user is unauthenticated */ public function testLogonRedirect(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'wrong'); // Error message $browser->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.'); // Checks if we're still on the logon page $browser->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); - }) - ->within(new Menu('footer'), function ($browser) { + }); + + if ($browser->isDesktop()) { + $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); - }) - ->assertUser('john@kolab.org'); + }); + } else { + $browser->assertMissing('#footer-menu .navbar-nav'); + } + + $browser->assertUser('john@kolab.org'); // Assert no "Account status" for this account $browser->assertMissing('@status'); // Goto /domains and assert that the link on logo element // leads to the dashboard $browser->visit('/domains') ->waitForText('Domains') ->click('a.navbar-brand') ->on(new Dashboard()); // Test that visiting '/' with logged in user does not open logon form // but "redirects" to the dashboard $browser->visit('/')->on(new Dashboard()); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { - $browser->click('.link-logout'); + $browser->clickMenuItem('logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Test 2-Factor Authentication * * @depends testLogoutByURL */ public function test2FA(): void { $this->browse(function (Browser $browser) { // Test missing 2fa code $browser->on(new Home()) ->type('@email-input', 'ned@kolab.org') ->type('@password-input', 'simple123') ->press('form button') ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') ->assertSeeIn( '@second-factor-input.is-invalid + .invalid-feedback', 'Second factor code is required.' ) ->assertFocused('@second-factor-input') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // Test invalid code $browser->type('@second-factor-input', '123456') ->press('form button') ->waitUntilMissing('@second-factor-input.is-invalid') ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') ->assertSeeIn( '@second-factor-input.is-invalid + .invalid-feedback', 'Second factor code is invalid.' ) ->assertFocused('@second-factor-input') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); $code = \App\Auth\SecondFactor::code('ned@kolab.org'); // Test valid (TOTP) code $browser->type('@second-factor-input', $code) ->press('form button') ->waitUntilMissing('@second-factor-input.is-invalid') ->waitForLocation('/dashboard')->on(new Dashboard()); }); } } diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php index 005fe6c2..0a279817 100644 --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -1,533 +1,536 @@ deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); } public function tearDown(): void { $this->deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); parent::tearDown(); } /** * Test signup code verification with a link */ public function testSignupCodeByLink(): void { // Test invalid code (invalid format) $this->browse(function (Browser $browser) { // Register Signup page element selectors we'll be using $browser->onWithoutAssert(new Signup()); // TODO: Test what happens if user is logged in $browser->visit('/signup/invalid-code'); // TODO: According to https://github.com/vuejs/vue-router/issues/977 // it is not yet easily possible to display error page component (route) // without changing the URL // TODO: Instead of css selector we should probably define page/component // and use it instead $browser->waitFor('#error-page'); }); // Test invalid code (valid format) $this->browse(function (Browser $browser) { $browser->visit('/signup/XXXXX-code'); // FIXME: User will not be able to continue anyway, so we should // either display 1st step or 404 error page $browser->waitFor('@step1') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Test valid code $this->browse(function (Browser $browser) { $code = SignupCode::create([ 'data' => [ 'email' => 'User@example.org', 'first_name' => 'User', 'last_name' => 'Name', 'plan' => 'individual', 'voucher' => '', ] ]); $browser->visit('/signup/' . $code->short_code . '-' . $code->code) ->waitFor('@step3') ->assertMissing('@step1') ->assertMissing('@step2'); // FIXME: Find a nice way to read javascript data without using hidden inputs $this->assertSame($code->code, $browser->value('@step2 #signup_code')); // TODO: Test if the signup process can be completed }); } /** * Test signup "welcome" page */ public function testSignupStep0(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); $browser->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); $browser->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); - $browser->assertActiveItem('signup'); + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login'], 'signup'); }); $browser->waitFor('@step0 .plan-selector > .plan-box'); // Assert first plan box and press the button $browser->with('@step0 .plan-selector > .plan-individual', function ($step) { $step->assertVisible('button') ->assertSeeIn('button', 'Individual Account') ->assertVisible('.plan-description') ->click('button'); }); $browser->waitForLocation('/signup/individual') ->assertVisible('@step1') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_first_name'); // Click Back button $browser->click('@step1 [type=button]') ->waitForLocation('/signup') ->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); // Choose the group account plan $browser->click('@step0 .plan-selector > .plan-group button') ->waitForLocation('/signup/group') ->assertVisible('@step1') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_first_name'); // TODO: Test if 'plan' variable is set properly in vue component }); } /** * Test 1st step of the signup process */ public function testSignupStep1(): void { $this->browse(function (Browser $browser) { - $browser->visit('/signup/individual')->onWithoutAssert(new Signup()); - - $browser->assertVisible('@step1'); - - $browser->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); - $browser->assertActiveItem('signup'); - }); + $browser->visit('/signup/individual') + ->onWithoutAssert(new Signup()); // Here we expect two text inputs and Back and Continue buttons $browser->with('@step1', function ($step) { $step->assertVisible('#signup_last_name') ->assertVisible('#signup_first_name') ->assertFocused('#signup_first_name') ->assertVisible('#signup_email') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Submit empty form // Email is required, so after pressing Submit // we expect focus to be moved to the email input $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#signup_email'); }); + $browser->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login'], 'signup'); + }); + // Submit invalid email, and first_name // We expect both inputs to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) use ($browser) { $step->type('#signup_first_name', str_repeat('a', 250)) ->type('#signup_email', '@test') ->click('[type=submit]') ->waitFor('#signup_email.is-invalid') ->assertVisible('#signup_first_name.is-invalid') ->assertVisible('#signup_email + .invalid-feedback') ->assertVisible('#signup_last_name + .invalid-feedback') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->with('@step1', function ($step) { $step->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]') ->assertMissing('#signup_email.is-invalid') ->assertMissing('#signup_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #signup_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the signup process * * @depends testSignupStep1 */ public function testSignupStep2(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step2') ->assertMissing('@step0') ->assertMissing('@step1') ->assertMissing('@step3'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#signup_short_code') ->assertFocused('#signup_short_code') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]') ->waitFor('@step1') ->assertFocused('@step1 #signup_first_name') ->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#signup_first_name', 'User') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) use ($browser) { $step->type('#signup_short_code', 'XXXXX'); $step->click('[type=submit]'); $step->waitFor('#signup_short_code.is-invalid') ->assertVisible('#signup_short_code + .invalid-feedback') ->assertFocused('#signup_short_code') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#signup_short_code.is-invalid'); $step->assertMissing('#signup_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the signup process * * @depends testSignupStep2 */ public function testSignupStep3(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // Here we expect 3 text inputs, Back and Continue buttons $browser->with('@step3', function ($step) { $step->assertVisible('#signup_login'); $step->assertVisible('#signup_password'); $step->assertVisible('#signup_confirm'); $step->assertVisible('select#signup_domain'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); $step->assertFocused('#signup_login'); $step->assertValue('select#signup_domain', \config('app.domain')); $step->assertValue('#signup_login', ''); $step->assertValue('#signup_password', ''); $step->assertValue('#signup_confirm', ''); // TODO: Test domain selector }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); $browser->assertFocused('@step2 #signup_short_code'); $browser->assertMissing('@step3'); // TODO: Test form reset when going back // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_password', '12345678') ->type('#signup_confirm', '123456789') ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertFocused('#signup_login') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid data (valid login, invalid password) $browser->with('@step3', function ($step) use ($browser) { $step->type('#signup_login', 'SignupTestDusk') ->click('[type=submit]') ->waitFor('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertMissing('#signup_login.is-invalid') ->assertMissing('#signup_domain + .invalid-feedback') ->assertFocused('#signup_password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#signup_confirm', '12345678'); $step->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')); // Logout the user - $browser->click('a.link-logout'); + $browser->within(new Menu(), function ($browser) { + $browser->clickMenuItem('logout'); + }); }); } /** * Test signup for a group account */ public function testSignupGroup(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); // Choose the group account plan $browser->waitFor('@step0 .plan-group button') ->click('@step0 .plan-group button'); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->whenAvailable('@step1', function ($step) { $step->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }); // Submit valid code $browser->whenAvailable('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }); // Here we expect 4 text inputs, Back and Continue buttons $browser->whenAvailable('@step3', function ($step) { $step->assertVisible('#signup_login') ->assertVisible('#signup_password') ->assertVisible('#signup_confirm') ->assertVisible('input#signup_domain') ->assertVisible('[type=button]') ->assertVisible('[type=submit]') ->assertFocused('#signup_login') ->assertValue('input#signup_domain', '') ->assertValue('#signup_login', '') ->assertValue('#signup_password', '') ->assertValue('#signup_confirm', ''); }); // Submit invalid login and password data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_domain', 'test.com') ->type('#signup_password', '12345678') ->type('#signup_confirm', '123456789') ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertFocused('#signup_login') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid domain $browser->with('@step3', function ($step) use ($browser) { $step->type('#signup_login', 'admin') ->type('#signup_domain', 'aaa') ->type('#signup_password', '12345678') ->type('#signup_confirm', '12345678') ->click('[type=submit]') ->waitUntilMissing('#signup_login.is-invalid') ->waitFor('#signup_domain.is-invalid + .invalid-feedback') ->assertMissing('#signup_password.is-invalid') ->assertMissing('#signup_password + .invalid-feedback') ->assertFocused('#signup_domain') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid domain $browser->with('@step3', function ($step) { $step->type('#signup_domain', 'user-domain-signup.com') ->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('admin@user-domain-signup.com'); - $browser->click('a.link-logout'); + $browser->within(new Menu(), function ($browser) { + $browser->clickMenuItem('logout'); + }); }); } /** * Test signup with voucher */ public function testSignupVoucherLink(): void { $this->browse(function (Browser $browser) { $browser->visit('/signup/voucher/TEST') ->onWithoutAssert(new Signup()) ->waitFor('@step0') ->click('.plan-individual button') ->whenAvailable('@step1', function (Browser $browser) { $browser->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }) ->whenAvailable('@step2', function (Browser $browser) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $browser->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $browser->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }) ->whenAvailable('@step3', function (Browser $browser) { // Assert that the code is filled in the input // Change it and test error handling $browser->assertValue('#signup_voucher', 'TEST') ->type('#signup_voucher', 'TESTXX') ->type('#signup_login', 'signuptestdusk') ->type('#signup_password', '123456789') ->type('#signup_confirm', '123456789') ->click('[type=submit]') ->waitFor('#signup_voucher.is-invalid') ->assertVisible('#signup_voucher + .invalid-feedback') ->assertFocused('#signup_voucher') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Submit the correct code ->type('#signup_voucher', 'TEST') ->click('[type=submit]'); }) ->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')) // Logout the user - ->click('a.link-logout'); + ->within(new Menu(), function ($browser) { + $browser->clickMenuItem('logout'); + }); }); $user = $this->getTestUser('signuptestdusk@' . \config('app.domain')); $discount = Discount::where('code', 'TEST')->first(); $this->assertSame($discount->id, $user->wallets()->first()->discount_id); } } diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index 705a1d4a..361d6d2f 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,560 +1,560 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Kolab Developers', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = User::where('email', 'john@kolab.org')->first(); $browser->visit('/user/' . $user->id)->on(new Home()); }); } /** * Test users list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/users')->on(new Home()); }); } /** * Test users list page */ public function testList(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-users', 'User accounts') ->click('@links .link-users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org') ->assertVisible('tbody tr:nth-child(1) button.button-delete') ->assertVisible('tbody tr:nth-child(2) button.button-delete') ->assertVisible('tbody tr:nth-child(3) button.button-delete') ->assertVisible('tbody tr:nth-child(4) button.button-delete'); }); }); } /** * Test user account editing page (not profile page) * * @depends testList */ public function testInfo(): void { $this->browse(function (Browser $browser) { $browser->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) #status', 'Active') ->assertFocused('div.row:nth-child(2) input') ->assertSeeIn('div.row:nth-child(2) label', 'First name') ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) ->assertSeeIn('div.row:nth-child(3) label', 'Last name') ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) ->assertSeeIn('div.row:nth-child(4) label', 'Organization') ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']) ->assertSeeIn('div.row:nth-child(5) label', 'Email') ->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org') ->assertDisabled('div.row:nth-child(5) input[type=text]') ->assertSeeIn('div.row:nth-child(6) label', 'Email aliases') ->assertVisible('div.row:nth-child(6) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['john.doe@kolab.org']) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(7) label', 'Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Confirm password') ->assertValue('div.row:nth-child(8) input[type=password]', '') ->assertSeeIn('button[type=submit]', 'Submit'); // Clear some fields and submit $browser->type('#first_name', '') ->type('#last_name', '') ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); // Test error handling (password) $browser->with('@form', function (Browser $browser) { $browser->type('#password', 'aaaaaa') ->type('#password_confirmation', '') ->click('button[type=submit]') ->waitFor('#password + .invalid-feedback') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') ->assertFocused('#password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // TODO: Test password change // Test form error handling (aliases) $browser->with('@form', function (Browser $browser) { // TODO: For some reason, clearing the input value // with ->type('#password', '') does not work, maybe some dusk/vue intricacy // For now we just use the default password $browser->type('#password', 'simple123') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }) ->with('@form', function (Browser $browser) { $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(2, 'The specified alias is invalid.', false); }); }); // Test adding aliases $browser->with('@form', function (Browser $browser) { $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(2) ->addListEntry('john.test@kolab.org'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }); $john = User::where('email', 'john@kolab.org')->first(); $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); // Test subscriptions $browser->with('@form', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions') ->assertVisible('@skus.row:nth-child(9)') ->with('@skus', function ($browser) { $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') ->assertDisabled('tbody tr:nth-child(1) td.selection input') ->assertTip( 'tbody tr:nth-child(1) td.buttons button', 'Just a mailbox' ) // 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', 'Some wiggle room' ) ->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') ->assertEnabled('tbody tr:nth-child(3) td.selection input') ->assertTip( 'tbody tr:nth-child(3) td.buttons button', 'Groupware functions like Calendar, Tasks, Notes, etc.' ) // 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', 'Mobile synchronization' ) // 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', 'Two factor authentication for webmail and administration panel' ) ->click('tbody tr:nth-child(4) td.selection input'); }) ->assertMissing('@skus table + .hint') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }); $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') + $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') + ->click('#sku-input-activesync') ->assertDialogOpened('Activesync requires Groupware Features.') ->acceptDialog() - ->assertNotChecked('@sku-input-activesync') + ->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') + ->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'); + ->click('#sku-input-2fa') + ->assertNotChecked('#sku-input-2fa') + ->assertNotReadonly('#sku-input-activesync'); }); }); }); } /** * Test user adding page * * @depends testList */ public function testNewUser(): void { $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.create-user', 'Create user') ->click('button.create-user') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Last name') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Organization') ->assertValue('div.row:nth-child(3) input[type=text]', '') ->assertSeeIn('div.row:nth-child(4) label', 'Email') ->assertValue('div.row:nth-child(4) input[type=text]', '') ->assertEnabled('div.row:nth-child(4) input[type=text]') ->assertSeeIn('div.row:nth-child(5) label', 'Email aliases') ->assertVisible('div.row:nth-child(5) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(6) label', 'Password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('div.row:nth-child(7) label', 'Confirm password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Package') // assert packages list widget, select "Lite Account" ->with('@packages', function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account') ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account') ->assertSeeIn('tbody tr:nth-child(1) .price', '9,99 CHF/month') ->assertSeeIn('tbody tr:nth-child(2) .price', '4,44 CHF/month') ->assertChecked('tbody tr:nth-child(1) input') ->click('tbody tr:nth-child(2) input') ->assertNotChecked('tbody tr:nth-child(1) input') ->assertChecked('tbody tr:nth-child(2) input'); }) ->assertMissing('@packages table + .hint') ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') ->click('button[type=submit]') ->assertFocused('#password') ->type('#password', 'simple123') ->click('button[type=submit]') ->assertFocused('#password_confirmation') ->type('#password_confirmation', 'simple') ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); }); // Test form error handling (aliases) $browser->with('@form', function (Browser $browser) { $browser->type('#email', 'julia.roberts@kolab.org') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, 'The specified alias is invalid.', false); }); }); // Successful account creation $browser->with('@form', function (Browser $browser) { $browser->type('#first_name', 'Julia') ->type('#last_name', 'Roberts') ->type('#organization', 'Test Org') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(1) ->addListEntry('julia.roberts2@kolab.org'); }) ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.') // check redirection to users list ->waitForLocation('/users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first(); $this->assertTrue(!empty($alias)); $this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage']); $this->assertSame('Julia', $julia->getSetting('first_name')); $this->assertSame('Roberts', $julia->getSetting('last_name')); $this->assertSame('Test Org', $julia->getSetting('organization')); }); } /** * Test user delete * * @depends testNewUser */ public function testDeleteUser(): void { // First create a new user $john = $this->getTestUser('john@kolab.org'); $julia = $this->getTestUser('julia.roberts@kolab.org'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $john->assignPackage($package_kolab, $julia); // Test deleting non-controller user $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org') ->click('tbody tr:nth-child(4) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->whenAvailable('@table', function (Browser $browser) { $browser->click('tbody tr:nth-child(4) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.') ->with('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $this->assertTrue(empty($julia)); // Test clicking Delete on the controller record redirects to /profile/delete $browser ->with('@table', function (Browser $browser) { $browser->click('tbody tr:nth-child(3) button.button-delete'); }) ->waitForLocation('/profile/delete'); }); // Test that non-controller user cannot see/delete himself on the users list // Note: Access to /profile/delete page is tested in UserProfileTest.php $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 0); }); }); // Test that controller user (Ned) can see/delete all the users ??? $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('ned@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertElementsCount('tbody button.button-delete', 4); }); // TODO: Test the delete action in details }); // TODO: Test what happens with the logged in user session after he's been deleted by another user } /** * Test discounted sku/package prices in the UI */ public function testDiscountedPrices(): void { // Add 10% discount $discount = Discount::where('code', 'TEST')->first(); $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->save(); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit(new UserList()) ->click('@table tr:nth-child(2) a') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 5) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '3,99 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->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¹') // ActiveSync SKU ->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'); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '8,99 CHF/month¹') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '3,99 CHF/month¹'); // Lite }) ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); } }