Page MenuHomePhorge

No OneTemporary

Size
251 KB
Referenced Files
None
Subscribers
None
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 = '<div class="app-loader"><div class="spinner-border" role="status"><span class="visually-hidden">Loading</span></div></div>'
+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 `<img src="${src}" alt="${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 = '<div id="error-page" class="error-page">'
+ `<div class="code">${code}</div><div class="message">${msg}</div><div class="hint">${hint}</div>`
+ '</div>'
$('#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($('<div>').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 = $('<div class="invalid-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 = '<div class="app-loader"><div class="spinner-border" role="status"><span class="visually-hidden">Loading</span></div></div>'
+
+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 @@
<template>
<div v-if="list.id" class="container">
<div class="card" id="distlist-info">
<div class="card-body">
<div class="card-title">{{ list.email }}</div>
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="distlistid" class="col-sm-4 col-form-label">
{{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span>
</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="distlistid">
{{ list.id }} <span class="text-muted">({{ list.created_at }})</span>
</span>
</div>
</div>
<div class="row plaintext">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(list) + ' form-control-plaintext'" id="status">{{ $root.statusText(list) }}</span>
</div>
</div>
<div class="row plaintext">
<label for="name" class="col-sm-4 col-form-label">{{ $t('distlist.name') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="name">{{ list.name }}</span>
</div>
</div>
<div class="row plaintext">
<label for="members" class="col-sm-4 col-form-label">{{ $t('distlist.recipients') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="members">
<span v-for="member in list.members" :key="member">{{ member }}<br></span>
</span>
</div>
</div>
</form>
<div class="mt-2 buttons">
<button v-if="!list.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendList">
{{ $t('btn.suspend') }}
</button>
<button v-if="list.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendList">
{{ $t('btn.unsuspend') }}
</button>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-settings" href="#distlist-settings" role="tab" aria-controls="distlist-settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="distlist-settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="sender_policy" class="col-sm-4 col-form-label">{{ $t('distlist.sender-policy') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="sender_policy">
{{ list.config.sender_policy && list.config.sender_policy.length ? list.config.sender_policy.join(', ') : $t('form.none') }}
</span>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
list: { members: [], config: {} }
}
},
created() {
- this.$root.startLoading()
-
- axios.get('/api/v4/groups/' + this.$route.params.list)
+ axios.get('/api/v4/groups/' + this.$route.params.list, { loader: true })
.then(response => {
- this.$root.stopLoading()
this.list = response.data
})
.catch(this.$root.errorHandler)
},
methods: {
suspendList() {
axios.post('/api/v4/groups/' + this.list.id + '/suspend')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.list = Object.assign({}, this.list, { isSuspended: true })
}
})
},
unsuspendList() {
axios.post('/api/v4/groups/' + this.list.id + '/unsuspend')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.list = Object.assign({}, this.list, { isSuspended: false })
}
})
}
}
}
</script>
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 @@
<template>
<div v-if="resource.id" class="container">
<div class="card" id="resource-info">
<div class="card-body">
<div class="card-title">{{ resource.email }}</div>
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="resourceid" class="col-sm-4 col-form-label">
{{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span>
</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="resourceid">
{{ resource.id }} <span class="text-muted">({{ resource.created_at }})</span>
</span>
</div>
</div>
<div class="row plaintext">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(resource) + ' form-control-plaintext'" id="status">{{ $root.statusText(resource) }}</span>
</div>
</div>
<div class="row plaintext">
<label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="name">{{ resource.name }}</span>
</div>
</div>
</form>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-settings" href="#resource-settings" role="tab" aria-controls="resource-settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="resource-settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="invitation_policy" class="col-sm-4 col-form-label">{{ $t('resource.invitation-policy') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="invitation_policy">
{{ resource.config.invitation_policy || $t('form.none') }}
</span>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
resource: { config: {} }
}
},
created() {
- this.$root.startLoading()
-
- axios.get('/api/v4/resources/' + this.$route.params.resource)
+ axios.get('/api/v4/resources/' + this.$route.params.resource, { loader: true })
.then(response => {
- this.$root.stopLoading()
this.resource = response.data
})
.catch(this.$root.errorHandler)
}
}
</script>
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 @@
<template>
<div v-if="folder.id" class="container">
<div class="card" id="folder-info">
<div class="card-body">
<div class="card-title">{{ folder.email }}</div>
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="folderid" class="col-sm-4 col-form-label">
{{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span>
</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="folderid">
{{ folder.id }} <span class="text-muted">({{ folder.created_at }})</span>
</span>
</div>
</div>
<div class="row plaintext">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(folder) + ' form-control-plaintext'" id="status">{{ $root.statusText(folder) }}</span>
</div>
</div>
<div class="row plaintext">
<label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="name">{{ folder.name }}</span>
</div>
</div>
<div class="row plaintext">
<label for="type" class="col-sm-4 col-form-label">{{ $t('form.type') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="type">{{ $t('shf.type-' + folder.type) }}</span>
</div>
</div>
</form>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-settings" href="#folder-settings" role="tab" aria-controls="folder-settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-aliases" href="#folder-aliases" role="tab" aria-controls="folder-aliases" aria-selected="false" @click="$root.tab">
{{ $t('user.aliases-email') }} ({{ folder.aliases.length }})
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="folder-settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="acl" class="col-sm-4 col-form-label">{{ $t('form.acl') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="acl">
<span v-if="folder.config.acl.length">
<span v-for="(entry, index) in folder.config.acl" :key="index">
{{ entry.replace(',', ':') }}<br>
</span>
</span>
<span v-else>{{ $t('form.none') }}</span>
</span>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="tab-pane" id="folder-aliases" role="tabpanel" aria-labelledby="tab-aliases">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(alias, index) in folder.aliases" :id="'alias' + index" :key="index">
<td>{{ alias }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('shf.aliases-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
folder: { config: {}, aliases: [] }
}
},
created() {
- this.$root.startLoading()
-
- axios.get('/api/v4/shared-folders/' + this.$route.params.folder)
+ axios.get('/api/v4/shared-folders/' + this.$route.params.folder, { loader: true })
.then(response => {
- this.$root.stopLoading()
this.folder = response.data
})
.catch(this.$root.errorHandler)
}
}
</script>
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 @@
<template>
<div id="stats-container" class="container"></div>
</template>
<script>
import { Chart } from 'frappe-charts/dist/frappe-charts.esm.js'
export default {
data() {
return {
charts: {},
chartTypes: ['users', 'users-all', 'income', 'discounts', 'vouchers']
}
},
mounted() {
this.chartTypes.forEach(chart => this.loadChart(chart))
},
methods: {
drawChart(name, data) {
if (!data.title) {
return
}
const ch = new Chart('#chart-' + name, data)
this.charts[name] = ch
},
loadChart(name) {
const chart = $('<div>').attr({ id: 'chart-' + name }).appendTo(this.$el)
- this.$root.addLoader(chart)
-
- axios.get('/api/v4/stats/chart/' + name)
+ axios.get('/api/v4/stats/chart/' + name, { loader: chart })
.then(response => {
- this.$root.removeLoader(chart)
this.drawChart(name, response.data)
})
.catch(error => {
console.error(error)
- this.$root.removeLoader(chart)
chart.append($('<span>').text(this.$t('msg.loading-failed')))
})
}
}
}
</script>
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 @@
<template>
<div class="container">
<div class="card" id="user-info">
<div class="card-body">
<h1 class="card-title">{{ user.email }}</h1>
<div class="card-text">
<form class="read-only short">
<div v-if="user.wallet.user_id != user.id" class="row plaintext">
<label for="manager" class="col-sm-4 col-form-label">{{ $t('user.managed-by') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="manager">
<router-link :to="{ path: '/user/' + user.wallet.user_id }">{{ user.wallet.user_email }}</router-link>
</span>
</div>
</div>
<div class="row plaintext">
<label for="userid" class="col-sm-4 col-form-label">ID <span class="text-muted">({{ $t('form.created') }})</span></label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="userid">
{{ user.id }} <span class="text-muted">({{ user.created_at }})</span>
</span>
</div>
</div>
<div class="row plaintext">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="status">
<span :class="$root.statusClass(user)">{{ $root.statusText(user) }}</span>
</span>
</div>
</div>
<div class="row plaintext" v-if="user.first_name">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="first_name">{{ user.first_name }}</span>
</div>
</div>
<div class="row plaintext" v-if="user.last_name">
<label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="last_name">{{ user.last_name }}</span>
</div>
</div>
<div class="row plaintext" v-if="user.organization">
<label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="organization">{{ user.organization }}</span>
</div>
</div>
<div class="row plaintext" v-if="user.phone">
<label for="phone" class="col-sm-4 col-form-label">{{ $t('form.phone') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="phone">{{ user.phone }}</span>
</div>
</div>
<div class="row plaintext">
<label for="external_email" class="col-sm-4 col-form-label">{{ $t('user.ext-email') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="external_email">
<a v-if="user.external_email" :href="'mailto:' + user.external_email">{{ user.external_email }}</a>
<btn class="btn-secondary btn-sm ms-2" @click="emailEdit">{{ $t('btn.edit') }}</btn>
</span>
</div>
</div>
<div class="row plaintext" v-if="user.billing_address">
<label for="billing_address" class="col-sm-4 col-form-label">{{ $t('user.address') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" style="white-space:pre" id="billing_address">{{ user.billing_address }}</span>
</div>
</div>
<div class="row plaintext">
<label for="country" class="col-sm-4 col-form-label">{{ $t('user.country') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="country">{{ user.country }}</span>
</div>
</div>
</form>
<div class="mt-2 buttons">
<btn v-if="!user.isSuspended" id="button-suspend" class="btn-warning" @click="suspendUser">
{{ $t('btn.suspend') }}
</btn>
<btn v-if="user.isSuspended" id="button-unsuspend" class="btn-warning" @click="unsuspendUser">
{{ $t('btn.unsuspend') }}
</btn>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-finances" href="#user-finances" role="tab" aria-controls="user-finances" aria-selected="true">
{{ $t('user.finances') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-aliases" href="#user-aliases" role="tab" aria-controls="user-aliases" aria-selected="false">
{{ $t('user.aliases') }} ({{ user.aliases.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-subscriptions" href="#user-subscriptions" role="tab" aria-controls="user-subscriptions" aria-selected="false">
{{ $t('user.subscriptions') }} ({{ skus.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-domains" href="#user-domains" role="tab" aria-controls="user-domains" aria-selected="false">
{{ $t('user.domains') }} ({{ domains.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-users" href="#user-users" role="tab" aria-controls="user-users" aria-selected="false">
{{ $t('user.users') }} ({{ users.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-distlists" href="#user-distlists" role="tab" aria-controls="user-distlists" aria-selected="false">
{{ $t('user.distlists') }} ({{ distlists.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-resources" href="#user-resources" role="tab" aria-controls="user-resources" aria-selected="false">
{{ $t('user.resources') }} ({{ resources.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-shared-folders" href="#user-shared-folders" role="tab" aria-controls="user-shared-folders" aria-selected="false">
{{ $t('dashboard.shared-folders') }} ({{ folders.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-settings" href="#user-settings" role="tab" aria-controls="user-settings" aria-selected="false">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="user-finances" role="tabpanel" aria-labelledby="tab-finances">
<div class="card-body">
<h2 class="card-title">
{{ $t('wallet.title') }}
<span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(wallet.balance, wallet.currency) }}</strong></span>
</h2>
<div class="card-text">
<form class="read-only short">
<div class="row">
<label class="col-sm-4 col-form-label">{{ $t('user.discount') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="discount">
<span>{{ wallet.discount ? (wallet.discount + '% - ' + wallet.discount_description) : 'none' }}</span>
<btn class="btn-secondary btn-sm ms-2" @click="discountEdit">{{ $t('btn.edit') }}</btn>
</span>
</div>
</div>
<div class="row" v-if="wallet.mandate && wallet.mandate.id">
<label class="col-sm-4 col-form-label">{{ $t('user.auto-payment') }}</label>
<div class="col-sm-8">
<span id="autopayment" :class="'form-control-plaintext' + (wallet.mandateState ? ' text-danger' : '')"
v-html="$t('user.auto-payment-text', {
amount: wallet.mandate.amount + ' ' + wallet.currency,
balance: wallet.mandate.balance + ' ' + wallet.currency,
method: wallet.mandate.method
})"
>
<span v-if="wallet.mandateState">({{ wallet.mandateState }})</span>.
</span>
</div>
</div>
<div class="row" v-if="wallet.providerLink">
<label class="col-sm-4 col-form-label">{{ capitalize(wallet.provider) }} {{ $t('form.id') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" v-html="wallet.providerLink"></span>
</div>
</div>
</form>
<div class="mt-2 buttons">
<btn id="button-award" class="btn-success" @click="awardDialog">{{ $t('user.add-bonus') }}</btn>
<btn id="button-penalty" class="btn-danger" @click="penalizeDialog">{{ $t('user.add-penalty') }}</btn>
</div>
</div>
<h2 class="card-title mt-4">{{ $t('wallet.transactions') }}</h2>
<transaction-log v-if="wallet.id && !walletReload" class="card-text" :wallet-id="wallet.id" :is-admin="true"></transaction-log>
</div>
</div>
<div class="tab-pane" id="user-aliases" role="tabpanel" aria-labelledby="tab-aliases">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(alias, index) in user.aliases" :id="'alias' + index" :key="index">
<td>{{ alias }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.aliases-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-subscriptions" role="tabpanel" aria-labelledby="tab-subscriptions">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('user.subscription') }}</th>
<th scope="col">{{ $t('user.price') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(sku, sku_id) in skus" :id="'sku' + sku.id" :key="sku_id">
<td>{{ sku.name }}</td>
<td class="price">{{ sku.price }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td colspan="2">{{ $t('user.subscriptions-none') }}</td>
</tr>
</tfoot>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
&sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
<div class="mt-2 buttons">
<btn class="btn-danger" id="reset2fa" v-if="has2FA" @click="reset2FADialog">{{ $t('user.reset-2fa') }}</btn>
<btn class="btn-secondary" id="addbetasku" v-if="!hasBeta" @click="addBetaSku">{{ $t('user.add-beta') }}</btn>
</div>
</div>
</div>
</div>
<div class="tab-pane" id="user-domains" role="tabpanel" aria-labelledby="tab-domains">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('domain.namespace') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="domain in domains" :id="'domain' + domain.id" :key="domain.id" @click="$root.clickRecord">
<td>
<svg-icon icon="globe" :class="$root.statusClass(domain)" :title="$root.statusText(domain)"></svg-icon>
<router-link :to="{ path: '/domain/' + domain.id }">{{ domain.namespace }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.domains-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-users" role="tabpanel" aria-labelledby="tab-users">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('form.primary-email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in users" :id="'user' + item.id" :key="item.id" @click="$root.clickRecord">
<td>
<svg-icon icon="user" :class="$root.statusClass(item)" :title="$root.statusText(item)"></svg-icon>
<router-link v-if="item.id != user.id" :to="{ path: '/user/' + item.id }">{{ item.email }}</router-link>
<span v-else>{{ item.email }}</span>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.users-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-distlists" role="tabpanel" aria-labelledby="tab-distlists">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('distlist.name') }}</th>
<th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="list in distlists" :key="list.id" @click="$root.clickRecord">
<td>
<svg-icon icon="users" :class="$root.statusClass(list)" :title="$root.statusText(list)"></svg-icon>
<router-link :to="{ path: '/distlist/' + list.id }">{{ list.name }}</router-link>
</td>
<td>
<router-link :to="{ path: '/distlist/' + list.id }">{{ list.email }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td colspan="2">{{ $t('distlist.list-empty') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-resources" role="tabpanel" aria-labelledby="tab-resources">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('form.name') }}</th>
<th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="resource in resources" :key="resource.id" @click="$root.clickRecord">
<td>
<svg-icon icon="gear" :class="$root.statusClass(resource)" :title="$root.statusText(resource)"></svg-icon>
<router-link :to="{ path: '/resource/' + resource.id }">{{ resource.name }}</router-link>
</td>
<td>
<router-link :to="{ path: '/resource/' + resource.id }">{{ resource.email }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td colspan="2">{{ $t('resource.list-empty') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-shared-folders" role="tabpanel" aria-labelledby="tab-shared-folders">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('form.name') }}</th>
<th scope="col">{{ $t('form.type') }}</th>
<th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="folder in folders" :key="folder.id" @click="$root.clickRecord">
<td>
<svg-icon icon="folder-open" :class="$root.statusClass(folder)" :title="$root.statusText(folder)"></svg-icon>
<router-link :to="{ path: '/shared-folder/' + folder.id }">{{ folder.name }}</router-link>
</td>
<td>{{ $t('shf.type-' + folder.type) }}</td>
<td><router-link :to="{ path: '/shared-folder/' + folder.id }">{{ folder.email }}</router-link></td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td colspan="3">{{ $t('shf.list-empty') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="greylist_enabled">
<span v-if="user.config.greylist_enabled" class="text-success">{{ $t('form.enabled') }}</span>
<span v-else class="text-danger">{{ $t('form.disabled') }}</span>
</span>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="discount-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.discount-title') }}</h5>
<btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
</div>
<div class="modal-body">
<p>
<select v-model="wallet.discount_id" class="form-select">
<option value="">- {{ $t('form.none') }} -</option>
<option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option>
</select>
</p>
</div>
<div class="modal-footer">
<btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
<btn class="btn-primary modal-action" @click="submitDiscount()" icon="check">{{ $t('btn.submit') }}</btn>
</div>
</div>
</div>
</div>
<div id="email-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.ext-email') }}</h5>
<btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
</div>
<div class="modal-body">
<p>
<input v-model="external_email" name="external_email" class="form-control">
</p>
</div>
<div class="modal-footer">
<btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
<btn class="btn-primary modal-action" @click="submitEmail()" icon="check">{{ $t('btn.submit') }}</btn>
</div>
</div>
</div>
</div>
<div id="oneoff-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t(oneoff_negative ? 'user.add-penalty-title' : 'user.add-bonus-title') }}</h5>
<btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
</div>
<div class="modal-body">
<form data-validation-prefix="oneoff_">
<div class="row mb-3">
<label for="oneoff_amount" class="col-form-label">{{ $t('form.amount') }}</label>
<div class="input-group">
<input type="text" class="form-control" id="oneoff_amount" v-model="oneoff_amount" required>
<span class="input-group-text">{{ wallet.currency }}</span>
</div>
</div>
<div class="row">
<label for="oneoff_description" class="col-form-label">{{ $t('form.description') }}</label>
<input class="form-control" id="oneoff_description" v-model="oneoff_description" required>
</div>
</form>
</div>
<div class="modal-footer">
<btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
<btn class="btn-primary modal-action" @click="submitOneOff()" icon="check">{{ $t('btn.submit') }}</btn>
</div>
</div>
</div>
</div>
<div id="reset-2fa-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.reset-2fa-title') }}</h5>
<btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
</div>
<div class="modal-body">
<p>{{ $t('user.2fa-hint1') }}</p>
<p>{{ $t('user.2fa-hint2') }}</p>
</div>
<div class="modal-footer">
<btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
<btn class="btn-danger modal-action" @click="reset2FA()">{{ $t('btn.reset') }}</btn>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from 'bootstrap'
import TransactionLog from '../Widgets/TransactionLog'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faFolderOpen').definition,
require('@fortawesome/free-solid-svg-icons/faGear').definition,
require('@fortawesome/free-solid-svg-icons/faGlobe').definition,
require('@fortawesome/free-solid-svg-icons/faUsers').definition,
)
export default {
components: {
TransactionLog
},
beforeRouteUpdate (to, from, next) {
// An event called when the route that renders this component has changed,
// but this component is reused in the new route.
// Required to handle links from /user/XXX to /user/YYY
next()
this.$parent.routerReload()
},
data() {
return {
oneoff_amount: '',
oneoff_description: '',
oneoff_negative: false,
discount: 0,
discount_description: '',
discounts: [],
external_email: '',
folders: [],
has2FA: false,
hasBeta: false,
wallet: {},
walletReload: false,
distlists: [],
domains: [],
resources: [],
skus: [],
sku2FA: null,
users: [],
user: {
aliases: [],
config: {},
wallet: {},
skus: {},
}
}
},
created() {
const user_id = this.$route.params.user
- this.$root.startLoading()
-
- axios.get('/api/v4/users/' + user_id)
+ axios.get('/api/v4/users/' + user_id, { loader: true })
.then(response => {
- this.$root.stopLoading()
-
this.user = response.data
- const financesTab = '#user-finances'
+ const loader = '#user-finances'
const keys = ['first_name', 'last_name', 'external_email', 'billing_address', 'phone', 'organization']
let country = this.user.settings.country
if (country && country in window.config.countries) {
country = window.config.countries[country][1]
}
this.user.country = country
keys.forEach(key => { this.user[key] = this.user.settings[key] })
this.discount = this.user.wallet.discount
this.discount_description = this.user.wallet.discount_description
// TODO: currencies, multi-wallets, accounts
// Get more info about the wallet (e.g. payment provider related)
- this.$root.addLoader(financesTab)
- axios.get('/api/v4/wallets/' + this.user.wallets[0].id)
+ axios.get('/api/v4/wallets/' + this.user.wallets[0].id, { loader })
.then(response => {
- this.$root.removeLoader(financesTab)
this.wallet = response.data
this.setMandateState()
})
- .catch(error => {
- this.$root.removeLoader(financesTab)
- })
// Create subscriptions list
axios.get('/api/v4/users/' + user_id + '/skus')
.then(response => {
// "merge" SKUs with user entitlement-SKUs
response.data.forEach(sku => {
const userSku = this.user.skus[sku.id]
if (userSku) {
let cost = userSku.costs.reduce((sum, current) => sum + current)
let item = {
id: sku.id,
name: sku.name,
cost: cost,
price: this.$root.priceLabel(cost, this.discount)
}
if (sku.range) {
item.name += ' ' + userSku.count + ' ' + sku.range.unit
}
this.skus.push(item)
if (sku.handler == 'Auth2F') {
this.has2FA = true
this.sku2FA = sku.id
} else if (sku.handler == 'Beta') {
this.hasBeta = true
}
}
})
})
// Fetch users
// TODO: Multiple wallets
axios.get('/api/v4/users?owner=' + user_id)
.then(response => {
this.users = response.data.list;
})
// Fetch domains
axios.get('/api/v4/domains?owner=' + user_id)
.then(response => {
this.domains = response.data.list
})
// Fetch distribution lists
axios.get('/api/v4/groups?owner=' + user_id)
.then(response => {
this.distlists = response.data.list
})
// Fetch resources lists
axios.get('/api/v4/resources?owner=' + user_id)
.then(response => {
this.resources = response.data.list
})
// Fetch shared folders lists
axios.get('/api/v4/shared-folders?owner=' + user_id)
.then(response => {
this.folders = response.data.list
})
})
.catch(this.$root.errorHandler)
},
mounted() {
$(this.$el).find('ul.nav-tabs a').on('click', this.$root.tab)
},
methods: {
addBetaSku() {
axios.post('/api/v4/users/' + this.user.id + '/skus/beta')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.hasBeta = true
const sku = response.data.sku
this.skus.push({
id: sku.id,
name: sku.name,
cost: sku.cost,
price: this.$root.priceLabel(sku.cost, this.discount)
})
}
})
},
capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
awardDialog() {
this.oneOffDialog(false)
},
discountEdit() {
if (!this.discount_dialog) {
const dialog = $('#discount-dialog')[0]
dialog.addEventListener('shown.bs.modal', e => {
$(dialog).find('select').focus()
// Note: Vue v-model is strict, convert null to a string
this.wallet.discount_id = this.wallet_discount_id || ''
})
this.discount_dialog = new Modal(dialog)
}
this.discount_dialog.show()
if (!this.discounts.length) {
// Fetch discounts
axios.get('/api/v4/users/' + this.user.id + '/discounts')
.then(response => {
this.discounts = response.data.list
})
}
},
emailEdit() {
this.external_email = this.user.external_email
this.$root.clearFormValidation($('#email-dialog'))
if (!this.email_dialog) {
const dialog = $('#email-dialog')[0]
dialog.addEventListener('shown.bs.modal', e => {
$(dialog).find('input').focus()
})
this.email_dialog = new Modal(dialog)
}
this.email_dialog.show()
},
setMandateState() {
let mandate = this.wallet.mandate
if (mandate && mandate.id) {
if (!mandate.isValid) {
this.wallet.mandateState = mandate.isPending ? 'pending' : 'invalid'
} else if (mandate.isDisabled) {
this.wallet.mandateState = 'disabled'
}
}
},
oneOffDialog(negative) {
this.oneoff_negative = negative
if (!this.oneoff_dialog) {
const dialog = $('#oneoff-dialog')[0]
dialog.addEventListener('shown.bs.modal', () => {
this.$root.clearFormValidation(dialog)
$(dialog).find('#oneoff_amount').focus()
})
this.oneoff_dialog = new Modal(dialog)
}
this.oneoff_dialog.show()
},
penalizeDialog() {
this.oneOffDialog(true)
},
reload() {
// this is to reload transaction log
this.walletReload = true
this.$nextTick(() => { this.walletReload = false })
},
reset2FA() {
new Modal('#reset-2fa-dialog').hide()
axios.post('/api/v4/users/' + this.user.id + '/reset2FA')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.skus = this.skus.filter(sku => sku.id != this.sku2FA)
this.has2FA = false
}
})
},
reset2FADialog() {
new Modal('#reset-2fa-dialog').show()
},
submitDiscount() {
this.discount_dialog.hide()
axios.put('/api/v4/wallets/' + this.user.wallets[0].id, { discount: this.wallet.discount_id })
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.wallet = Object.assign({}, this.wallet, response.data)
// Update prices in Subscriptions tab
if (this.user.wallet.id == response.data.id) {
this.discount = this.wallet.discount
this.discount_description = this.wallet.discount_description
this.skus.forEach(sku => {
sku.price = this.$root.priceLabel(sku.cost, this.discount)
})
}
}
})
},
submitEmail() {
axios.put('/api/v4/users/' + this.user.id, { external_email: this.external_email })
.then(response => {
if (response.data.status == 'success') {
this.email_dialog.hide()
this.$toast.success(response.data.message)
this.user.external_email = this.external_email
this.external_email = null // required because of Vue
}
})
},
submitOneOff() {
let wallet_id = this.user.wallets[0].id
let post = {
amount: this.oneoff_amount,
description: this.oneoff_description
}
if (this.oneoff_negative && /^\d+(\.?\d+)?$/.test(post.amount)) {
post.amount *= -1
}
this.$root.clearFormValidation('#oneoff-dialog')
axios.post('/api/v4/wallets/' + wallet_id + '/one-off', post)
.then(response => {
if (response.data.status == 'success') {
this.oneoff_dialog.hide()
this.$toast.success(response.data.message)
this.wallet = Object.assign({}, this.wallet, {balance: response.data.balance})
this.oneoff_amount = ''
this.oneoff_description = ''
this.reload()
}
})
},
suspendUser() {
axios.post('/api/v4/users/' + this.user.id + '/suspend')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.user = Object.assign({}, this.user, { isSuspended: true })
}
})
},
unsuspendUser() {
axios.post('/api/v4/users/' + this.user.id + '/unsuspend')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.user = Object.assign({}, this.user, { isSuspended: false })
}
})
}
}
}
</script>
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 @@
<template>
<router-view v-if="!isLoading && !routerReloading && key" :key="key" @hook:mounted="childMounted"></router-view>
</template>
<script>
export default {
data() {
return {
isLoading: true,
routerReloading: false
}
},
computed: {
key() {
// Display 403 error page if the current user has no permission to a specified page
// Note that it's the only place I found that allows us to do this.
if (this.$route.meta.perm && !this.checkPermission(this.$route.meta.perm)) {
// Returning false here will block the page component from execution,
// as we're using the key in v-if condition on the router-view above
return false
}
// The 'key' property is used to reload the Page component
// whenever a route changes. Normally vue does not do that.
return this.$route.name == '404' ? this.$route.path : 'static'
}
},
mounted() {
const token = localStorage.getItem('token')
if (token) {
- this.$root.startLoading()
axios.defaults.headers.common.Authorization = 'Bearer ' + token
const post = { refresh_token: localStorage.getItem("refreshToken") }
- axios.post('/api/auth/info?refresh=1', post, { ignoreErrors: true })
+ axios.post('/api/auth/info?refresh=1', post, { ignoreErrors: true, loader: true })
.then(response => {
this.$root.loginUser(response.data, false)
- this.$root.stopLoading()
- this.isLoading = false
})
.catch(error => {
- // Release lock on the router-view, otherwise links (e.g. Logout) will not work
- this.isLoading = false
// Handle the error, on 401 display the logon page
this.$root.errorHandler(error)
})
+ .finally(() => {
+ // Release lock on the router-view, otherwise links (e.g. Logout) will not work
+ this.isLoading = false
+ })
} else {
this.isLoading = false
}
},
methods: {
checkPermission(type) {
if (this.$root.hasPermission(type)) {
return true
}
const hint = type == 'wallets' ? this.$t('wallet.noperm') : ''
this.$root.errorPage(403, null, hint)
return false
},
childMounted() {
this.$root.updateBodyClass()
this.getFAQ()
this.degradedWarning()
},
degradedWarning() {
// Display "Account Degraded" warning on all pages
if (this.$root.isDegraded()) {
let message = this.$t('user.degraded-warning')
if (this.$root.authInfo.isDegraded) {
message += ' ' + this.$t('user.degraded-hint')
}
const html = `<div id="status-degraded" class="d-flex justify-content-center">`
+ `<p class="alert alert-danger">${message}</p></div>`
$('#app > div.container').prepend(html)
}
},
getFAQ() {
let page = this.$route.path
if (page == '/' || page == '/login') {
return
}
axios.get('/content/faq' + page, { ignoreErrors: true })
.then(response => {
const result = response.data.faq
$('#faq').remove()
if (result && result.length) {
let faq = $('<div id="faq" class="faq mt-3"><h5>' + this.$t('app.faq') + '</h5><ul class="pl-4"></ul></div>')
let list = $([])
result.forEach(item => {
let li = $('<li>').append($('<a>').attr('href', item.href).text(item.title))
// Handle internal links with the vue-router
if (item.href.charAt(0) == '/') {
li.find('a').on('click', event => {
event.preventDefault()
this.$router.push(item.href)
})
}
list = list.add(li)
})
faq.find('ul').append(list)
$(this.$el).append(faq)
}
})
},
routerReload() {
// Together with beforeRouteUpdate even on a route component
// allows us to force reload the component. So it is possible
// to jump from/to page that uses currently loaded component.
this.routerReloading = true
this.$nextTick().then(() => {
this.routerReloading = false
})
}
}
}
</script>
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 @@
<template>
<div class="container" dusk="companionapp-component">
<div class="card">
<div class="card-body">
<div class="card-title">
{{ $t('companion.title') }}
<small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small>
</div>
<div class="card-text">
<p>
{{ $t('companion.description') }}
</p>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-2" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-qrcode" href="#companion-qrcode" role="tab" aria-controls="companion-qrcode" aria-selected="true" @click="$root.tab">
{{ $t('companion.pair-new') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-list" href="#companion-list" role="tab" aria-controls="companion-list" aria-selected="false" @click="$root.tab">
{{ $t('companion.paired') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="companion-qrcode" role="tabpanel" aria-labelledby="tab-qrcode">
<div class="card-body">
<div class="card-text">
<p>
{{ $t('companion.pairing-instructions') }}
</p>
<p>
<img :src="qrcode" />
</p>
</div>
</div>
</div>
<div class="tab-pane" id="companion-list" role="tabpanel" aria-labelledby="tab-list">
<div class="card-body">
<companionapp-list class="card-text"></companionapp-list>
</div>
</div>
</div>
</div>
</template>
<script>
import CompanionappList from './Widgets/CompanionappList'
export default {
components: {
CompanionappList
},
data() {
return {
qrcode: ""
}
},
mounted() {
- this.$root.startLoading()
-
- axios.get('/api/v4/companion/pairing')
+ axios.get('/api/v4/companion/pairing', { loading: true })
.then(response => {
- this.$root.stopLoading()
this.qrcode = response.data.qrcode
})
.catch(this.$root.errorHandler)
- },
- methods: {
- },
+ }
}
</script>
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 @@
<template>
<div class="container">
<status-component v-if="list_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="distlist-info">
<div class="card-body">
<div class="card-title" v-if="list_id !== 'new'">
{{ $tc('distlist.list-title', 1) }}
<btn class="btn-outline-danger button-delete float-end" @click="deleteList()" icon="trash-can">{{ $t('distlist.delete') }}</btn>
</div>
<div class="card-title" v-if="list_id === 'new'">{{ $t('distlist.new') }}</div>
<div class="card-text">
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
{{ $t('form.general') }}
</a>
</li>
<li v-if="list_id !== 'new'" class="nav-item">
<a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="list_id !== 'new'" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(list) + ' form-control-plaintext'" id="status">{{ $root.statusText(list) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="name" class="col-sm-4 col-form-label">{{ $t('distlist.name') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="name" required v-model="list.name">
</div>
</div>
<div class="row mb-3">
<label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="email" :disabled="list_id !== 'new'" required v-model="list.email">
</div>
</div>
<div class="row mb-3">
<label for="members-input" class="col-sm-4 col-form-label">{{ $t('distlist.recipients') }}</label>
<div class="col-sm-8">
<list-input id="members" :list="list.members"></list-input>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<form @submit.prevent="submitSettings" class="card-body">
<div class="row mb-3">
<label for="sender-policy-input" class="col-sm-4 col-form-label">{{ $t('distlist.sender-policy') }}</label>
<div class="col-sm-8 pt-2">
<list-input id="sender-policy" :list="list.config.sender_policy" class="mb-1"></list-input>
<small id="sender-policy-hint" class="text-muted">
{{ $t('distlist.sender-policy-text') }}
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
ListInput,
StatusComponent
},
data() {
return {
list_id: null,
list: { members: [], config: {} },
status: {}
}
},
created() {
this.list_id = this.$route.params.list
if (this.list_id != 'new') {
- this.$root.startLoading()
-
- axios.get('/api/v4/groups/' + this.list_id)
+ axios.get('/api/v4/groups/' + this.list_id, { loader: true })
.then(response => {
- this.$root.stopLoading()
this.list = response.data
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#name').focus()
},
methods: {
deleteList() {
axios.delete('/api/v4/groups/' + this.list_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'distlists' })
}
})
},
statusUpdate(list) {
this.list = Object.assign({}, this.list, list)
},
submit() {
this.$root.clearFormValidation($('#list-info form'))
let method = 'post'
let location = '/api/v4/groups'
if (this.list_id !== 'new') {
method = 'put'
location += '/' + this.list_id
}
axios[method](location, this.list)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'distlists' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
let post = this.list.config
axios.post('/api/v4/groups/' + this.list_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
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 @@
<template>
<div class="container">
<div class="card" id="distlist-list">
<div class="card-body">
<div class="card-title">
{{ $tc('distlist.list-title', 2) }}
<small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small>
<btn-router v-if="!$root.isDegraded()" class="btn-success float-end" to="distlist/new" icon="users">
{{ $t('distlist.create') }}
</btn-router>
</div>
<div class="card-text">
<table class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">{{ $t('distlist.name') }}</th>
<th scope="col">{{ $t('distlist.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="list in lists" :key="list.id" @click="$root.clickRecord">
<td>
<svg-icon icon="users" :class="$root.statusClass(list)" :title="$root.statusText(list)"></svg-icon>
<router-link :to="{ path: 'distlist/' + list.id }">{{ list.name }}</router-link>
</td>
<td>
<router-link :to="{ path: 'distlist/' + list.id }">{{ list.email }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td colspan="2">{{ $t('distlist.list-empty') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faUsers').definition,
)
export default {
data() {
return {
lists: []
}
},
created() {
- this.$root.startLoading()
-
- axios.get('/api/v4/groups')
+ axios.get('/api/v4/groups', { loader: true })
.then(response => {
- this.$root.stopLoading()
this.lists = response.data
})
.catch(this.$root.errorHandler)
}
}
</script>
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 @@
<template>
<div class="container">
<status-component v-if="domain_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card">
<div class="card-body">
<div class="card-title" v-if="domain_id === 'new'">{{ $t('domain.new') }}</div>
<div class="card-title" v-else>{{ $t('form.domain') }}
<btn class="btn-outline-danger button-delete float-end" @click="showDeleteConfirmation()" icon="trash-can">{{ $t('domain.delete') }}</btn>
</div>
<div class="card-text">
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
{{ $t('form.general') }}
</a>
</li>
<li class="nav-item" v-if="domain.id">
<a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="domain.id" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(domain) + ' form-control-plaintext'" id="status">{{ $root.statusText(domain) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="name" class="col-sm-4 col-form-label">{{ $t('domain.namespace') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="namespace" v-model="domain.namespace" :disabled="domain.id">
</div>
</div>
<div v-if="!domain.id" id="domain-packages" class="row">
<label class="col-sm-4 col-form-label">{{ $t('user.package') }}</label>
<package-select class="col-sm-8 pt-sm-1" type="domain"></package-select>
</div>
<div v-if="domain.id" id="domain-skus" class="row">
<label class="col-sm-4 col-form-label">{{ $t('user.subscriptions') }}</label>
<subscription-select v-if="domain.id" class="col-sm-8 pt-sm-1" type="domain" :object="domain" :readonly="true"></subscription-select>
</div>
<btn v-if="!domain.id" class="btn-primary mt-3" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
<hr class="m-0" v-if="domain.id">
<div v-if="domain.id && !domain.isConfirmed" class="card-body" id="domain-verify">
<h5 class="mb-3">{{ $t('domain.verify') }}</h5>
<div class="card-text">
<p>{{ $t('domain.verify-intro') }}</p>
<p>
<span v-html="$t('domain.verify-dns')"></span>
<ul>
<li>{{ $t('domain.verify-dns-txt') }} <code>{{ domain.hash_text }}</code></li>
<li>{{ $t('domain.verify-dns-cname') }} <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
</ul>
<span>{{ $t('domain.verify-outro') }}</span>
</p>
<p>{{ $t('domain.verify-sample') }} <pre>{{ domain.dns.join("\n") }}</pre></p>
<btn class="btn-primary" @click="confirm" icon="rotate">{{ $t('btn.verify') }}</btn>
</div>
</div>
<div v-if="domain.isConfirmed" class="card-body" id="domain-config">
<h5 class="mb-3">{{ $t('domain.config') }}</h5>
<div class="card-text">
<p>{{ $t('domain.config-intro', { app: $root.appName }) }}</p>
<p>{{ $t('domain.config-sample') }} <pre>{{ domain.mx.join("\n") }}</pre></p>
<p>{{ $t('domain.config-hint') }}</p>
</div>
</div>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<form @submit.prevent="submitSettings">
<div class="row mb-3">
<label for="spf_whitelist" class="col-sm-4 col-form-label">{{ $t('domain.spf-whitelist') }}</label>
<div class="col-sm-8">
<list-input id="spf_whitelist" name="spf_whitelist" :list="spf_whitelist"></list-input>
<small id="spf-hint" class="text-muted d-block mt-2">
{{ $t('domain.spf-whitelist-text') }}
<span class="d-block" v-html="$t('domain.spf-whitelist-ex')"></span>
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="delete-warning" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('domain.delete-domain', { domain: domain.namespace }) }}</h5>
<btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
</div>
<div class="modal-body">
<p>{{ $t('domain.delete-text') }}</p>
</div>
<div class="modal-footer">
<btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
<btn class="btn-danger modal-action" @click="deleteDomain()" icon="trash-can">{{ $t('btn.delete') }}</btn>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from 'bootstrap'
import ListInput from '../Widgets/ListInput'
import PackageSelect from '../Widgets/PackageSelect'
import StatusComponent from '../Widgets/Status'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faRotate').definition,
)
export default {
components: {
ListInput,
PackageSelect,
StatusComponent,
SubscriptionSelect
},
data() {
return {
domain_id: null,
domain: {},
spf_whitelist: [],
status: {}
}
},
created() {
this.domain_id = this.$route.params.domain
if (this.domain_id !== 'new') {
- this.$root.startLoading()
-
- axios.get('/api/v4/domains/' + this.domain_id)
+ axios.get('/api/v4/domains/' + this.domain_id, { loader: true })
.then(response => {
- this.$root.stopLoading()
this.domain = response.data
this.spf_whitelist = this.domain.config.spf_whitelist || []
if (!this.domain.isConfirmed) {
$('#domain-verify button').focus()
}
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#namespace').focus()
$('#delete-warning')[0].addEventListener('shown.bs.modal', event => {
$(event.target).find('button.modal-cancel').focus()
})
},
methods: {
confirm() {
axios.get('/api/v4/domains/' + this.domain_id + '/confirm')
.then(response => {
if (response.data.status == 'success') {
this.domain.isConfirmed = true
this.status = response.data.statusInfo
}
if (response.data.message) {
this.$toast[response.data.status](response.data.message)
}
})
},
deleteDomain() {
// Delete the domain from the confirm dialog
axios.delete('/api/v4/domains/' + this.domain_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'domains' })
}
})
},
showDeleteConfirmation() {
// Display the warning
new Modal('#delete-warning').show()
},
statusUpdate(domain) {
this.domain = Object.assign({}, this.domain, domain)
},
submit() {
this.$root.clearFormValidation($('#general form'))
let post = this.$root.pick(this.domain, ['namespace'])
post.package = $('#domain-packages input:checked').val()
axios.post('/api/v4/domains', post)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'domains' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
const post = this.$root.pick(this, ['spf_whitelist'])
axios.post('/api/v4/domains/' + this.domain_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
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 @@
<template>
<div class="container">
<div class="card" id="domain-list">
<div class="card-body">
<div class="card-title">
{{ $t('user.domains') }}
<btn-router v-if="!$root.isDegraded()" class="btn-success float-end" to="domain/new" icon="globe">
{{ $t('domain.create') }}
</btn-router>
</div>
<div class="card-text">
<table class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">{{ $t('domain.namespace') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="domain in domains" :key="domain.id" @click="$root.clickRecord">
<td>
<svg-icon icon="globe" :class="$root.statusClass(domain)" :title="$root.statusText(domain)"></svg-icon>
<router-link :to="{ path: 'domain/' + domain.id }">{{ domain.namespace }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.domains-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faGlobe').definition,
)
export default {
data() {
return {
domains: []
}
},
created() {
- this.$root.startLoading()
-
- axios.get('/api/v4/domains')
+ axios.get('/api/v4/domains', { loader: true })
.then(response => {
- this.$root.stopLoading()
this.domains = response.data
})
.catch(this.$root.errorHandler)
}
}
</script>
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 @@
<template>
<div class="container">
<div class="card" id="file-info">
<div class="card-body">
<div class="card-title">
{{ file.name }}
<btn v-if="file.canDelete" class="btn-outline-danger button-delete float-end" @click="fileDelete" icon="trash-can">{{ $t('file.delete') }}</btn>
</div>
<div class="card-text">
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
{{ $t('form.general') }}
</a>
</li>
<li class="nav-item" v-if="file.isOwner">
<a class="nav-link" id="tab-sharing" href="#sharing" role="tab" aria-controls="sharing" aria-selected="false" @click="$root.tab">
{{ $t('file.sharing') }}
</a>
</li>
</ul>
<div class="tab-content">
<form class="tab-pane show active card-body read-only short" id="general" role="tabpanel" aria-labelledby="tab-general">
<div class="row plaintext">
<label for="mimetype" class="col-sm-4 col-form-label">{{ $t('file.mimetype') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="mimetype">{{ file.mimetype }}</span>
</div>
</div>
<div class="row plaintext">
<label for="size" class="col-sm-4 col-form-label">{{ $t('form.size') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="size">{{ api.sizeText(file.size) }}</span>
</div>
</div>
<div class="row plaintext mb-3">
<label for="mtime" class="col-sm-4 col-form-label">{{ $t('file.mtime') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="mtime">{{ file.mtime }}</span>
</div>
</div>
<btn class="btn-primary" icon="download" @click="fileDownload">{{ $t('btn.download') }}</btn>
</form>
<div v-if="file.isOwner" class="tab-pane card-body" id="sharing" role="tabpanel" aria-labelledby="tab-sharing">
<div id="share-form" class="mb-3">
<div class="row">
<small id="share-links-hint" class="text-muted mb-2">
{{ $t('file.sharing-links-text') }}
</small>
<div class="input-group">
<input type="text" class="form-control" id="user" :placeholder="$t('form.email')">
<a href="#" class="btn btn-outline-secondary" @click.prevent="shareAdd">
<svg-icon icon="plus"></svg-icon><span class="visually-hidden">{{ $t('btn.add') }}</span>
</a>
</div>
</div>
</div>
<div id="share-links" class="row m-0" v-if="shares.length">
<div class="list-group p-0">
<div v-for="item in shares" :key="item.id" class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<span class="user lh-lg">
<svg-icon icon="user"></svg-icon> {{ item.user }}
</span>
<span class="d-inline-block">
<btn class="btn-link p-1" :icon="['far', 'clipboard']" :title="$t('btn.copy')" @click="copyLink(item.link)"></btn>
<btn class="btn-link text-danger p-1" icon="trash-can" :title="$t('btn.delete')" @click="shareDelete(item.id)"></btn>
</span>
</div>
<code>{{ item.link }}</code>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import FileAPI from '../../js/files.js'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-regular-svg-icons/faClipboard').definition,
require('@fortawesome/free-solid-svg-icons/faDownload').definition,
)
export default {
data() {
return {
file: {},
fileId: null,
shares: []
}
},
created() {
this.api = new FileAPI({})
this.fileId = this.$route.params.file
- this.$root.startLoading()
-
- axios.get('/api/v4/files/' + this.fileId)
+ axios.get('/api/v4/files/' + this.fileId, { loader: true })
.then(response => {
- this.$root.stopLoading()
this.file = response.data
if (this.file.isOwner) {
axios.get('api/v4/files/' + this.fileId + '/permissions')
.then(response => {
if (response.data.list) {
this.shares = response.data.list
}
})
}
})
.catch(this.$root.errorHandler)
},
methods: {
copyLink(link) {
navigator.clipboard.writeText(link);
},
fileDelete() {
axios.delete('api/v4/files/' + this.fileId)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'files' })
}
})
},
fileDownload() {
this.api.fileDownload(this.fileId)
},
shareAdd() {
let post = { permissions: 'read-only', user: $('#user').val() }
if (!post.user) {
return
}
axios.post('api/v4/files/' + this.fileId + '/permissions', post)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.shares.push(response.data)
}
})
},
shareDelete(id) {
axios.delete('api/v4/files/' + this.fileId + '/permissions/' + id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$delete(this.shares, this.shares.findIndex(element => element.id == id))
}
})
}
}
}
</script>
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 @@
<template>
<div class="container">
<div class="card" id="files">
<div class="card-body">
<div class="card-title">
{{ $t('dashboard.files') }}
<small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small>
<div id="drop-area" class="file-drop-area float-end">
<svg-icon icon="upload"></svg-icon> Click or drop file(s) here
</div>
</div>
<div class="card-text pt-4">
<div class="mb-2 d-flex w-100">
<list-search :placeholder="$t('file.search')" :on-search="searchFiles"></list-search>
</div>
<table class="table table-sm table-hover files">
<thead>
<tr>
<th scope="col" class="name">{{ $t('form.name') }}</th>
<th scope="col" class="buttons"></th>
</tr>
</thead>
<tbody>
<tr v-for="file in files" :key="file.id" @click="$root.clickRecord">
<td class="name">
<svg-icon icon="file"></svg-icon>
<router-link :to="{ path: 'file/' + file.id }">{{ file.name }}</router-link>
</td>
<td class="buttons">
<btn class="button-download p-0 ms-1" @click="fileDownload(file)" icon="download" :title="$t('btn.download')"></btn>
<btn class="button-delete text-danger p-0 ms-1" @click="fileDelete(file)" icon="trash-can" :title="$t('btn.delete')"></btn>
</td>
</tr>
</tbody>
<list-foot :colspan="2" :text="$t('file.list-empty')"></list-foot>
</table>
<list-more v-if="hasMore" :on-click="loadFiles"></list-more>
</div>
</div>
</div>
</div>
</template>
<script>
import FileAPI from '../../js/files.js'
import ListTools from '../Widgets/ListTools'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faFile').definition,
require('@fortawesome/free-solid-svg-icons/faDownload').definition,
require('@fortawesome/free-solid-svg-icons/faUpload').definition,
)
export default {
mixins: [ ListTools ],
data() {
return {
api: {},
files: []
}
},
mounted() {
this.uploads = {}
this.api = new FileAPI({
dropArea: '#drop-area',
eventHandler: this.eventHandler
})
this.loadFiles({ init: true })
},
methods: {
eventHandler(name, params) {
const camelCase = name.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase())
const method = camelCase + 'Handler'
if (method in this) {
this[method](params)
}
},
fileDelete(file) {
axios.delete('api/v4/files/' + file.id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
// Refresh the list
this.loadFiles({ reset: true })
}
})
},
fileDownload(file) {
// This is not an appropriate method for big files, we can consider
// using it still for very small files.
- // this.$root.downloadFile('api/v4/files/' + file.id + '?download=1', file.name)
+ // downloadFile('api/v4/files/' + file.id + '?download=1', file.name)
// This method first makes a request to the API to get the download URL (which does not
// require authentication) and then use it to download the file.
this.api.fileDownload(file.id)
},
loadFiles(params) {
this.listSearch('files', 'api/v4/files', params)
},
searchFiles(search) {
this.loadFiles({ reset: true, search })
},
uploadProgressHandler(params) {
// Note: There might be more than one event with completed=0
// e.g. if you upload multiple files at once
if (params.completed == 0 && !(params.id in this.uploads)) {
// Create the toast message with progress bar
this.uploads[params.id] = this.$toast.message({
icon: 'upload',
timeout: 24 * 60 * 60 * 60 * 1000,
title: this.$t('msg.uploading'),
msg: `${params.name} (${this.api.sizeText(params.total)})`,
progress: 0
})
} else if (params.id in this.uploads) {
if (params.completed == 100) {
this.uploads[params.id].delete() // close the toast message
delete this.uploads[params.id]
// TODO: Reloading the list is probably not the best solution
this.loadFiles({ reset: true })
} else {
// update progress bar
this.uploads[params.id].updateProgress(params.completed)
}
}
}
}
}
</script>
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 @@
<template>
<div class="page-content container" @click="clickHandler" v-html="content"></div>
</template>
<script>
export default {
data() {
return {
content: ''
}
},
mounted() {
let page = this.$root.pageName()
// Redirect / to /dashboard, if root page is not defined
if (page == '404' && this.$route.path == '/') {
this.$router.push({ name: 'dashboard' })
return
}
- this.$root.startLoading()
-
- axios.get('/content/page/' + page, { ignoreErrors: true })
+ axios.get('/content/page/' + page, { ignoreErrors: true, loader: true })
.then(response => {
- this.$root.stopLoading()
this.content = response.data
})
.catch(this.$root.errorHandler)
},
methods: {
clickHandler(event) {
// ensure we use the link, in case the click has been received by a subelement
let target = event.target
while (target && target.tagName !== 'A') {
target = target.parentNode
}
// handle only links that do not reference external resources
if (target && target.href && !target.getAttribute('href').match(/:\/\//)) {
const { altKey, ctrlKey, metaKey, shiftKey, button, defaultPrevented } = event
if (
// don't handle with control keys
metaKey || altKey || ctrlKey || shiftKey
// don't handle when preventDefault called
|| defaultPrevented
// don't handle right clicks
|| (button !== undefined && button !== 0)
// don't handle if `target="_blank"`
|| /_blank/i.test(target.getAttribute('target'))
) {
return
}
// don't handle same page links/anchors
const url = new URL(target.href)
const to = url.pathname
if (to == '/support/contact') {
event.preventDefault()
this.$root.supportDialog(this.$el)
return
}
if (window.location.pathname !== to) {
event.preventDefault()
this.$router.push(to)
}
}
}
}
}
</script>
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 @@
<template>
<div class="container">
<div class="card" id="step1">
<div class="card-body">
<h4 class="card-title">{{ $t('password.reset') }} - {{ $t('nav.step', { i: 1, n: 3 }) }}</h4>
<p class="card-text">
{{ $t('password.reset-step1') }}
<span v-if="fromEmail">{{ $t('password.reset-step1-hint', { email: fromEmail }) }}</span>
</p>
<form @submit.prevent="submitStep1" data-validation-prefix="reset_">
<div class="mb-3">
<label for="reset_email" class="visually-hidden">{{ $t('form.email') }}</label>
<input type="text" class="form-control" id="reset_email" :placeholder="$t('form.email')" required v-model="email">
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.continue') }}</btn>
</form>
</div>
</div>
<div class="card d-none" id="step2">
<div class="card-body">
<h4 class="card-title">{{ $t('password.reset') }} - {{ $t('nav.step', { i: 2, n: 3 }) }}</h4>
<p class="card-text">
{{ $t('password.reset-step2') }}
</p>
<form @submit.prevent="submitStep2" data-validation-prefix="reset_">
<div class="mb-3">
<label for="reset_short_code" class="visually-hidden">{{ $t('form.code') }}</label>
<input type="text" class="form-control" id="reset_short_code" :placeholder="$t('form.code')" required v-model="short_code">
</div>
<btn class="btn-secondary" @click="stepBack">{{ $t('btn.back') }}</btn>
<btn class="btn-primary ms-2" type="submit" icon="check">{{ $t('btn.continue') }}</btn>
<input type="hidden" id="reset_code" v-model="code" />
</form>
</div>
</div>
<div class="card d-none" id="step3">
<div class="card-body">
<h4 class="card-title">{{ $t('password.reset') }} - {{ $t('nav.step', { i: 3, n: 3 }) }}</h4>
<p class="card-text">
</p>
<form @submit.prevent="submitStep3" data-validation-prefix="reset_">
<password-input class="mb-3" v-model="pass" :user="userId" v-if="userId" :focus="true"></password-input>
<div class="form-group pt-1 mb-3">
<label for="secondfactor" class="visually-hidden">{{ $t('login.2fa') }}</label>
<div class="input-group">
<span class="input-group-text">
<svg-icon icon="key"></svg-icon>
</span>
<input type="text" id="secondfactor" class="form-control rounded-end" :placeholder="$t('login.2fa')" v-model="secondfactor">
</div>
<small class="form-text text-muted">{{ $t('login.2fa_desc') }}</small>
</div>
<btn class="btn-secondary" @click="stepBack">{{ $t('btn.back') }}</btn>
<btn class="btn-primary ms-2" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</template>
<script>
import PasswordInput from './Widgets/PasswordInput'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faKey').definition,
)
export default {
components: {
PasswordInput
},
data() {
return {
email: '',
code: '',
short_code: '',
pass: {},
secondfactor: '',
userId: null,
fromEmail: window.config['mail.from.address']
}
},
created() {
// Verification code provided, auto-submit Step 2
if (this.$route.params.code) {
if (/^([A-Z0-9]+)-([a-zA-Z0-9]+)$/.test(this.$route.params.code)) {
this.short_code = RegExp.$1
this.code = RegExp.$2
this.submitStep2(true)
} else {
this.$root.errorPage(404)
}
}
},
mounted() {
// Focus the first input (autofocus does not work when using the menu/router)
this.displayForm(1, true)
},
methods: {
// Submits data to the API, validates and gets verification code
submitStep1() {
this.$root.clearFormValidation($('#step1 form'))
axios.post('/api/auth/password-reset/init', {
email: this.email
}).then(response => {
this.displayForm(2, true)
this.code = response.data.code
})
},
// Submits the code to the API for verification
submitStep2(bylink) {
let post = {
code: this.code,
short_code: this.short_code
}
let params = {}
if (bylink === true) {
- this.$root.startLoading()
params.ignoreErrors = true
+ params.loader = true
}
this.$root.clearFormValidation($('#step2 form'))
axios.post('/api/auth/password-reset/verify', post, params).then(response => {
- this.$root.stopLoading()
this.userId = response.data.userId
this.displayForm(3, true)
}).catch(error => {
if (bylink === true) {
- this.$root.stopLoading()
this.$root.errorPage(404, '', this.$t('password.link-invalid'))
}
})
},
// Submits the data to the API to reset the password
submitStep3() {
this.$root.clearFormValidation($('#step3 form'))
const post = {
...this.$root.pick(this, ['code', 'short_code', 'secondfactor']),
...this.pass
}
axios.post('/api/auth/password-reset', post)
.then(response => {
// auto-login and goto dashboard
this.$root.loginUser(response.data)
})
},
// Moves the user a step back in registration form
stepBack(e) {
var card = $(e.target).closest('.card')
card.prev().removeClass('d-none').find('input').first().focus()
card.addClass('d-none').find('form')[0].reset()
this.userId = null
},
displayForm(step, focus) {
[1, 2, 3].filter(value => value != step).forEach(value => {
$('#step' + value).addClass('d-none')
})
$('#step' + step).removeClass('d-none')
if (focus) {
$('#step' + step).find('input').first().focus()
}
}
}
}
</script>
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 @@
<template>
<div class="container">
<status-component v-if="resource_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="resource-info">
<div class="card-body">
<div class="card-title" v-if="resource_id !== 'new'">
{{ $tc('resource.list-title', 1) }}
<btn class="btn-outline-danger button-delete float-end" @click="deleteResource()" icon="trash-can">{{ $t('resource.delete') }}</btn>
</div>
<div class="card-title" v-if="resource_id === 'new'">{{ $t('resource.new') }}</div>
<div class="card-text">
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
{{ $t('form.general') }}
</a>
</li>
<li v-if="resource_id !== 'new'" class="nav-item">
<a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="resource_id !== 'new'" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(resource) + ' form-control-plaintext'" id="status">{{ $root.statusText(resource) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="name" v-model="resource.name">
</div>
</div>
<div v-if="domains.length" class="row mb-3">
<label for="domain" class="col-sm-4 col-form-label">{{ $t('form.domain') }}</label>
<div class="col-sm-8">
<select class="form-select" v-model="resource.domain">
<option v-for="_domain in domains" :key="_domain.id" :value="_domain.namespace">{{ _domain.namespace }}</option>
</select>
</div>
</div>
<div v-if="resource.email" class="row mb-3">
<label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="email" disabled v-model="resource.email">
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<form @submit.prevent="submitSettings" class="card-body">
<div class="row mb-3">
<label for="invitation_policy" class="col-sm-4 col-form-label">{{ $t('resource.invitation-policy') }}</label>
<div class="col-sm-8">
<div class="input-group input-group-select mb-1">
<select class="form-select" id="invitation_policy" v-model="resource.config.invitation_policy" @change="policyChange">
<option value="accept">{{ $t('resource.ipolicy-accept') }}</option>
<option value="manual">{{ $t('resource.ipolicy-manual') }}</option>
<option value="reject">{{ $t('resource.ipolicy-reject') }}</option>
</select>
<input type="text" class="form-control" id="owner" v-model="resource.config.owner" :placeholder="$t('form.email')">
</div>
<small id="invitation-policy-hint" class="text-muted">
{{ $t('resource.invitation-policy-text') }}
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import StatusComponent from '../Widgets/Status'
export default {
components: {
StatusComponent
},
data() {
return {
domains: [],
resource_id: null,
resource: { config: {} },
status: {}
}
},
created() {
this.resource_id = this.$route.params.resource
if (this.resource_id != 'new') {
- this.$root.startLoading()
-
- axios.get('/api/v4/resources/' + this.resource_id)
+ axios.get('/api/v4/resources/' + this.resource_id, { loader: true })
.then(response => {
- this.$root.stopLoading()
this.resource = response.data
this.status = response.data.statusInfo
if (this.resource.config.invitation_policy.match(/^manual:(.+)$/)) {
this.resource.config.owner = RegExp.$1
this.resource.config.invitation_policy = 'manual'
}
this.$nextTick().then(() => { this.policyChange() })
})
.catch(this.$root.errorHandler)
} else {
- this.$root.startLoading()
-
- axios.get('/api/v4/domains')
+ axios.get('/api/v4/domains', { loader: true })
.then(response => {
- this.$root.stopLoading()
this.domains = response.data
this.resource.domain = this.domains[0].namespace
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#name').focus()
},
methods: {
deleteResource() {
axios.delete('/api/v4/resources/' + this.resource_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'resources' })
}
})
},
policyChange() {
let select = $('#invitation_policy')
select.parent()[select.val() == 'manual' ? 'addClass' : 'removeClass']('selected')
},
statusUpdate(resource) {
this.resource = Object.assign({}, this.resource, resource)
},
submit() {
this.$root.clearFormValidation($('#resource-info form'))
let method = 'post'
let location = '/api/v4/resources'
if (this.resource_id !== 'new') {
method = 'put'
location += '/' + this.resource_id
}
const post = this.$root.pick(this.resource, ['id', 'name', 'domain'])
axios[method](location, post)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'resources' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
let post = this.$root.pick(this.resource.config, ['invitation_policy', 'owner'])
if (post.invitation_policy == 'manual') {
post.invitation_policy += ':' + post.owner
}
delete post.owner
axios.post('/api/v4/resources/' + this.resource_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
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 @@
<template>
<div class="container">
<div class="card" id="resource-list">
<div class="card-body">
<div class="card-title">
{{ $tc('resource.list-title', 2) }}
<small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small>
<btn-router v-if="!$root.isDegraded()" to="resource/new" class="btn-success float-end" icon="gear">
{{ $t('resource.create') }}
</btn-router>
</div>
<div class="card-text">
<table class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">{{ $t('form.name') }}</th>
<th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="resource in resources" :key="resource.id" @click="$root.clickRecord">
<td>
<svg-icon icon="gear" :class="$root.statusClass(resource)" :title="$root.statusText(resource)"></svg-icon>
<router-link :to="{ path: 'resource/' + resource.id }">{{ resource.name }}</router-link>
</td>
<td>
<router-link :to="{ path: 'resource/' + resource.id }">{{ resource.email }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td colspan="2">{{ $t('resource.list-empty') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faGear').definition,
)
export default {
data() {
return {
resources: []
}
},
created() {
- this.$root.startLoading()
-
- axios.get('/api/v4/resources')
+ axios.get('/api/v4/resources', { loader: true })
.then(response => {
- this.$root.stopLoading()
this.resources = response.data
})
.catch(this.$root.errorHandler)
}
}
</script>
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 @@
<template>
<div class="container" dusk="rooms-component">
<div id="meet-rooms" class="card">
<div class="card-body">
<div class="card-title">{{ $t('meet.title') }} <small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small></div>
<div class="card-text">
<p>{{ $t('meet.welcome') }}</p>
<p>{{ $t('meet.url') }}</p>
<p><router-link v-if="href" :to="roomRoute">{{ href }}</router-link></p>
<p>{{ $t('meet.notice') }}</p>
<dl>
<dt>{{ $t('meet.sharing') }}</dt>
<dd>{{ $t('meet.sharing-text') }}</dd>
<dt>{{ $t('meet.security') }}</dt>
<dd>{{ $t('meet.security-text') }}</dd>
<dt>{{ $t('meet.qa-title') }}</dt>
<dd>{{ $t('meet.qa-text') }}</dd>
<dt>{{ $t('meet.moderation') }}</dt>
<dd>{{ $t('meet.moderation-text') }}</dd>
<dt>{{ $t('meet.eject') }}</dt>
<dd>{{ $t('meet.eject-text') }}</dd>
<dt>{{ $t('meet.silent') }}</dt>
<dd>{{ $t('meet.silent-text') }}</dd>
<dt>{{ $t('meet.interpreters') }}</dt>
<dd>{{ $t('meet.interpreters-text') }}</dd>
</dl>
<p>{{ $t('meet.beta-notice') }}</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
rooms: [],
href: '',
roomRoute: ''
}
},
mounted() {
if (!this.$root.hasSKU('meet') || this.$root.isDegraded()) {
this.$root.errorPage(403)
return
}
- this.$root.startLoading()
-
- axios.get('/api/v4/meet/rooms')
+ axios.get('/api/v4/meet/rooms', { loader: true })
.then(response => {
- this.$root.stopLoading()
-
this.rooms = response.data.list
if (response.data.count) {
this.roomRoute = '/meet/' + encodeURI(this.rooms[0].name)
this.href = window.config['app.url'] + this.roomRoute
}
})
.catch(this.$root.errorHandler)
}
}
</script>
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 @@
<template>
<div class="container">
<div class="card" id="settings">
<div class="card-body">
<div class="card-title">
{{ $t('dashboard.settings') }}
</div>
<div class="card-text">
<form @submit.prevent="submit">
<div class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('settings.password-policy') }}</label>
<div class="col-sm-8">
<ul id="password_policy" class="list-group ms-1 mt-1">
<li v-for="rule in passwordPolicy" :key="rule.label" class="list-group-item border-0 form-check pt-1 pb-1">
<input type="checkbox" class="form-check-input"
:id="'policy-' + rule.label"
:name="rule.label"
:checked="rule.enabled || isRequired(rule)"
:disabled="isRequired(rule)"
>
<span v-if="rule.label == 'last'" v-html="ruleLastHTML(rule)"></span>
<label v-else :for="'policy-' + rule.label" class="form-check-label pe-2" style="opacity: 1;">{{ rule.name.split(':')[0] }}</label>
<input type="text" class="form-control form-control-sm w-auto d-inline" v-if="['min', 'max'].includes(rule.label)" :value="rule.param" size="3">
</li>
</ul>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('settings.password-retention') }}</label>
<div class="col-sm-8">
<ul id="password_retention" class="list-group ms-1 mt-1">
<li class="list-group-item border-0 form-check pt-1 pb-1">
<input type="checkbox" class="form-check-input" id="max_password_age" :checked="config.max_password_age">
<label for="max_password_age" class="form-check-label pe-2">{{ $t('settings.password-max-age') }}</label>
<select class="form-select form-select-sm d-inline w-auto" id="max_password_age_value">
<option v-for="num in [3, 6, 9, 12]" :key="num" :value="num" :selected="num == config.max_password_age">
{{ num }} {{ $t('form.months') }}
</option>
</select>
</li>
</ul>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
config: [],
passwordPolicy: []
}
},
created() {
this.wallet = this.$root.authInfo.wallet
},
mounted() {
- this.$root.startLoading()
-
- axios.get('/api/v4/password-policy')
+ axios.get('/api/v4/password-policy', { loader: true })
.then(response => {
- this.$root.stopLoading()
-
if (response.data.list) {
this.passwordPolicy = response.data.list
this.config = response.data.config
}
})
.catch(this.$root.errorHandler)
},
methods: {
isRequired(rule) {
return rule.label == 'min' || rule.label == 'max'
},
ruleLastHTML(rule) {
let parts = rule.name.split(/[0-9]+/)
let options = [1, 2, 3, 4, 5, 6]
options = options.map(num => {
let selected = num == rule.param ? ' selected' : ''
return `<option value="${num}"${selected}>${num}</option>`
})
return `<label for="policy-last" class="form-check-label pe-2">
${parts[0]} <select class="form-select form-select-sm d-inline w-auto">${options.join('')}</select> ${parts[1]}
</label>`
},
submit() {
this.$root.clearFormValidation($('#settings form'))
let max_password_age = $('#max_password_age:checked').length ? $('#max_password_age_value').val() : 0
let password_policy = [];
$('#password_policy > li > input:checked').each((i, element) => {
let entry = element.name
let param = $(element.parentNode).find('select,input[type=text]').val()
if (param) {
entry += ':' + param
}
password_policy.push(entry)
})
let post = {
max_password_age,
password_policy: password_policy.join(','),
}
axios.post('/api/v4/users/' + this.wallet.user_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
},
}
}
</script>
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 @@
<template>
<div class="container">
<status-component v-if="folder_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="folder-info">
<div class="card-body">
<div class="card-title" v-if="folder_id !== 'new'">
{{ $tc('shf.list-title', 1) }}
<btn class="btn-outline-danger button-delete float-end" @click="deleteFolder()" icon="trash-can">{{ $t('shf.delete') }}</btn>
</div>
<div class="card-title" v-if="folder_id === 'new'">{{ $t('shf.new') }}</div>
<div class="card-text">
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
{{ $t('form.general') }}
</a>
</li>
<li v-if="folder_id !== 'new'" class="nav-item">
<a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="folder_id !== 'new'" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(folder) + ' form-control-plaintext'" id="status">{{ $root.statusText(folder) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="name" v-model="folder.name">
</div>
</div>
<div class="row mb-3">
<label for="type" class="col-sm-4 col-form-label">{{ $t('form.type') }}</label>
<div class="col-sm-8">
<select id="type" class="form-select" v-model="folder.type" :disabled="folder_id !== 'new'">
<option v-for="type in types" :key="type" :value="type">{{ $t('shf.type-' + type) }}</option>
</select>
</div>
</div>
<div v-if="domains.length" class="row mb-3">
<label for="domain" class="col-sm-4 col-form-label">{{ $t('form.domain') }}</label>
<div v-if="domains.length" class="col-sm-8">
<select class="form-select" v-model="folder.domain">
<option v-for="_domain in domains" :key="_domain.id" :value="_domain.namespace">{{ _domain.namespace }}</option>
</select>
</div>
</div>
<div class="row mb-3" v-if="folder.type == 'mail'">
<label for="aliases-input" class="col-sm-4 col-form-label">{{ $t('form.emails') }}</label>
<div class="col-sm-8">
<list-input id="aliases" :list="folder.aliases"></list-input>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<form @submit.prevent="submitSettings" class="card-body">
<div class="row mb-3">
<label for="acl-input" class="col-sm-4 col-form-label">{{ $t('form.acl') }}</label>
<div class="col-sm-8">
<acl-input id="acl" v-model="folder.config.acl" :list="folder.config.acl" class="mb-1"></acl-input>
<small id="acl-hint" class="text-muted">
{{ $t('shf.acl-text') }}
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import AclInput from '../Widgets/AclInput'
import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
AclInput,
ListInput,
StatusComponent
},
data() {
return {
domains: [],
folder_id: null,
folder: { type: 'mail', config: {}, aliases: [] },
status: {},
types: [ 'mail', 'event', 'task', 'contact', 'note', 'file' ]
}
},
created() {
this.folder_id = this.$route.params.folder
if (this.folder_id != 'new') {
- this.$root.startLoading()
-
- axios.get('/api/v4/shared-folders/' + this.folder_id)
+ axios.get('/api/v4/shared-folders/' + this.folder_id, { loader: true })
.then(response => {
- this.$root.stopLoading()
this.folder = response.data
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
} else {
- this.$root.startLoading()
-
- axios.get('/api/v4/domains')
+ axios.get('/api/v4/domains', { loader: true })
.then(response => {
- this.$root.stopLoading()
this.domains = response.data
this.folder.domain = this.domains[0].namespace
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#name').focus()
},
methods: {
deleteFolder() {
axios.delete('/api/v4/shared-folders/' + this.folder_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'shared-folders' })
}
})
},
statusUpdate(folder) {
this.folder = Object.assign({}, this.folder, folder)
},
submit() {
this.$root.clearFormValidation($('#folder-info form'))
let method = 'post'
let location = '/api/v4/shared-folders'
if (this.folder_id !== 'new') {
method = 'put'
location += '/' + this.folder_id
}
const post = this.$root.pick(this.folder, ['id', 'name', 'domain', 'type', 'aliases'])
if (post.type != 'mail') {
delete post.aliases
}
axios[method](location, post)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'shared-folders' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
let post = this.$root.pick(this.folder.config, ['acl'])
axios.post('/api/v4/shared-folders/' + this.folder_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
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 @@
<template>
<div class="container">
<div class="card" id="folder-list">
<div class="card-body">
<div class="card-title">
{{ $tc('shf.list-title', 2) }}
<small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small>
<btn-router v-if="!$root.isDegraded()" to="shared-folder/new" class="btn-success float-end" icon="gear">
{{ $t('shf.create') }}
</btn-router>
</div>
<div class="card-text">
<table class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">{{ $t('form.name') }}</th>
<th scope="col">{{ $t('form.type') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="folder in folders" :key="folder.id" @click="$root.clickRecord">
<td>
<svg-icon icon="folder-open" :class="$root.statusClass(folder)" :title="$root.statusText(folder)"></svg-icon>
<router-link :to="{ path: 'shared-folder/' + folder.id }">{{ folder.name }}</router-link>
</td>
<td>{{ $t('shf.type-' + folder.type) }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td colspan="2">{{ $t('shf.list-empty') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faFolderOpen').definition,
require('@fortawesome/free-solid-svg-icons/faGear').definition,
)
export default {
data() {
return {
folders: []
}
},
created() {
- this.$root.startLoading()
-
- axios.get('/api/v4/shared-folders')
+ axios.get('/api/v4/shared-folders', { loader: true })
.then(response => {
- this.$root.stopLoading()
this.folders = response.data
})
.catch(this.$root.errorHandler)
}
}
</script>
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 @@
<template>
<div class="container">
<div id="step0" v-if="!invitation">
<div class="plan-selector row row-cols-sm-2 g-3">
<div v-for="item in plans" :key="item.id">
<div :class="'card bg-light plan-' + item.title">
<div class="card-header plan-header">
<div class="plan-ico text-center">
<svg-icon :icon="plan_icons[item.title]"></svg-icon>
</div>
</div>
<div class="card-body text-center">
<btn class="btn-primary" :data-title="item.title" @click="selectPlan(item.title)" v-html="item.button"></btn>
<div class="plan-description text-start mt-3" v-html="item.description"></div>
</div>
</div>
</div>
</div>
</div>
<div class="card d-none" id="step1" v-if="!invitation">
<div class="card-body">
<h4 class="card-title">{{ $t('signup.title') }} - {{ $t('nav.step', { i: 1, n: 3 }) }}</h4>
<p class="card-text">
{{ $t('signup.step1') }}
</p>
<form @submit.prevent="submitStep1" data-validation-prefix="signup_">
<div class="mb-3">
<div class="input-group">
<input type="text" class="form-control" id="signup_first_name" :placeholder="$t('form.firstname')" autofocus v-model="first_name">
<input type="text" class="form-control rounded-end" id="signup_last_name" :placeholder="$t('form.surname')" v-model="last_name">
</div>
</div>
<div class="mb-3">
<label for="signup_email" class="visually-hidden">{{ $t('signup.email') }}</label>
<input type="text" class="form-control" id="signup_email" :placeholder="$t('signup.email')" required v-model="email">
</div>
<btn class="btn-secondary" @click="stepBack">{{ $t('btn.back') }}</btn>
<btn class="btn-primary ms-2" type="submit" icon="check">{{ $t('btn.continue') }}</btn>
</form>
</div>
</div>
<div class="card d-none" id="step2" v-if="!invitation">
<div class="card-body">
<h4 class="card-title">{{ $t('signup.title') }} - {{ $t('nav.step', { i: 2, n: 3 }) }}</h4>
<p class="card-text">
{{ $t('signup.step2') }}
</p>
<form @submit.prevent="submitStep2" data-validation-prefix="signup_">
<div class="mb-3">
<label for="signup_short_code" class="visually-hidden">{{ $t('form.code') }}</label>
<input type="text" class="form-control" id="signup_short_code" :placeholder="$t('form.code')" required v-model="short_code">
</div>
<btn class="btn-secondary" @click="stepBack">{{ $t('btn.back') }}</btn>
<btn class="btn-primary ms-2" type="submit" icon="check">{{ $t('btn.continue') }}</btn>
<input type="hidden" id="signup_code" v-model="code" />
</form>
</div>
</div>
<div class="card d-none" id="step3">
<div class="card-body">
<h4 v-if="!invitation" class="card-title">{{ $t('signup.title') }} - {{ $t('nav.step', { i: 3, n: 3 }) }}</h4>
<p class="card-text">
{{ $t('signup.step3') }}
</p>
<form @submit.prevent="submitStep3" data-validation-prefix="signup_">
<div class="mb-3" v-if="invitation">
<div class="input-group">
<input type="text" class="form-control" id="signup_first_name" :placeholder="$t('form.firstname')" autofocus v-model="first_name">
<input type="text" class="form-control rounded-end" id="signup_last_name" :placeholder="$t('form.surname')" v-model="last_name">
</div>
</div>
<div class="mb-3">
<label for="signup_login" class="visually-hidden"></label>
<div class="input-group">
<input type="text" class="form-control" id="signup_login" required v-model="login" :placeholder="$t('signup.login')">
<span class="input-group-text">@</span>
<input v-if="is_domain" type="text" class="form-control rounded-end" id="signup_domain" required v-model="domain" :placeholder="$t('form.domain')">
<select v-else class="form-select rounded-end" id="signup_domain" required v-model="domain">
<option v-for="_domain in domains" :key="_domain" :value="_domain">{{ _domain }}</option>
</select>
</div>
</div>
<password-input class="mb-3" v-model="pass"></password-input>
<div class="mb-3">
<label for="signup_voucher" class="visually-hidden">{{ $t('signup.voucher') }}</label>
<input type="text" class="form-control" id="signup_voucher" :placeholder="$t('signup.voucher')" v-model="voucher">
</div>
<btn v-if="!invitation" class="btn-secondary me-2" @click="stepBack">{{ $t('btn.back') }}</btn>
<btn class="btn-primary" type="submit" icon="check">
<span v-if="invitation">{{ $t('btn.signup') }}</span>
<span v-else>{{ $t('btn.submit') }}</span>
</btn>
</form>
</div>
</div>
</div>
</template>
<script>
import PasswordInput from './Widgets/PasswordInput'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faUsers').definition,
)
export default {
components: {
PasswordInput
},
data() {
return {
email: '',
first_name: '',
last_name: '',
code: '',
short_code: '',
login: '',
pass: {},
domain: '',
domains: [],
invitation: null,
is_domain: false,
plan: null,
plan_icons: {
individual: 'user',
group: 'users'
},
plans: [],
voucher: ''
}
},
mounted() {
let param = this.$route.params.param;
if (this.$route.name == 'signup-invite') {
- this.$root.startLoading()
- axios.get('/api/auth/signup/invitations/' + param)
+ axios.get('/api/auth/signup/invitations/' + param, { loader: true })
.then(response => {
this.invitation = response.data
this.login = response.data.login
this.voucher = response.data.voucher
this.first_name = response.data.first_name
this.last_name = response.data.last_name
this.plan = response.data.plan
this.is_domain = response.data.is_domain
this.setDomain(response.data)
- this.$root.stopLoading()
this.displayForm(3, true)
})
.catch(error => {
this.$root.errorHandler(error)
})
} else if (param) {
if (this.$route.path.indexOf('/signup/voucher/') === 0) {
// Voucher (discount) code
this.voucher = param
this.displayForm(0)
} else if (/^([A-Z0-9]+)-([a-zA-Z0-9]+)$/.test(param)) {
// Verification code provided, auto-submit Step 2
this.short_code = RegExp.$1
this.code = RegExp.$2
this.submitStep2(true)
} else if (/^([a-zA-Z_]+)$/.test(param)) {
// Plan title provided, save it and display Step 1
this.plan = param
this.displayForm(1, true)
} else {
this.$root.errorPage(404)
}
} else {
this.displayForm(0)
}
},
methods: {
selectPlan(plan) {
this.$router.push({path: '/signup/' + plan})
this.plan = plan
this.displayForm(1, true)
},
// Composes plan selection page
step0() {
if (!this.plans.length) {
- this.$root.startLoading()
- axios.get('/api/auth/signup/plans').then(response => {
- this.$root.stopLoading()
+ axios.get('/api/auth/signup/plans', { loader: true }).then(response => {
this.plans = response.data.plans
})
.catch(error => {
this.$root.errorHandler(error)
})
}
},
// Submits data to the API, validates and gets verification code
submitStep1() {
this.$root.clearFormValidation($('#step1 form'))
const post = this.$root.pick(this, ['email', 'last_name', 'first_name', 'plan', 'voucher'])
axios.post('/api/auth/signup/init', post)
.then(response => {
this.displayForm(2, true)
this.code = response.data.code
})
},
// Submits the code to the API for verification
submitStep2(bylink) {
if (bylink === true) {
this.displayForm(2, false)
}
this.$root.clearFormValidation($('#step2 form'))
const post = this.$root.pick(this, ['code', 'short_code'])
axios.post('/api/auth/signup/verify', post)
.then(response => {
this.displayForm(3, true)
// Reset user name/email/plan, we don't have them if user used a verification link
this.first_name = response.data.first_name
this.last_name = response.data.last_name
this.email = response.data.email
this.is_domain = response.data.is_domain
this.voucher = response.data.voucher
// Fill the domain selector with available domains
if (!this.is_domain) {
this.setDomain(response.data)
}
})
.catch(error => {
if (bylink === true) {
// FIXME: display step 1, user can do nothing about it anyway
// Maybe we should display 404 error page?
this.displayForm(1, true)
}
})
},
// Submits the data to the API to create the user account
submitStep3() {
this.$root.clearFormValidation($('#step3 form'))
let post = {
...this.$root.pick(this, ['login', 'domain', 'voucher']),
...this.pass
}
if (this.invitation) {
post.invitation = this.invitation.id
post.plan = this.plan
post.first_name = this.first_name
post.last_name = this.last_name
} else {
post.code = this.code
post.short_code = this.short_code
}
axios.post('/api/auth/signup', post).then(response => {
// auto-login and goto dashboard
this.$root.loginUser(response.data)
})
},
// Moves the user a step back in registration form
stepBack(e) {
var card = $(e.target).closest('.card')
card.prev().removeClass('d-none').find('input').first().focus()
card.addClass('d-none').find('form')[0].reset()
if (card.attr('id') == 'step1') {
this.step0()
this.$router.replace({path: '/signup'})
}
},
displayForm(step, focus) {
[0, 1, 2, 3].filter(value => value != step).forEach(value => {
$('#step' + value).addClass('d-none')
})
if (!step) {
return this.step0()
}
$('#step' + step).removeClass('d-none')
if (focus) {
$('#step' + step).find('input').first().focus()
}
},
setDomain(response) {
if (response.domains) {
this.domains = response.domains
}
this.domain = response.domain || window.config['app.domain']
}
}
}
</script>
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 @@
<template>
<div class="container">
<status-component v-if="user_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="user-info">
<div class="card-body">
<div class="card-title" v-if="user_id !== 'new'">{{ $t('user.title') }}
<btn icon="trash-can" class="btn-outline-danger button-delete float-end" @click="showDeleteConfirmation()">
{{ $t('user.delete') }}
</btn>
</div>
<div class="card-title" v-if="user_id === 'new'">{{ $t('user.new') }}</div>
<div class="card-text">
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
{{ $t('form.general') }}
</a>
</li>
<li v-if="user_id !== 'new'" class="nav-item">
<a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="user_id !== 'new'" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(user) + ' form-control-plaintext'" id="status">{{ $root.statusText(user) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="first_name" v-model="user.first_name">
</div>
</div>
<div class="row mb-3">
<label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="last_name" v-model="user.last_name">
</div>
</div>
<div class="row mb-3">
<label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="organization" v-model="user.organization">
</div>
</div>
<div class="row mb-3">
<label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="email" :disabled="user_id !== 'new'" required v-model="user.email">
</div>
</div>
<div class="row mb-3">
<label for="aliases-input" class="col-sm-4 col-form-label">{{ $t('user.aliases-email') }}</label>
<div class="col-sm-8">
<list-input id="aliases" :list="user.aliases"></list-input>
</div>
</div>
<div class="row mb-3">
<label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
<div class="col-sm-8">
<div v-if="!isSelf" class="btn-group w-100" role="group">
<input type="checkbox" id="pass-mode-input" value="input" class="btn-check" @change="setPasswordMode" :checked="passwordMode == 'input'">
<label class="btn btn-outline-secondary" for="pass-mode-input">{{ $t('user.pass-input') }}</label>
<input type="checkbox" id="pass-mode-link" value="link" class="btn-check" @change="setPasswordMode">
<label class="btn btn-outline-secondary" for="pass-mode-link">{{ $t('user.pass-link') }}</label>
</div>
<password-input v-if="passwordMode == 'input'" :class="isSelf ? '' : 'mt-2'" v-model="user"></password-input>
<div id="password-link" v-if="passwordMode == 'link' || user.passwordLinkCode" class="mt-2">
<span>{{ $t('user.pass-link-label') }}</span>&nbsp;<code>{{ passwordLink }}</code>
<span class="d-inline-block">
<btn class="btn-link p-1" :icon="['far', 'clipboard']" :title="$t('btn.copy')" @click="passwordLinkCopy"></btn>
<btn v-if="user.passwordLinkCode" class="btn-link text-danger p-1" icon="trash-can" :title="$t('btn.delete')" @click="passwordLinkDelete"></btn>
</span>
<div v-if="!user.passwordLinkCode" class="form-text m-0">{{ $t('user.pass-link-hint') }}</div>
</div>
</div>
</div>
<div v-if="user_id === 'new'" id="user-packages" class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('user.package') }}</label>
<package-select class="col-sm-8 pt-sm-1"></package-select>
</div>
<div v-if="user_id !== 'new'" id="user-skus" class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('user.subscriptions') }}</label>
<subscription-select v-if="user.id" class="col-sm-8 pt-sm-1" :object="user"></subscription-select>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<form @submit.prevent="submitSettings" class="card-body">
<div class="row checkbox mb-3">
<label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
<div class="col-sm-8 pt-2">
<input type="checkbox" id="greylist_enabled" name="greylist_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.greylist_enabled">
<small id="greylisting-hint" class="text-muted">
{{ $t('user.greylisting-text') }}
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
<div id="delete-warning" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.delete-email', { email: user.email }) }}</h5>
<btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
</div>
<div class="modal-body">
<p>{{ $t('user.delete-text') }}</p>
</div>
<div class="modal-footer">
<btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
<btn class="btn-danger modal-action" icon="trash-can" @click="deleteUser()">{{ $t('btn.delete') }}</btn>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from 'bootstrap'
import ListInput from '../Widgets/ListInput'
import PackageSelect from '../Widgets/PackageSelect'
import PasswordInput from '../Widgets/PasswordInput'
import StatusComponent from '../Widgets/Status'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-regular-svg-icons/faClipboard').definition,
)
export default {
components: {
ListInput,
PackageSelect,
PasswordInput,
StatusComponent,
SubscriptionSelect
},
data() {
return {
passwordLinkCode: '',
passwordMode: '',
user_id: null,
user: { aliases: [], config: [] },
status: {}
}
},
computed: {
isSelf: function () {
return this.user_id == this.$root.authInfo.id
},
passwordLink: function () {
return this.$root.appUrl + '/password-reset/' + this.passwordLinkCode
}
},
created() {
this.user_id = this.$route.params.user
if (this.user_id !== 'new') {
- this.$root.startLoading()
-
- axios.get('/api/v4/users/' + this.user_id)
+ axios.get('/api/v4/users/' + this.user_id, { loader: true })
.then(response => {
- this.$root.stopLoading()
-
this.user = response.data
this.user.first_name = response.data.settings.first_name
this.user.last_name = response.data.settings.last_name
this.user.organization = response.data.settings.organization
this.status = response.data.statusInfo
this.passwordLinkCode = this.user.passwordLinkCode
})
.catch(this.$root.errorHandler)
if (this.isSelf) {
this.passwordMode = 'input'
}
} else {
this.passwordMode = 'input'
}
},
mounted() {
$('#first_name').focus()
$('#delete-warning')[0].addEventListener('shown.bs.modal', event => {
$(event.target).find('button.modal-cancel').focus()
})
},
methods: {
passwordLinkCopy() {
navigator.clipboard.writeText($('#password-link code').text());
},
passwordLinkDelete() {
this.passwordMode = ''
$('#pass-mode-link')[0].checked = false
// Delete the code for real
axios.delete('/api/v4/password-reset/code/' + this.passwordLinkCode)
.then(response => {
this.passwordLinkCode = ''
this.user.passwordLinkCode = ''
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
}
})
},
setPasswordMode(event) {
const mode = event.target.checked ? event.target.value : ''
// In the "new user" mode the password mode cannot be unchecked
if (!mode && this.user_id === 'new') {
event.target.checked = true
return
}
this.passwordMode = mode
if (!event.target.checked) {
return
}
$('#pass-mode-' + (mode == 'link' ? 'input' : 'link'))[0].checked = false
// Note: we use $nextTick() because we have to wait for the HTML elements to exist
this.$nextTick().then(() => {
if (mode == 'link' && !this.passwordLinkCode) {
- const element = $('#password-link')
- this.$root.addLoader(element)
- axios.post('/api/v4/password-reset/code')
+ axios.post('/api/v4/password-reset/code', {}, { loader: '#password-link' })
.then(response => {
- this.$root.removeLoader(element)
this.passwordLinkCode = response.data.short_code + '-' + response.data.code
})
- .catch(error => {
- this.$root.removeLoader(element)
- })
} else if (mode == 'input') {
$('#password').focus();
}
})
},
submit() {
this.$root.clearFormValidation($('#general form'))
let method = 'post'
let location = '/api/v4/users'
let post = this.$root.pick(this.user, ['aliases', 'email', 'first_name', 'last_name', 'organization'])
if (this.user_id !== 'new') {
method = 'put'
location += '/' + this.user_id
let skus = {}
$('#user-skus input[type=checkbox]:checked').each((idx, input) => {
let id = $(input).val()
let range = $(input).parents('tr').first().find('input[type=range]').val()
skus[id] = range || 1
})
post.skus = skus
} else {
post.package = $('#user-packages input:checked').val()
}
if (this.passwordMode == 'link' && this.passwordLinkCode) {
post.passwordLinkCode = this.passwordLinkCode
} else if (this.passwordMode == 'input') {
post.password = this.user.password
post.password_confirmation = this.user.password_confirmation
}
axios[method](location, post)
.then(response => {
if (response.data.statusInfo) {
this.$root.authInfo.statusInfo = response.data.statusInfo
}
this.$toast.success(response.data.message)
this.$router.push({ name: 'users' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
let post = { greylist_enabled: $('#greylist_enabled').prop('checked') ? 1 : 0 }
axios.post('/api/v4/users/' + this.user_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
},
statusUpdate(user) {
this.user = Object.assign({}, this.user, user)
},
deleteUser() {
// Delete the user from the confirm dialog
axios.delete('/api/v4/users/' + this.user_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'users' })
}
})
},
showDeleteConfirmation() {
if (this.user_id == this.$root.authInfo.id) {
// Deleting self, redirect to /profile/delete page
this.$router.push({ name: 'profile-delete' })
} else {
// Display the warning
new Modal('#delete-warning').show()
}
}
}
}
</script>
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 @@
<template>
<div class="container" dusk="wallet-component">
<div v-if="wallet.id" id="wallet" class="card">
<div class="card-body">
<div class="card-title">{{ $t('wallet.title') }} <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'">{{ $root.price(wallet.balance, wallet.currency) }}</span></div>
<div class="card-text">
<p v-if="wallet.notice" id="wallet-notice">{{ wallet.notice }}</p>
<div v-if="showPendingPayments" class="alert alert-warning">
{{ $t('wallet.pending-payments-warning') }}
</div>
<p>
<btn class="btn-primary" @click="paymentMethodForm('manual')">{{ $t('wallet.add-credit') }}</btn>
</p>
<div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending">
<template v-if="mandate.id && !mandate.isValid">
<div class="alert alert-danger">
{{ $t('wallet.auto-payment-failed') }}
</div>
<btn class="btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</btn>
</template>
<btn class="btn-primary" @click="paymentMethodForm('auto')">{{ $t('wallet.auto-payment-setup') }}</btn>
</div>
<div id="mandate-info" v-else>
<div v-if="mandate.isDisabled" class="disabled-mandate alert alert-danger">
{{ $t('wallet.auto-payment-disabled') }}
</div>
<template v-else>
<p v-html="$t('wallet.auto-payment-info', { amount: mandate.amount + ' ' + wallet.currency, balance: mandate.balance + ' ' + wallet.currency})"></p>
<p>{{ $t('wallet.payment-method', { method: mandate.method }) }}</p>
</template>
<div v-if="mandate.isPending" class="alert alert-warning">
{{ $t('wallet.auto-payment-inprogress') }}
</div>
<p class="buttons">
<btn class="btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</btn>
<btn class="btn-primary" @click="autoPaymentChange">{{ $t('wallet.auto-payment-change') }}</btn>
</p>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-receipts" href="#wallet-receipts" role="tab" aria-controls="wallet-receipts" aria-selected="true">
{{ $t('wallet.receipts') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-history" href="#wallet-history" role="tab" aria-controls="wallet-history" aria-selected="false">
{{ $t('wallet.history') }}
</a>
</li>
<li v-if="showPendingPayments" class="nav-item">
<a class="nav-link" id="tab-payments" href="#wallet-payments" role="tab" aria-controls="wallet-payments" aria-selected="false">
{{ $t('wallet.pending-payments') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="wallet-receipts" role="tabpanel" aria-labelledby="tab-receipts">
<div class="card-body">
<div class="card-text">
<p v-if="receipts.length">
{{ $t('wallet.receipts-hint') }}
</p>
<div v-if="receipts.length" class="input-group">
<select id="receipt-id" class="form-control">
<option v-for="(receipt, index) in receipts" :key="index" :value="receipt">{{ receipt }}</option>
</select>
<btn class="btn-secondary" @click="receiptDownload" icon="download">{{ $t('btn.download') }}</btn>
</div>
<p v-if="!receipts.length">
{{ $t('wallet.receipts-none') }}
</p>
</div>
</div>
</div>
<div class="tab-pane" id="wallet-history" role="tabpanel" aria-labelledby="tab-history">
<div class="card-body">
<transaction-log v-if="walletId && loadTransactions" class="card-text" :wallet-id="walletId"></transaction-log>
</div>
</div>
<div class="tab-pane" id="wallet-payments" role="tabpanel" aria-labelledby="tab-payments">
<div class="card-body">
<payment-log v-if="walletId && loadPayments" class="card-text" :wallet-id="walletId"></payment-log>
</div>
</div>
</div>
<div id="payment-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ paymentDialogTitle }}</h5>
<btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
</div>
<div class="modal-body">
<div id="payment-method" v-if="paymentForm == 'method'">
<form data-validation-prefix="mandate_">
<div id="payment-method-selection">
<a v-for="method in paymentMethods" :key="method.id" @click="selectPaymentMethod(method)" href="#" :class="'card link-' + method.id">
<svg-icon v-if="method.icon" :icon="[method.icon.prefix, method.icon.name]" />
<img v-if="method.image" :src="method.image" />
<span class="name">{{ method.name }}</span>
</a>
</div>
</form>
</div>
<div id="manual-payment" v-if="paymentForm == 'manual'">
<p v-if="wallet.currency != selectedPaymentMethod.currency">
{{ $t('wallet.currency-conv', { wc: wallet.currency, pc: selectedPaymentMethod.currency }) }}
</p>
<p v-if="selectedPaymentMethod.id == 'banktransfer'">
{{ $t('wallet.banktransfer-hint') }}
</p>
<p>
{{ $t('wallet.payment-amount-hint') }}
</p>
<form id="payment-form" @submit.prevent="payment">
<div class="input-group">
<input type="text" class="form-control" id="amount" v-model="amount" required>
<span class="input-group-text">{{ wallet.currency }}</span>
</div>
<div v-if="wallet.currency != selectedPaymentMethod.currency && !isNaN(amount)" class="alert alert-warning m-0 mt-3">
{{ $t('wallet.payment-warning', { price: $root.price(amount * selectedPaymentMethod.exchangeRate * 100, selectedPaymentMethod.currency) }) }}
</div>
</form>
</div>
<div id="auto-payment" v-if="paymentForm == 'auto'">
<form data-validation-prefix="mandate_">
<p>
{{ $t('wallet.auto-payment-hint') }}
</p>
<div class="row mb-3">
<label for="mandate_amount" class="col-sm-6 col-form-label">{{ $t('wallet.fill-up') }}</label>
<div class="col-sm-6">
<div class="input-group">
<input type="text" class="form-control" id="mandate_amount" v-model="mandate.amount" required>
<span class="input-group-text">{{ wallet.currency }}</span>
</div>
</div>
</div>
<div class="row mb-3">
<label for="mandate_balance" class="col-sm-6 col-form-label">{{ $t('wallet.when-below') }}</label>
<div class="col-sm-6">
<div class="input-group">
<input type="text" class="form-control" id="mandate_balance" v-model="mandate.balance" required>
<span class="input-group-text">{{ wallet.currency }}</span>
</div>
</div>
</div>
<p v-if="!mandate.isValid">
{{ $t('wallet.auto-payment-next') }}
</p>
<div v-if="mandate.isValid && mandate.isDisabled" class="disabled-mandate alert alert-danger m-0">
{{ $t('wallet.auto-payment-disabled-next') }}
</div>
</form>
</div>
</div>
<div class="modal-footer">
<btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
<btn class="btn-primary modal-action" icon="check" @click="autoPayment"
v-if="paymentForm == 'auto' && (mandate.isValid || mandate.isPending)"
>
{{ $t('btn.submit') }}
</btn>
<btn class="btn btn-primary modal-action" icon="check" @click="autoPayment"
v-if="paymentForm == 'auto' && !mandate.isValid && !mandate.isPending"
>
{{ $t('btn.continue') }}
</btn>
<btn class="btn-primary modal-action" icon="check" @click="payment"
v-if="paymentForm == 'manual'"
>
{{ $t('btn.continue') }}
</btn>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from 'bootstrap'
import TransactionLog from './Widgets/TransactionLog'
import PaymentLog from './Widgets/PaymentLog'
+ import { downloadFile } from '../js/utils'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-brands-svg-icons/faPaypal').definition,
require('@fortawesome/free-regular-svg-icons/faCreditCard').definition,
require('@fortawesome/free-solid-svg-icons/faBuildingColumns').definition,
)
export default {
components: {
TransactionLog,
PaymentLog
},
data() {
return {
amount: '',
mandate: { amount: 10, balance: 0, method: null },
paymentDialogTitle: null,
paymentForm: null,
nextForm: null,
receipts: [],
stripe: null,
loadTransactions: false,
loadPayments: false,
showPendingPayments: false,
wallet: {},
walletId: null,
paymentMethods: [],
selectedPaymentMethod: null
}
},
mounted() {
$('#wallet button').focus()
this.walletId = this.$root.authInfo.wallets[0].id
- this.$root.startLoading()
- axios.get('/api/v4/wallets/' + this.walletId)
+ axios.get('/api/v4/wallets/' + this.walletId, { loader: true })
.then(response => {
- this.$root.stopLoading()
this.wallet = response.data
- const receiptsTab = $('#wallet-receipts')
-
- this.$root.addLoader(receiptsTab)
- axios.get('/api/v4/wallets/' + this.walletId + '/receipts')
+ axios.get('/api/v4/wallets/' + this.walletId + '/receipts', { loader: '#wallet-receipts' })
.then(response => {
- this.$root.removeLoader(receiptsTab)
this.receipts = response.data.list
})
- .catch(error => {
- this.$root.removeLoader(receiptsTab)
- })
if (this.wallet.provider == 'stripe') {
this.stripeInit()
}
})
.catch(this.$root.errorHandler)
this.loadMandate()
axios.get('/api/v4/payments/has-pending')
.then(response => {
this.showPendingPayments = response.data.hasPending
})
},
updated() {
$(this.$el).find('ul.nav-tabs a').on('click', e => {
this.$root.tab(e)
if ($(e.target).is('#tab-history')) {
this.loadTransactions = true
} else if ($(e.target).is('#tab-payments')) {
this.loadPayments = true
}
})
},
methods: {
loadMandate() {
- const mandate_form = $('#mandate-form')
+ const loader = '#mandate-form'
- this.$root.removeLoader(mandate_form)
+ this.$root.stopLoading(loader)
if (!this.mandate.id || this.mandate.isPending) {
- this.$root.addLoader(mandate_form)
- axios.get('/api/v4/payments/mandate')
+ axios.get('/api/v4/payments/mandate', { loader })
.then(response => {
- this.$root.removeLoader(mandate_form)
this.mandate = response.data
})
- .catch(error => {
- this.$root.removeLoader(mandate_form)
- })
}
},
selectPaymentMethod(method) {
this.formLock = false
this.selectedPaymentMethod = method
this.paymentForm = this.nextForm
setTimeout(() => { $('#payment-dialog').find('#amount,#mandate_amount').focus() }, 10)
},
payment() {
if (this.formLock) {
return
}
// Lock the form to prevent from double submission
this.formLock = true
let onFinish = () => { this.formLock = false }
this.$root.clearFormValidation($('#payment-form'))
const post = {
amount: this.amount,
methodId: this.selectedPaymentMethod.id,
currency: this.selectedPaymentMethod.currency
}
axios.post('/api/v4/payments', post, { onFinish })
.then(response => {
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
} else {
this.stripeCheckout(response.data)
}
})
},
autoPayment() {
if (this.formLock) {
return
}
// Lock the form to prevent from double submission
this.formLock = true
let onFinish = () => { this.formLock = false }
const method = this.mandate.id && (this.mandate.isValid || this.mandate.isPending) ? 'put' : 'post'
let post = {
amount: this.mandate.amount,
balance: this.mandate.balance,
}
// Modifications can't change the method of payment
if (this.selectedPaymentMethod) {
post.methodId = this.selectedPaymentMethod.id
post.currency = this.selectedPaymentMethod.currency
}
this.$root.clearFormValidation($('#auto-payment form'))
axios[method]('/api/v4/payments/mandate', post, { onFinish })
.then(response => {
if (method == 'post') {
this.mandate.id = null
// a new mandate, redirect to the chackout page
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
} else if (response.data.id) {
this.stripeCheckout(response.data)
}
} else {
// an update
if (response.data.status == 'success') {
this.dialog.hide();
this.mandate = response.data
this.$toast.success(response.data.message)
}
}
})
},
autoPaymentChange(event) {
this.autoPaymentForm(event, this.$t('wallet.auto-payment-update'))
},
autoPaymentDelete() {
axios.delete('/api/v4/payments/mandate')
.then(response => {
this.mandate = { amount: 10, balance: 0 }
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
}
})
},
paymentMethodForm(nextForm) {
this.formLock = false
this.paymentMethods = []
this.paymentForm = 'method'
this.nextForm = nextForm
this.paymentDialogTitle = this.$t(nextForm == 'auto' ? 'wallet.auto-payment-setup' : 'wallet.top-up')
this.dialog = new Modal('#payment-dialog')
this.dialog.show()
this.$nextTick().then(() => {
- const form = $('#payment-method')
const type = nextForm == 'manual' ? 'oneoff' : 'recurring'
+ const loader = ['#payment-method', { 'min-height': '10em', small: false }]
- this.$root.addLoader(form, false, { 'min-height': '10em' })
-
- axios.get('/api/v4/payments/methods', { params: { type } })
+ axios.get('/api/v4/payments/methods', { params: { type }, loader })
.then(response => {
- this.$root.removeLoader(form)
this.paymentMethods = response.data
})
})
},
autoPaymentForm(event, title) {
this.paymentForm = 'auto'
this.paymentDialogTitle = title
this.formLock = false
this.dialog = new Modal('#payment-dialog')
this.dialog.show()
},
receiptDownload() {
const receipt = $('#receipt-id').val()
- this.$root.downloadFile('/api/v4/wallets/' + this.walletId + '/receipts/' + receipt)
+ downloadFile('/api/v4/wallets/' + this.walletId + '/receipts/' + receipt)
},
stripeInit() {
let script = $('#stripe-script')
if (!script.length) {
script = document.createElement('script')
script.onload = () => {
this.stripe = Stripe(window.config.stripePK)
}
script.id = 'stripe-script'
script.src = 'https://js.stripe.com/v3/'
document.getElementsByTagName('head')[0].appendChild(script)
} else {
this.stripe = Stripe(window.config.stripePK)
}
},
stripeCheckout(data) {
if (!this.stripe) {
return
}
this.stripe.redirectToCheckout({
sessionId: data.id
}).then(result => {
// If it fails due to a browser or network error,
// display the localized error message to the user
if (result.error) {
this.$toast.error(result.error.message)
}
})
}
}
}
</script>
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 @@
<template>
<div></div>
</template>
<script>
const ListSearch = {
props: {
onSearch: { type: Function, default: () => {} },
placeholder: { type: String, default: '' }
},
data() {
return {
search: ''
}
},
template: `<form @submit.prevent="onSearch(search)" id="search-form" class="input-group" style="flex:1">
<input class="form-control" type="text" :placeholder="placeholder" v-model="search">
<button type="submit" class="btn btn-primary"><svg-icon icon="magnifying-glass"></svg-icon> {{ $t('btn.search') }}</button>
</form>`
}
const ListFoot = {
props: {
colspan: { type: Number, default: 1 },
text: { type: String, default: '' }
},
template: `<tfoot class="table-fake-body"><tr><td :colspan="colspan">{{ text }}</td></tr></tfoot>`
}
const ListMore = {
props: {
onClick: { type: Function, default: () => {} }
},
template: `<div class="text-center p-3 more-loader">
<button class="btn btn-secondary" @click="onClick({})">{{ $t('nav.more') }}</button>
</div>`
}
export default {
components: {
ListFoot,
ListMore,
ListSearch
},
data() {
return {
currentSearch: '',
hasMore: false,
page: 1
}
},
methods: {
listSearch(name, url, params) {
let loader
let get = params.get || {}
if (params) {
if (params.reset || params.init) {
this[name] = []
this.page = 0
}
get.page = params.page || (this.page + 1)
if ('search' in params) {
get.search = params.search
this.currentSearch = params.search
this.hasMore = false
} else {
get.search = this.currentSearch
}
if (!params.init) {
loader = $(this.$el).find('.more-loader')
if (!loader.length || get.page == 1) {
loader = $(this.$el).find('tfoot td')
}
+ } else {
+ loader = true
}
} else {
this.currentSearch = null
}
- if (params && params.init) {
- this.$root.startLoading()
- } else {
- this.$root.addLoader(loader)
- }
-
- const finish = () => {
- if (params && params.init) {
- this.$root.stopLoading()
- } else {
- this.$root.removeLoader(loader)
- }
- }
-
- axios.get(url, { params: get })
+ axios.get(url, { params: get, loader })
.then(response => {
// Note: In Vue we can't just use .concat()
for (let i in response.data.list) {
this.$set(this[name], this[name].length, response.data.list[i])
}
this.hasMore = response.data.hasMore
this.page = response.data.page || 1
-
- finish()
- })
- .catch(error => {
- finish()
})
}
}
}
</script>
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 @@
<template>
<div>
<table class="table table-sm form-list">
<thead class="visually-hidden">
<tr>
<th scope="col"></th>
<th scope="col">{{ $t('user.package') }}</th>
<th scope="col">{{ $t('user.price') }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="pkg in packages" :id="'p' + pkg.id" :key="pkg.id">
<td class="selection">
<input type="checkbox" @change="selectPackage"
:value="pkg.id"
:checked="pkg.id == package_id"
:readonly="pkg.id == package_id"
:id="'pkg-input-' + pkg.id"
>
</td>
<td class="name">
<label :for="'pkg-input-' + pkg.id">{{ pkg.name }}</label>
</td>
<td class="price text-nowrap">
{{ $root.priceLabel(pkg.cost, discount, currency) }}
</td>
<td class="buttons">
<btn v-if="pkg.description" class="btn-link btn-lg p-0" v-tooltip="pkg.description" icon="circle-info">
<span class="visually-hidden">{{ $t('btn.moreinfo') }}</span>
</btn>
</td>
</tr>
</tbody>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0 mt-1">
&sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
</div>
</template>
<script>
export default {
props: {
type: { type: String, default: 'user' }
},
data() {
return {
currency: '',
discount: 0,
discount_description: '',
packages: [],
package_id: null
}
},
created() {
// assign currency, discount, discount_description of the current user
this.$root.userWalletProps(this)
- this.$root.startLoading()
-
- axios.get('/api/v4/packages')
+ axios.get('/api/v4/packages', { loader: true })
.then(response => {
- this.$root.stopLoading()
-
this.packages = response.data.filter(pkg => {
if (this.type == 'domain') {
return pkg.isDomain
}
return !pkg.isDomain
})
this.package_id = this.packages[0].id
})
.catch(this.$root.errorHandler)
},
methods: {
selectPackage(e) {
// Make sure there always is one package selected
$(this.$el).find('input').not(e.target).prop('checked', false)
this.package_id = $(e.target).prop('checked', true).val()
},
}
}
</script>
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 @@
<template>
<div>
<table class="table table-sm form-list">
<thead class="visually-hidden">
<tr>
<th scope="col"></th>
<th scope="col">{{ $t('user.subscription') }}</th>
<th scope="col">{{ $t('user.price') }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="sku in skus" :id="'s' + sku.id" :key="sku.id">
<td class="selection">
<input type="checkbox" @input="onInputSku"
:value="sku.id"
:disabled="sku.readonly || readonly"
:checked="sku.enabled"
:id="'sku-input-' + sku.title"
>
</td>
<td class="name">
<label :for="'sku-input-' + sku.title">{{ sku.name }}</label>
<div v-if="sku.range" class="range-input">
<label class="text-nowrap">{{ sku.range.min }} {{ sku.range.unit }}</label>
<input type="range" class="form-range" @input="rangeUpdate"
:value="sku.value || sku.range.min"
:min="sku.range.min"
:max="sku.range.max"
>
</div>
</td>
<td class="price text-nowrap">
{{ $root.priceLabel(sku.cost, discount, currency) }}
</td>
<td class="buttons">
<btn v-if="sku.description" class="btn-link btn-lg p-0" v-tooltip="sku.description" icon="circle-info">
<span class="visually-hidden">{{ $t('btn.moreinfo') }}</span>
</btn>
</td>
</tr>
</tbody>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0 mt-1">
&sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
</div>
</template>
<script>
export default {
props: {
object: { type: Object, default: () => {} },
readonly: { type: Boolean, default: false },
type: { type: String, default: 'user' }
},
data() {
return {
currency: '',
discount: 0,
discount_description: '',
skus: []
}
},
created() {
// assign currency, discount, discount_description of the current user
this.$root.userWalletProps(this)
if (this.object.wallet) {
this.discount = this.object.wallet.discount
this.discount_description = this.object.wallet.discount_description
}
- this.$root.startLoading()
-
- axios.get('/api/v4/' + this.type + 's/' + this.object.id + '/skus')
+ axios.get('/api/v4/' + this.type + 's/' + this.object.id + '/skus', { loader: true })
.then(response => {
- this.$root.stopLoading()
-
if (this.readonly) {
response.data = response.data.filter(sku => { return sku.id in this.object.skus })
}
// "merge" SKUs with user entitlement-SKUs
this.skus = response.data
.map(sku => {
const objSku = this.object.skus[sku.id]
if (objSku) {
sku.enabled = true
sku.skuCost = sku.cost
sku.cost = objSku.costs.reduce((sum, current) => sum + current)
sku.value = objSku.count
sku.costs = objSku.costs
} else if (!sku.readonly) {
sku.enabled = false
}
return sku
})
// Update all range inputs (and price)
this.$nextTick(() => {
$(this.$el).find('input[type=range]').each((idx, elem) => { this.rangeUpdate(elem) })
})
})
.catch(this.$root.errorHandler)
},
methods: {
findSku(id) {
for (let i = 0; i < this.skus.length; i++) {
if (this.skus[i].id == id) {
return this.skus[i];
}
}
},
onInputSku(e) {
let input = e.target
let sku = this.findSku(input.value)
let required = []
// We use 'readonly', not 'disabled', because we might want to handle
// input events. For example to display an error when someone clicks
// the locked input
if (input.readOnly) {
input.checked = !input.checked
// TODO: Display an alert explaining why it's locked
return
}
// TODO: Following code might not work if we change definition of forbidden/required
// or we just need more sophisticated SKU dependency rules
if (input.checked) {
// Check if a required SKU is selected, alert the user if not
(sku.required || []).forEach(requiredHandler => {
this.skus.forEach(item => {
if (item.handler == requiredHandler) {
if (!$('#s' + item.id).find('input[type=checkbox]:checked').length) {
required.push(item.name)
}
}
})
})
if (required.length) {
input.checked = false
return alert(this.$t('user.skureq', { sku: sku.name, list: required.join(', ') }))
}
} else {
// Uncheck all dependent SKUs, e.g. when unchecking Groupware we also uncheck Activesync
// TODO: Should we display an alert instead?
this.skus.forEach(item => {
if (item.required && item.required.indexOf(sku.handler) > -1) {
$('#s' + item.id).find('input[type=checkbox]').prop('checked', false)
}
})
}
// Uncheck+lock/unlock conflicting SKUs
(sku.forbidden || []).forEach(forbiddenHandler => {
this.skus.forEach(item => {
let checkbox
if (item.handler == forbiddenHandler && (checkbox = $('#s' + item.id).find('input[type=checkbox]')[0])) {
if (input.checked) {
checkbox.checked = false
checkbox.readOnly = true
} else {
checkbox.readOnly = false
}
}
})
})
},
rangeUpdate(e) {
let input = $(e.target || e)
let value = input.val()
let record = input.parents('tr').first()
let sku_id = record.find('input[type=checkbox]').val()
let sku = this.findSku(sku_id)
let existing = sku.costs ? sku.costs.length : 0
let cost
// Calculate cost, considering both existing entitlement cost and sku cost
if (existing) {
cost = sku.costs
.sort((a, b) => a - b) // sort by cost ascending (free units first)
.slice(0, value)
.reduce((sum, current) => sum + current)
if (value > existing) {
cost += sku.skuCost * (value - existing)
}
} else {
cost = sku.cost * (value - sku.units_free)
}
// Update the label
input.prev().text(value + ' ' + sku.range.unit)
// Update the price
record.find('.price').text(this.$root.priceLabel(cost, this.discount, this.currency))
}
}
}
</script>
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 @@
<template>
<div>
<table class="table table-sm m-0 transactions">
<thead>
<tr>
<th scope="col">{{ $t('form.date') }}</th>
<th scope="col" v-if="isAdmin">{{ $t('form.user') }}</th>
<th scope="col"></th>
<th scope="col">{{ $t('form.description') }}</th>
<th scope="col" class="price">{{ $t('form.amount') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="transaction in transactions" :id="'log' + transaction.id" :key="transaction.id">
<td class="datetime">{{ transaction.createdAt }}</td>
<td class="email" v-if="isAdmin">{{ transaction.user }}</td>
<td class="selection">
<btn v-if="transaction.hasDetails" class="btn-lg btn-link btn-action" icon="circle-info"
:title="$t('form.details')"
@click="loadTransaction(transaction.id)"
></btn>
</td>
<td class="description">{{ description(transaction) }}</td>
<td :class="'price ' + className(transaction)">{{ amount(transaction) }}</td>
</tr>
</tbody>
<list-foot :text="$t('wallet.transactions-none')" :colspan="isAdmin ? 5 : 4"></list-foot>
</table>
<list-more v-if="hasMore" :on-click="loadLog"></list-more>
</div>
</template>
<script>
import ListTools from './ListTools'
export default {
mixins: [ ListTools ],
props: {
walletId: { type: String, default: null },
isAdmin: { type: Boolean, default: false },
},
data() {
return {
transactions: []
}
},
mounted() {
this.loadLog({ reset: true })
},
methods: {
loadLog(params) {
if (this.walletId) {
this.listSearch('transactions', '/api/v4/wallets/' + this.walletId + '/transactions', params)
}
},
loadTransaction(id) {
let record = $('#log' + id)
let cell = record.find('td.description')
let details = $('<div class="list-details"><ul></ul><div>').appendTo(cell)
- this.$root.addLoader(cell)
- axios.get('/api/v4/wallets/' + this.walletId + '/transactions' + '?transaction=' + id)
+ axios.get('/api/v4/wallets/' + this.walletId + '/transactions' + '?transaction=' + id, { loader: cell })
.then(response => {
- this.$root.removeLoader(cell)
record.find('button').remove()
let list = details.find('ul')
response.data.list.forEach(elem => {
list.append($('<li>').text(this.description(elem)))
})
})
- .catch(error => {
- this.$root.removeLoader(cell)
- })
},
amount(transaction) {
return this.$root.price(transaction.amount, transaction.currency)
},
className(transaction) {
return transaction.amount < 0 ? 'text-danger' : 'text-success';
},
description(transaction) {
let desc = transaction.description
if (/^(billed|created|deleted)$/.test(transaction.type)) {
desc += ' (' + this.$root.price(transaction.amount) + ')'
}
return desc
}
}
}
</script>

File Metadata

Mime Type
text/x-diff
Expires
Fri, Feb 6, 2:23 PM (1 h, 32 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
428302
Default Alt Text
(251 KB)

Event Timeline