diff --git a/src/resources/js/app.js b/src/resources/js/app.js index ffc8deba..4455e3ca 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,501 +1,501 @@ /** * 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 SupportForm from '../vue/Widgets/SupportForm' import { loadLangAsync, i18n } from './locale' import { clearFormValidation, pick, startLoading, stopLoading } from './utils' const routerState = { afterLogin: null, isLoggedIn: !!localStorage.getItem('token'), isLocked: false } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.meta.requiresAuth && !routerState.isLoggedIn) { // remember the original request, to use after login routerState.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (routerState.isLocked && to.meta.requiresAuth && !['login', 'payment-status'].includes(to.name)) { // redirect to the payment-status page next({ name: 'payment-status' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() $('body').css('padding', 0) // remove padding added by unclosed modal // Close the mobile menu if ($('#header-menu .navbar-collapse.show').length) { $('#header-menu .navbar-toggler').click(); } }) const app = new Vue({ components: { AppComponent, MenuComponent, }, i18n, router: window.router, data() { return { authInfo: null, isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] } }, methods: { clearFormValidation, countriesText(list) { if (list && list.length) { let result = [] list.forEach(code => { let country = window.config.countries[code] if (country) { result.push(country[1]) } else { console.warn(`Unknown country code: ${code}`) } }) return result.join(', ') } return this.$t('form.norestrictions') }, hasPermission(type) { const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) return !!(this.authInfo && this.authInfo.statusInfo[key]) }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { return this.authInfo.statusInfo.skus && this.authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && this.authInfo) { let i for (i = 0; i < this.authInfo.wallets.length; i++) { if (wallet_id == this.authInfo.wallets[i].id) { return true } } for (i = 0; i < this.authInfo.accounts.length; i++) { if (wallet_id == this.authInfo.accounts[i].id) { return true } } } return false }, isDegraded() { return this.authInfo && this.authInfo.isAccountDegraded }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { routerState.isLoggedIn = true this.authInfo = null } localStorage.setItem('token', response.access_token) localStorage.setItem('refreshToken', response.refresh_token) if (response.email) { this.authInfo = response } routerState.isLocked = this.isUser && this.authInfo && this.authInfo.isLocked if (dashboard !== false) { this.$router.push(routerState.afterLogin || { name: response.redirect || 'dashboard' }) } else if (routerState.isLocked && this.$route.meta.requiresAuth && this.$route.name != 'payment-status') { // Always redirect locked user, here we can be after router's beforeEach handler this.$router.push({ name: 'payment-status' }) } routerState.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('api/auth/refresh', { refresh_token: localStorage.getItem('refreshToken') }).then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { - routerState.isLoggedIn = true + routerState.isLoggedIn = false this.authInfo = null localStorage.removeItem('token') localStorage.removeItem('refreshToken') if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, logo(mode) { let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' return `${this.appName}` }, pick, startLoading, stopLoading, errorPage(code, msg, hint) { // 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". if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown') if (!hint) hint = '' const error_page = '
' + `
${code}
${msg}
${hint}
` + '
' $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { stopLoading() const status = error.response ? error.response.status : 500 const message = error.response ? error.response.statusText : '' if (status == 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { routerState.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { if (!error.response) { console.error(error) } this.errorPage(status, message) } }, price(price, currency) { if (!currency) { currency = 'CHF' } else { currency = currency.toUpperCase() } let args = { style: 'currency', currency } if (currency == 'BTC') { args.minimumFractionDigits = 6 args.maximumFractionDigits = 9 } // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', args) }, priceLabel(cost, discount, currency) { let index = '' if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost, currency) + '/' + this.$t('wallet.month') + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { $(event.target).closest('tr').find('a').trigger('click') } }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, supportDialog(container) { let dialog = $('#support-dialog')[0] if (!dialog) { // FIXME: Find a nicer way of doing this SupportForm.i18n = i18n let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = form.$el } dialog.__vue__.show() }, statusClass(obj) { if (obj.isDeleted) { return 'text-muted' } if (obj.isDegraded || obj.isAccountDegraded || obj.isSuspended) { return 'text-warning' } if (!obj.isReady) { return 'text-danger' } return 'text-success' }, statusText(obj) { if (obj.isDeleted) { return this.$t('status.deleted') } if (obj.isDegraded || obj.isAccountDegraded) { return this.$t('status.degraded') } if (obj.isSuspended) { return this.$t('status.suspended') } if (!obj.isReady) { return this.$t('status.notready') } return this.$t('status.active') }, unlock() { routerState.isLocked = this.authInfo.isLocked = false this.$router.push({ name: 'dashboard' }) }, // Append some wallet properties to the object userWalletProps(object) { let wallet = this.authInfo.accounts[0] if (!wallet) { wallet = this.authInfo.wallets[0] } if (wallet) { object.currency = wallet.currency if (wallet.discount) { object.discount = wallet.discount object.discount_description = wallet.discount_description } } }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '').toLowerCase() } } }) // Fetch the locale file and the start the app loadLangAsync().then(() => app.$mount('#app')) // Add a axios request interceptor axios.interceptors.request.use( config => { // Set the Authorization header. Note that some request might force // empty Authorization header therefore we check if the header is already set, // not whether it's empty const token = localStorage.getItem('token') if (token && !('Authorization' in config.headers)) { config.headers.Authorization = 'Bearer ' + token } let loader = config.loader if (loader) { startLoading(loader) } return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } let loader = response.config.loader if (loader) { stopLoading(loader) } return response }, error => { if (error.config && error.config.loader) { stopLoading(error.config.loader) } // Do not display the error in a toast message, pass the error as-is if (axios.isCancel(error) || (error.config && error.config.ignoreErrors)) { return Promise.reject(error) } if (error.config && error.config.onFinish) { error.config.onFinish() } let error_msg const status = error.response ? error.response.status : 200 const data = error.response ? error.response.data : {} if (status == 422 && data.errors) { error_msg = app.$t('error.form') const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(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 (typeof(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 let controls = input.children(':not(:first-child)') if (!controls.length && typeof msg == 'string') { // this is an empty list (the main input only) // and the error message is not an array input.find('.main-input').addClass('is-invalid') } else { controls.each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) } input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // a special case, e.g. the invitation policy widget if (input.is('select') && input.parent().is('.input-group-select.selected')) { input = input.next() } // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.list-input)').first().focus() }) } else if (data.status == 'error') { error_msg = data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || app.$t('error.server')) // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php index f727c043..4017b96f 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,292 +1,299 @@ browse(function (Browser $browser) { $browser->visit(new Home()) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'support', 'login', 'lang']) ->assertSeeIn('#footer-copyright', \config('app.company.copyright')) ->assertSeeIn('#footer-copyright', date('Y')); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['signup', 'support', 'login']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } $browser->assertSeeLink('Forgot password?') ->assertSeeLink('Webmail'); }); } /** * Test language menu, and language change */ public function testLocales(): void { $this->browse(function (Browser $browser) { if (!$browser->isDesktop()) { $this->markTestIncomplete(); } $browser->visit(new Home()) // ->plainCookie('language', '') ->within(new Menu(), function ($browser) { $browser->assertSeeIn('@lang', 'EN') ->click('@lang'); }) // Switch English -> German ->whenAvailable('nav .dropdown-menu', function (Browser $browser) { $browser->assertElementsCount('a', 3) ->assertSeeIn('a:nth-child(1)', 'EN - English') ->assertSeeIn('a:nth-child(2)', 'DE - German') ->assertSeeIn('a:nth-child(3)', 'FR - French') ->click('a:nth-child(2)'); }) ->waitUntilMissing('nav .dropdown-menu') ->within(new Menu(), function ($browser) { $browser->assertSeeIn('@lang', 'DE'); }) ->waitForTextIn('#header-menu .link-login', 'EINLOGGEN') ->assertSeeIn('#footer-menu .link-login', 'Einloggen') ->assertSeeIn('@logon-button', 'Anmelden') // refresh the page to see if it uses the lang previously set ->refresh() ->waitForTextIn('#header-menu .link-login', 'EINLOGGEN') ->assertSeeIn('#footer-menu .link-login', 'Einloggen') ->assertSeeIn('@logon-button', 'Anmelden') ->within(new Menu(), function ($browser) { $browser->assertSeeIn('@lang', 'DE') ->click('@lang'); }) // Switch German -> English ->whenAvailable('nav .dropdown-menu', function (Browser $browser) { $browser->assertSeeIn('a:nth-child(1)', 'Englisch') ->click('a:nth-child(1)'); }) ->waitUntilMissing('nav .dropdown-menu') ->within(new Menu(), function ($browser) { $browser->assertSeeIn('@lang', 'EN'); }) ->waitForTextIn('#header-menu .link-login', 'LOGIN'); }); } /** * Test redirect to /login if user is unauthenticated */ public function testRequiredAuth(): 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 ->on(new Dashboard()) ->assertVisible('@links a.link-settings') ->assertVisible('@links a.link-domains') ->assertVisible('@links a.link-users') ->assertVisible('@links a.link-wallet') ->assertVisible('@links a.link-webmail') ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'dashboard', 'logout', 'lang']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['support', 'dashboard', 'logout']); }); } 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('/') ->waitForLocation('/dashboard') ->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->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', 'support', 'login', 'lang']); }); // 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', 'support', 'login', 'lang']); }); // 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()); }); } /** * Test redirect to the requested page after logon * * @depends test2FA */ public function testAfterLogonRedirect(): void { $this->browse(function (Browser $browser) { - // User is logged in, visit the My account page - $browser->visit('/settings') - // invalidate the session token - ->execScript("localStorage.setItem('token', '123')") - // refresh the page - ->refresh() + // User is logged in, invalidate the session token and visit /settings page + $browser->execScript("localStorage.setItem('token', '123')") + ->visit('/settings') ->on(new Home()) // log in the user ->submitLogon('john@kolab.org', 'simple123', false) // wait for a "redirect" to the My account page ->waitForLocation('/settings'); + + // User is logged in, invalidate the session token and visit root page + // and expect to land on the logon form, and then Dashboard + $browser->execScript("localStorage.setItem('token', '123')") + ->visit('/') + ->on(new Home()) + // log in the user + ->submitLogon('john@kolab.org', 'simple123', false) + // wait for a "redirect" to the Dashboard + ->waitForLocation('/dashboard'); }); } }