diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 5cdf1f35..be2af755 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,518 +1,455 @@ /** * 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 { Tab } from 'bootstrap' import { loadLangAsync, i18n } from './locale' - -const loader = '
Loading
' +import { clearFormValidation, pick, startLoading, stopLoading } from './utils' const routerState = { afterLogin: null, isLoggedIn: !!localStorage.getItem('token') } -let isLoading = 0 - -// Lock the UI with the 'loading...' element -const startLoading = () => { - isLoading++ - let loading = $('#app > .app-loader').removeClass('fadeOut') - if (!loading.length) { - $('#app').append($(loader)) - } -} - -// Hide "loading" overlay -const stopLoading = () => { - if (isLoading > 0) { - $('#app > .app-loader').addClass('fadeOut') - isLoading--; - } -} - 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: { - // Clear (bootstrap) form validation state - clearFormValidation(form) { - $(form).find('.is-invalid').removeClass('is-invalid') - $(form).find('.invalid-feedback').remove() - }, + clearFormValidation, 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 => { 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}` }, - // Display "loading" overlay inside of the specified element - addLoader(elem, small = true, style = null) { - if (style) { - $(elem).css(style) - } else { - $(elem).css('position', 'relative') - } - - $(elem).append(small ? $(loader).addClass('small') : $(loader)) - }, - // Create an object copy with specified properties only - pick(obj, properties) { - let result = {} - - properties.forEach(prop => { - if (prop in obj) { - result[prop] = obj[prop] - } - }) - - return result - }, - // Remove loader element added in addLoader() - removeLoader(elem) { - $(elem).find('.app-loader').remove() - }, + pick, startLoading, stopLoading, - isLoading() { - return isLoading > 0 - }, tab(e) { e.preventDefault() new Tab(e.target).show() }, 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) { - this.stopLoading() + 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 { this.errorPage(status, message) } }, - downloadFile(url, filename) { - // 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') - - if (!filename) { - const contentDisposition = response.headers['content-disposition'] - filename = 'unknown' - - if (contentDisposition) { - const match = contentDisposition.match(/filename="?(.+)"?/); - if (match && match.length === 2) { - filename = match[1]; - } - } - } - - link.href = window.URL.createObjectURL(response.data) - link.download = filename - link.click() - }) - }, price(price, currency) { // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, 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') } }, - isDegraded() { - return this.authInfo && this.authInfo.isAccountDegraded - }, 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__.showDialog() }, 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 + 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 => { + let loader = error.config.loader + if (loader) { + stopLoading(loader) + } + // Do not display the error in a toast message, pass the error as-is if (axios.isCancel(error) || error.config.ignoreErrors) { return Promise.reject(error) } if (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/js/utils.js b/src/resources/js/utils.js new file mode 100644 index 00000000..17312f73 --- /dev/null +++ b/src/resources/js/utils.js @@ -0,0 +1,134 @@ + +/** + * Clear (bootstrap) form validation state + */ +const clearFormValidation = (form) => { + $(form).find('.is-invalid').removeClass('is-invalid') + $(form).find('.invalid-feedback').remove() +} + +/** + * File downloader + */ +const downloadFile = (url, filename) => { + // 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') + + if (!filename) { + const contentDisposition = response.headers['content-disposition'] + filename = 'unknown' + + if (contentDisposition) { + const match = contentDisposition.match(/filename="?(.+)"?/); + if (match && match.length === 2) { + filename = match[1]; + } + } + } + + link.href = window.URL.createObjectURL(response.data) + link.download = filename + link.click() + }) +} + +/** + * Create an object copy with specified properties only + */ +const pick = (obj, properties) => { + let result = {} + + properties.forEach(prop => { + if (prop in obj) { + result[prop] = obj[prop] + } + }) + + return result +} + +const loader = '
Loading
' + +let isLoading = 0 + +/** + * Display the 'loading...' element, lock the UI + * + * @param array|string|DOMElement|null|bool|jQuery $element Supported input: + * - DOMElement or jQuery collection or selector string: for element-level loader inside + * - array: for element-level loader inside the element specified in the first array element + * - undefined, null or true: for page-level loader + * @param object $style Additional element style + */ +const startLoading = (element, style = null) => { + let small = false + + if (Array.isArray(element)) { + style = element[1] + element = element[0] + } + + if (element && element !== true) { + // The loader inside some page element + small = true + + if (style) { + small = style.small + delete style.small + $(element).css(style) + } else { + $(element).css('position', 'relative') + } + } else { + // The full page loader + isLoading++ + let loading = $('#app > .app-loader').removeClass('fadeOut') + if (loading.length) { + return + } + + element = $('#app') + } + + const loaderElement = $(loader) + + if (small) { + loaderElement.addClass('small') + } + + $(element).append(loaderElement) + + return loaderElement +} + +/** + * Hide the "loading" element + * + * @param array|string|DOMElement|null|bool|jQuery $element + * @see startLoading() + */ +const stopLoading = (element) => { + if (element && element !== true) { + if (Array.isArray(element)) { + element = element[0] + } + + $(element).find('.app-loader').remove() + } else if (isLoading > 0) { + $('#app > .app-loader').addClass('fadeOut') + isLoading--; + } +} + +export { + clearFormValidation, + downloadFile, + pick, + startLoading, + stopLoading +} diff --git a/src/resources/vue/Admin/Distlist.vue b/src/resources/vue/Admin/Distlist.vue index acfe289c..76477f43 100644 --- a/src/resources/vue/Admin/Distlist.vue +++ b/src/resources/vue/Admin/Distlist.vue @@ -1,116 +1,113 @@ diff --git a/src/resources/vue/Admin/Resource.vue b/src/resources/vue/Admin/Resource.vue index 64c37eff..7ddc0846 100644 --- a/src/resources/vue/Admin/Resource.vue +++ b/src/resources/vue/Admin/Resource.vue @@ -1,80 +1,77 @@ diff --git a/src/resources/vue/Admin/SharedFolder.vue b/src/resources/vue/Admin/SharedFolder.vue index 91e01b48..6ca77c63 100644 --- a/src/resources/vue/Admin/SharedFolder.vue +++ b/src/resources/vue/Admin/SharedFolder.vue @@ -1,119 +1,116 @@ diff --git a/src/resources/vue/Admin/Stats.vue b/src/resources/vue/Admin/Stats.vue index bffdc8cb..c45db4c2 100644 --- a/src/resources/vue/Admin/Stats.vue +++ b/src/resources/vue/Admin/Stats.vue @@ -1,46 +1,42 @@ diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index b48f6138..3bc64e3d 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,841 +1,832 @@ diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue index 19823114..21d45741 100644 --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -1,131 +1,130 @@ diff --git a/src/resources/vue/CompanionApp.vue b/src/resources/vue/CompanionApp.vue index bc1c3275..211692de 100644 --- a/src/resources/vue/CompanionApp.vue +++ b/src/resources/vue/CompanionApp.vue @@ -1,78 +1,73 @@ diff --git a/src/resources/vue/Distlist/Info.vue b/src/resources/vue/Distlist/Info.vue index 01d3cb3e..55e2f1cd 100644 --- a/src/resources/vue/Distlist/Info.vue +++ b/src/resources/vue/Distlist/Info.vue @@ -1,151 +1,148 @@ diff --git a/src/resources/vue/Distlist/List.vue b/src/resources/vue/Distlist/List.vue index a8dd499f..8ed1e34b 100644 --- a/src/resources/vue/Distlist/List.vue +++ b/src/resources/vue/Distlist/List.vue @@ -1,67 +1,64 @@ diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue index a86e5801..7916c6c5 100644 --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -1,227 +1,224 @@ diff --git a/src/resources/vue/Domain/List.vue b/src/resources/vue/Domain/List.vue index 3ffb5275..94f48c98 100644 --- a/src/resources/vue/Domain/List.vue +++ b/src/resources/vue/Domain/List.vue @@ -1,62 +1,59 @@ diff --git a/src/resources/vue/File/Info.vue b/src/resources/vue/File/Info.vue index 729308ae..3b8c6c1d 100644 --- a/src/resources/vue/File/Info.vue +++ b/src/resources/vue/File/Info.vue @@ -1,165 +1,162 @@ diff --git a/src/resources/vue/File/List.vue b/src/resources/vue/File/List.vue index 3753f66b..648f8b43 100644 --- a/src/resources/vue/File/List.vue +++ b/src/resources/vue/File/List.vue @@ -1,135 +1,135 @@ diff --git a/src/resources/vue/Page.vue b/src/resources/vue/Page.vue index 077f361a..de96b17e 100644 --- a/src/resources/vue/Page.vue +++ b/src/resources/vue/Page.vue @@ -1,74 +1,71 @@ diff --git a/src/resources/vue/PasswordReset.vue b/src/resources/vue/PasswordReset.vue index 58ec2068..eaf04012 100644 --- a/src/resources/vue/PasswordReset.vue +++ b/src/resources/vue/PasswordReset.vue @@ -1,179 +1,177 @@ diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/Resource/Info.vue index 423e1806..9e2caddd 100644 --- a/src/resources/vue/Resource/Info.vue +++ b/src/resources/vue/Resource/Info.vue @@ -1,188 +1,182 @@ diff --git a/src/resources/vue/Resource/List.vue b/src/resources/vue/Resource/List.vue index 236fac4c..6dc7d7ff 100644 --- a/src/resources/vue/Resource/List.vue +++ b/src/resources/vue/Resource/List.vue @@ -1,67 +1,64 @@ diff --git a/src/resources/vue/Rooms.vue b/src/resources/vue/Rooms.vue index e41ac2f2..013677ef 100644 --- a/src/resources/vue/Rooms.vue +++ b/src/resources/vue/Rooms.vue @@ -1,64 +1,60 @@ diff --git a/src/resources/vue/Settings.vue b/src/resources/vue/Settings.vue index abe21542..b3ef3a7c 100644 --- a/src/resources/vue/Settings.vue +++ b/src/resources/vue/Settings.vue @@ -1,123 +1,119 @@ diff --git a/src/resources/vue/SharedFolder/Info.vue b/src/resources/vue/SharedFolder/Info.vue index ac434ee6..1e38a2ff 100644 --- a/src/resources/vue/SharedFolder/Info.vue +++ b/src/resources/vue/SharedFolder/Info.vue @@ -1,182 +1,176 @@ diff --git a/src/resources/vue/SharedFolder/List.vue b/src/resources/vue/SharedFolder/List.vue index a306954c..e88bf31d 100644 --- a/src/resources/vue/SharedFolder/List.vue +++ b/src/resources/vue/SharedFolder/List.vue @@ -1,66 +1,63 @@ diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue index 2fb4318d..2020ece7 100644 --- a/src/resources/vue/Signup.vue +++ b/src/resources/vue/Signup.vue @@ -1,303 +1,299 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index 987a45cc..ec989702 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,332 +1,322 @@ diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue index 97290317..91b8a0fc 100644 --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -1,446 +1,430 @@ diff --git a/src/resources/vue/Widgets/ListTools.vue b/src/resources/vue/Widgets/ListTools.vue index 0dd24feb..d177ff9a 100644 --- a/src/resources/vue/Widgets/ListTools.vue +++ b/src/resources/vue/Widgets/ListTools.vue @@ -1,116 +1,99 @@ diff --git a/src/resources/vue/Widgets/PackageSelect.vue b/src/resources/vue/Widgets/PackageSelect.vue index 5460fbfe..8e0a26d2 100644 --- a/src/resources/vue/Widgets/PackageSelect.vue +++ b/src/resources/vue/Widgets/PackageSelect.vue @@ -1,86 +1,82 @@ diff --git a/src/resources/vue/Widgets/SubscriptionSelect.vue b/src/resources/vue/Widgets/SubscriptionSelect.vue index 69ef925e..9a887232 100644 --- a/src/resources/vue/Widgets/SubscriptionSelect.vue +++ b/src/resources/vue/Widgets/SubscriptionSelect.vue @@ -1,206 +1,202 @@ diff --git a/src/resources/vue/Widgets/TransactionLog.vue b/src/resources/vue/Widgets/TransactionLog.vue index 66879cfc..60e19b7c 100644 --- a/src/resources/vue/Widgets/TransactionLog.vue +++ b/src/resources/vue/Widgets/TransactionLog.vue @@ -1,92 +1,87 @@