diff --git a/src/resources/js/app.js b/src/resources/js/app.js index a3f7f344..c2e31855 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,484 +1,491 @@ /** * 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') } 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 (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) - axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { this.authInfo = response } if (dashboard !== false) { this.$router.push(routerState.afterLogin || { name: 'dashboard' }) } 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: response.refresh_token }).then(response => { + 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 this.authInfo = null localStorage.setItem('token', '') localStorage.setItem('refreshToken', '') delete axios.defaults.headers.common.Authorization 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.isImapReady === false || obj.isLdapReady === false || obj.isVerified === false || obj.isConfirmed === false) { 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.isImapReady === false || obj.isLdapReady === false || obj.isVerified === false || obj.isConfirmed === false) { return this.$t('status.notready') } return this.$t('status.active') }, // 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(/\/.*$/, '') } } }) // Fetch the locale file and the start the app loadLangAsync().then(() => app.$mount('#app')) // Add a axios request interceptor 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 + // 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/resources/vue/App.vue b/src/resources/vue/App.vue index 21d45741..c2ef3867 100644 --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -1,130 +1,128 @@