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 ``
},
- // 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 @@