Page MenuHomePhorge

No OneTemporary

Size
392 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/resources/js/bootstrap.js b/src/resources/js/bootstrap.js
index b3eadcdd..286db050 100644
--- a/src/resources/js/bootstrap.js
+++ b/src/resources/js/bootstrap.js
@@ -1,84 +1,88 @@
/**
* Import Cash (jQuery replacement)
*/
import $ from 'cash-dom'
window.$ = $
$.fn.focus = function() {
if (this.length && this[0].focus) {
this[0].focus()
}
return this
}
/**
* Load Vue, VueRouter and global components
*/
import { Tooltip } from 'bootstrap'
import FontAwesomeIcon from './fontawesome'
import Vue from 'vue'
import VueRouter from 'vue-router'
+import Btn from '../vue/Widgets/Btn'
+import BtnRouter from '../vue/Widgets/BtnRouter'
import Toast from '../vue/Widgets/Toast'
import store from './store'
window.Vue = Vue
Vue.component('SvgIcon', FontAwesomeIcon)
+Vue.component('Btn', Btn)
+Vue.component('BtnRouter', BtnRouter)
const vTooltip = (el, binding) => {
let t = []
if (binding.modifiers.focus) t.push('focus')
if (binding.modifiers.hover) t.push('hover')
if (binding.modifiers.click) t.push('click')
if (!t.length) t.push('click')
el.tooltip = new Tooltip(el, {
title: binding.value,
placement: binding.arg || 'top',
trigger: t.join(' '),
html: !!binding.modifiers.html
})
}
Vue.directive('tooltip', {
bind: vTooltip,
update: vTooltip,
unbind (el) {
el.tooltip.dispose()
}
})
Vue.use(Toast)
Vue.use(VueRouter)
let vueRouterBase = '/'
try {
let url = new URL(window.config['app.url'])
vueRouterBase = url.pathname
} catch(e) {
// ignore
}
window.router = new VueRouter({
base: vueRouterBase,
mode: 'history',
routes: window.routes,
scrollBehavior (to, from, savedPosition) {
// Scroll the page to top, but not on Back action
return savedPosition || { x: 0, y: 0 }
}
})
/**
* Load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
window.axios = require('axios')
axios.defaults.baseURL = vueRouterBase
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue
index f279a1dc..74e9b749 100644
--- a/src/resources/vue/Admin/Domain.vue
+++ b/src/resources/vue/Admin/Domain.vue
@@ -1,118 +1,118 @@
<template>
<div v-if="domain" class="container">
<div class="card" id="domain-info">
<div class="card-body">
<div class="card-title">{{ domain.namespace }}</div>
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="domainid" 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="domainid">
{{ domain.id }} <span class="text-muted">({{ domain.created_at }})</span>
</span>
</div>
</div>
<div class="row plaintext">
<label for="first_name" 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(domain)">{{ $root.statusText(domain) }}</span>
</span>
</div>
</div>
</form>
<div class="mt-2 buttons">
- <button v-if="!domain.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendDomain">
+ <btn v-if="!domain.isSuspended" id="button-suspend" class="btn-warning" @click="suspendDomain">
{{ $t('btn.suspend') }}
- </button>
- <button v-if="domain.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendDomain">
+ </btn>
+ <btn v-if="domain.isSuspended" id="button-unsuspend" class="btn-warning" @click="unsuspendDomain">
{{ $t('btn.unsuspend') }}
- </button>
+ </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-config" href="#domain-config" role="tab" aria-controls="domain-config" aria-selected="true" @click="$root.tab">
{{ $t('form.config') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-settings" href="#domain-settings" role="tab" aria-controls="domain-settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="domain-config" role="tabpanel" aria-labelledby="tab-config">
<div class="card-body">
<div class="card-text">
<p>{{ $t('domain.dns-verify') }}</p>
<p><pre id="dns-verify">{{ domain.dns.join("\n") }}</pre></p>
<p>{{ $t('domain.dns-config') }}</p>
<p><pre id="dns-config">{{ domain.mx.join("\n") }}</pre></p>
</div>
</div>
</div>
<div class="tab-pane" id="domain-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="spf_whitelist" class="col-sm-4 col-form-label">{{ $t('domain.spf-whitelist') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="spf_whitelist">
{{ domain.config && domain.config.spf_whitelist.length ? domain.config.spf_whitelist.join(', ') : $t('form.none') }}
</span>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
domain: null
}
},
created() {
const domain_id = this.$route.params.domain;
axios.get('/api/v4/domains/' + domain_id)
.then(response => {
this.domain = response.data
})
.catch(this.$root.errorHandler)
},
methods: {
suspendDomain() {
axios.post('/api/v4/domains/' + this.domain.id + '/suspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.domain = Object.assign({}, this.domain, { isSuspended: true })
}
})
},
unsuspendDomain() {
axios.post('/api/v4/domains/' + this.domain.id + '/unsuspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.domain = Object.assign({}, this.domain, { isSuspended: false })
}
})
}
}
}
</script>
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
index 004a4614..0349d9fc 100644
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -1,842 +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>
- <button type="button" class="btn btn-secondary btn-sm ms-2" @click="emailEdit">{{ $t('btn.edit') }}</button>
+ <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">
- <button v-if="!user.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendUser">
+ <btn v-if="!user.isSuspended" id="button-suspend" class="btn-warning" @click="suspendUser">
{{ $t('btn.suspend') }}
- </button>
- <button v-if="user.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendUser">
+ </btn>
+ <btn v-if="user.isSuspended" id="button-unsuspend" class="btn-warning" @click="unsuspendUser">
{{ $t('btn.unsuspend') }}
- </button>
+ </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>
- <button type="button" class="btn btn-secondary btn-sm ms-2" @click="discountEdit">{{ $t('btn.edit') }}</button>
+ <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">
- <button id="button-award" class="btn btn-success" type="button" @click="awardDialog">{{ $t('user.add-bonus') }}</button>
- <button id="button-penalty" class="btn btn-danger" type="button" @click="penalizeDialog">{{ $t('user.add-penalty') }}</button>
+ <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">
- <button type="button" class="btn btn-danger" id="reset2fa" v-if="has2FA" @click="reset2FADialog">
- {{ $t('user.reset-2fa') }}
- </button>
- <button type="button" class="btn btn-secondary" id="addbetasku" v-if="!hasBeta" @click="addBetaSku">
- {{ $t('user.add-beta') }}
- </button>
+ <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="cog" :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>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ <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">
- <button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
- <button type="button" class="btn btn-primary modal-action" @click="submitDiscount()">
- <svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
- </button>
+ <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>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ <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">
- <button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
- <button type="button" class="btn btn-primary modal-action" @click="submitEmail()">
- <svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
- </button>
+ <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>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ <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">
- <button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
- <button type="button" class="btn btn-primary modal-action" @click="submitOneOff()">
- <svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
- </button>
+ <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>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ <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">
- <button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
- <button type="button" class="btn btn-danger modal-action" @click="reset2FA()">{{ $t('btn.reset') }}</button>
+ <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'
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)
.then(response => {
this.$root.stopLoading()
this.user = response.data
const financesTab = '#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)
.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/Distlist/Info.vue b/src/resources/vue/Distlist/Info.vue
index 93694c69..6cd05062 100644
--- a/src/resources/vue/Distlist/Info.vue
+++ b/src/resources/vue/Distlist/Info.vue
@@ -1,153 +1,151 @@
<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) }}
- <button class="btn btn-outline-danger button-delete float-end" @click="deleteList()" tag="button">
- <svg-icon icon="trash-alt"></svg-icon> {{ $t('distlist.delete') }}
- </button>
+ <btn class="btn-outline-danger button-delete float-end" @click="deleteList()" icon="trash-alt">{{ $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>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ <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>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ <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)
.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 4916b356..6e221122 100644
--- a/src/resources/vue/Distlist/List.vue
+++ b/src/resources/vue/Distlist/List.vue
@@ -1,61 +1,61 @@
<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>
- <router-link v-if="!$root.isDegraded()" class="btn btn-success float-end create-list" :to="{ path: 'distlist/new' }" tag="button">
- <svg-icon icon="users"></svg-icon> {{ $t('distlist.create') }}
- </router-link>
+ <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>
export default {
data() {
return {
lists: []
}
},
created() {
this.$root.startLoading()
axios.get('/api/v4/groups')
.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 38e0d6d4..27abe379 100644
--- a/src/resources/vue/Domain/Info.vue
+++ b/src/resources/vue/Domain/Info.vue
@@ -1,231 +1,222 @@
<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') }}
- <button
- class="btn btn-outline-danger button-delete float-end"
- @click="showDeleteConfirmation()" type="button"
- >
- <svg-icon icon="trash-alt"></svg-icon> {{ $t('domain.delete') }}
- </button>
+ <btn class="btn-outline-danger button-delete float-end" @click="showDeleteConfirmation()" icon="trash-alt">{{ $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>
- <button v-if="!domain.id" class="btn btn-primary mt-3" type="submit">
- <svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
- </button>
+ <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>
- <button class="btn btn-primary" type="button" @click="confirm"><svg-icon icon="sync-alt"></svg-icon> {{ $t('btn.verify') }}</button>
+ <btn class="btn-primary" @click="confirm" icon="sync-alt">{{ $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>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ <btn class="btn-primary" type="submit" icon="check">{{ $t('form.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>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ <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">
- <button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
- <button type="button" class="btn btn-danger modal-action" @click="deleteDomain()">
- <svg-icon icon="trash-alt"></svg-icon> {{ $t('btn.delete') }}
- </button>
+ <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
+ <btn class="btn-danger modal-action" @click="deleteDomain()" icon="trash-alt">{{ $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'
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)
.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 method = 'post'
let location = '/api/v4/domains'
this.domain.package = $('#domain-packages input:checked').val()
axios[method](location, this.domain)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'domains' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
let post = { spf_whitelist: 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 9187b86b..2a84e3d6 100644
--- a/src/resources/vue/Domain/List.vue
+++ b/src/resources/vue/Domain/List.vue
@@ -1,58 +1,56 @@
<template>
<div class="container">
<div class="card" id="domain-list">
<div class="card-body">
<div class="card-title">
{{ $t('user.domains') }}
- <router-link v-if="!$root.isDegraded()" class="btn btn-success float-end create-domain" :to="{ path: 'domain/new' }" tag="button">
- <svg-icon icon="globe"></svg-icon> {{ $t('domain.create') }}
- </router-link>
+ <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>
- <th scope="col"></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>
- <td class="buttons"></td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td colspan="2">{{ $t('user.domains-none') }}</td>
+ <td>{{ $t('user.domains-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
domains: []
}
},
created() {
this.$root.startLoading()
axios.get('/api/v4/domains')
.then(response => {
this.$root.stopLoading()
this.domains = response.data
})
.catch(this.$root.errorHandler)
}
}
</script>
diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue
index 86459cdb..a8ca65fe 100644
--- a/src/resources/vue/Login.vue
+++ b/src/resources/vue/Login.vue
@@ -1,75 +1,73 @@
<template>
<div class="container d-flex flex-column align-items-center justify-content-center">
<div id="logon-form" class="card col-sm-8 col-lg-6">
<div class="card-body p-4">
<h1 class="card-title text-center mb-3">{{ $t('login.header') }}</h1>
<div class="card-text m-2 mb-0">
<form class="form-signin" @submit.prevent="submitLogin">
<div class="row mb-3">
<label for="inputEmail" class="visually-hidden">{{ $t('form.email') }}</label>
<div class="input-group">
<span class="input-group-text"><svg-icon icon="user"></svg-icon></span>
<input type="email" id="inputEmail" class="form-control" :placeholder="$t('form.email')" required autofocus v-model="email">
</div>
</div>
<div class="row mb-4">
<label for="inputPassword" class="visually-hidden">{{ $t('form.password') }}</label>
<div class="input-group">
<span class="input-group-text"><svg-icon icon="lock"></svg-icon></span>
<input type="password" id="inputPassword" class="form-control" :placeholder="$t('form.password')" required v-model="password">
</div>
</div>
<div class="row mb-3" v-if="$root.isUser">
<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" :placeholder="$t('login.2fa')" v-model="secondFactor">
</div>
<small class="text-muted mt-2">{{ $t('login.2fa_desc') }}</small>
</div>
<div class="text-center">
- <button class="btn btn-primary" type="submit">
- <svg-icon icon="sign-in-alt"></svg-icon> {{ $t('login.sign_in') }}
- </button>
+ <btn class="btn-primary" type="submit" icon="sign-in-alt">{{ $t('login.sign_in') }}</btn>
</div>
</form>
</div>
</div>
</div>
<div id="logon-form-footer" class="mt-1">
<router-link v-if="$root.isUser && $root.hasRoute('password-reset')" :to="{ name: 'password-reset' }" id="forgot-password">{{ $t('login.forgot_password') }}</router-link>
<a v-if="webmailURL && $root.isUser" :href="webmailURL" id="webmail">{{ $t('login.webmail') }}</a>
</div>
</div>
</template>
<script>
export default {
props: {
dashboard: { type: Boolean, default: true }
},
data() {
return {
email: '',
password: '',
secondFactor: '',
webmailURL: window.config['app.webmail_url']
}
},
methods: {
submitLogin() {
this.$root.clearFormValidation($('form.form-signin'))
axios.post('/api/auth/login', {
email: this.email,
password: this.password,
secondfactor: this.secondFactor
}).then(response => {
// login user and redirect to dashboard
this.$root.loginUser(response.data, this.dashboard)
this.$emit('success')
})
}
}
}
</script>
diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue
index 1c8b8a9e..756786c5 100644
--- a/src/resources/vue/Meet/Room.vue
+++ b/src/resources/vue/Meet/Room.vue
@@ -1,761 +1,761 @@
<template>
<div id="meet-component">
<div id="meet-session-toolbar" class="hidden">
<span id="meet-counter" :title="$t('meet.partcnt')"><svg-icon icon="users"></svg-icon> <span></span></span>
<span id="meet-session-logo" v-html="$root.logo()"></span>
<div id="meet-session-menu">
<button :class="'btn link-audio' + (audioActive ? '' : ' on')" @click="switchSound" :disabled="!isPublisher()" :title="$t('meet.menu-audio-' + (audioActive ? 'mute' : 'unmute'))">
<svg-icon :icon="audioActive ? 'microphone' : 'microphone-slash'"></svg-icon>
</button>
<button :class="'btn link-video' + (videoActive ? '' : ' on')" @click="switchVideo" :disabled="!isPublisher()" :title="$t('meet.menu-video-' + (videoActive ? 'mute' : 'unmute'))">
<svg-icon :icon="videoActive ? 'video' : 'video-slash'"></svg-icon>
</button>
<button :class="'btn link-screen' + (screenShareActive ? ' on' : '')" @click="switchScreen" :disabled="!canShareScreen || !isPublisher()" :title="$t('meet.menu-screen')">
<svg-icon icon="desktop"></svg-icon>
</button>
<button :class="'btn link-hand' + (handRaised ? ' on' : '')" v-if="!isPublisher()" @click="switchHand" :title="$t('meet.menu-hand-' + (handRaised ? 'lower' : 'raise'))">
<svg-icon icon="hand-paper"></svg-icon>
</button>
<span id="channel-select" :style="'display:' + (channels.length ? '' : 'none')" class="dropdown">
<button :class="'btn link-channel' + (session.channel ? ' on' : '')" data-bs-toggle="dropdown"
:title="$t('meet.menu-channel')" aria-haspopup="true" aria-expanded="false"
>
<svg-icon icon="headphones"></svg-icon>
<span class="badge bg-danger" v-if="session.channel">{{ session.channel.toUpperCase() }}</span>
</button>
<div class="dropdown-menu">
<a :class="'dropdown-item' + (!session.channel ? ' active' : '')" href="#" data-code="" @click="switchChannel">- {{ $t('form.none') }} -</a>
<a v-for="code in channels" :key="code" href="#" @click="switchChannel" :data-code="code"
:class="'dropdown-item' + (session.channel == code ? ' active' : '')"
>{{ $t('lang.' + code) }}</a>
</div>
</span>
<button :class="'btn link-chat' + (chatActive ? ' on' : '')" @click="switchChat" :title="$t('meet.menu-chat')">
<svg-icon icon="comment"></svg-icon>
</button>
<button class="btn link-fullscreen closed hidden" @click="switchFullscreen" :title="$t('meet.menu-fullscreen')">
<svg-icon icon="expand"></svg-icon>
</button>
<button class="btn link-fullscreen open hidden" @click="switchFullscreen" :title="$t('meet.menu-fullscreen-exit')">
<svg-icon icon="compress"></svg-icon>
</button>
<button class="btn link-options" v-if="isRoomOwner()" @click="roomOptions" :title="$t('meet.options')">
<svg-icon icon="cog"></svg-icon>
</button>
<button class="btn link-logout" @click="logout" :title="$t('meet.menu-leave')">
<svg-icon icon="power-off"></svg-icon>
</button>
</div>
</div>
<div id="meet-setup" class="card container mt-2 mt-md-5 mb-5">
<div class="card-body">
<div class="card-title">{{ $t('meet.setup-title') }}</div>
<div class="card-text">
<form class="media-setup-form row" @submit.prevent="joinSession">
<div class="media-setup-preview col-sm-6 mb-3 mb-sm-0">
<video class="rounded"></video>
<div class="volume"><div class="bar"></div></div>
</div>
<div class="col-sm-6 align-self-center">
<div class="input-group mb-2">
<label for="setup-microphone" class="input-group-text mb-0" :title="$t('meet.mic')">
<svg-icon icon="microphone"></svg-icon>
</label>
<select class="form-select" id="setup-microphone" v-model="microphone" @change="setupMicrophoneChange">
<option value="">{{ $t('form.none') }}</option>
<option v-for="mic in setup.microphones" :value="mic.deviceId" :key="mic.deviceId">{{ mic.label }}</option>
</select>
</div>
<div class="input-group mb-2">
<label for="setup-camera" class="input-group-text mb-0" :title="$t('meet.cam')">
<svg-icon icon="video"></svg-icon>
</label>
<select class="form-select" id="setup-camera" v-model="camera" @change="setupCameraChange">
<option value="">{{ $t('form.none') }}</option>
<option v-for="cam in setup.cameras" :value="cam.deviceId" :key="cam.deviceId">{{ cam.label }}</option>
</select>
</div>
<div class="input-group mb-2">
<label for="setup-nickname" class="input-group-text mb-0" :title="$t('meet.nick')">
<svg-icon icon="user"></svg-icon>
</label>
<input class="form-control" type="text" id="setup-nickname" v-model="nickname" :placeholder="$t('meet.nick-placeholder')">
</div>
<div class="input-group mt-2" v-if="session.config && session.config.requires_password">
<label for="setup-password" class="input-group-text mb-0" :title="$t('form.password')">
<svg-icon icon="key"></svg-icon>
</label>
<input type="password" class="form-control" id="setup-password" v-model="password" :placeholder="$t('form.password')">
</div>
<div class="mt-3">
<button type="submit" id="join-button"
:class="'btn w-100 btn-' + (isRoomReady() ? 'success' : 'primary')"
>
<span v-if="isRoomReady()">{{ $t('meet.joinnow') }}</span>
<span v-else-if="roomState == 323">{{ $t('meet.imaowner') }}</span>
<span v-else>{{ $t('meet.join') }}</span>
</button>
</div>
</div>
<div class="mt-4 col-sm-12">
<status-message :status="roomState" :status-labels="roomStateLabels"></status-message>
</div>
</form>
</div>
</div>
</div>
<div id="meet-session-layout" class="d-flex hidden">
<div id="meet-queue">
<div class="head" :title="$t('meet.qa')"><svg-icon icon="microphone-alt"></svg-icon></div>
</div>
<div id="meet-session"></div>
<div id="meet-chat">
<div class="chat"></div>
<div class="chat-input m-2">
<textarea class="form-control" rows="1"></textarea>
</div>
</div>
</div>
<logon-form id="meet-auth" class="hidden" :dashboard="false" @success="authSuccess"></logon-form>
<div id="leave-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('meet.leave-title') }}</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
</div>
<div class="modal-body">
<p>{{ $t('meet.leave-body') }}</p>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-danger modal-action" data-bs-dismiss="modal">{{ $t('btn.close') }}</button>
+ <btn class="btn-danger modal-action" data-bs-dismiss="modal">{{ $t('btn.close') }}</btn>
</div>
</div>
</div>
</div>
<div id="media-setup-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('meet.media-title') }}</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
</div>
<div class="modal-body">
<form class="media-setup-form">
<div class="media-setup-preview"></div>
<div class="input-group mt-2">
<label for="setup-mic" class="input-group-text mb-0" :title="$t('meet.mic')">
<svg-icon icon="microphone"></svg-icon>
</label>
<select class="form-select" id="setup-mic" v-model="microphone" @change="setupMicrophoneChange">
<option value="">{{ $t('form.none') }}</option>
<option v-for="mic in setup.microphones" :value="mic.deviceId" :key="mic.deviceId">{{ mic.label }}</option>
</select>
</div>
<div class="input-group mt-2">
<label for="setup-cam" class="input-group-text mb-0" :title="$t('meet.cam')">
<svg-icon icon="video"></svg-icon>
</label>
<select class="form-select" id="setup-cam" v-model="camera" @change="setupCameraChange">
<option value="">{{ $t('form.none') }}</option>
<option v-for="cam in setup.cameras" :value="cam.deviceId" :key="cam.deviceId">{{ cam.label }}</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-action" data-bs-dismiss="modal">{{ $t('btn.close') }}</button>
+ <btn class="btn-secondary modal-action" data-bs-dismiss="modal">{{ $t('btn.close') }}</btn>
</div>
</div>
</div>
</div>
<room-options v-if="session.config" :config="session.config" :room="room" @config-update="configUpdate"></room-options>
</div>
</template>
<script>
import { Modal } from 'bootstrap'
import { Meet, Roles } from '../../js/meet/app.js'
import StatusMessage from '../Widgets/StatusMessage'
import LogonForm from '../Login'
import RoomOptions from './RoomOptions'
// Register additional icons
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faComment,
faCog,
faCompress,
faCrown,
faDesktop,
faExpand,
faHandPaper,
faHeadphones,
faMicrophone,
faMicrophoneSlash,
faMicrophoneAlt,
faPowerOff,
faUser,
faUsers,
faVideo,
faVideoSlash,
faVolumeMute
} from '@fortawesome/free-solid-svg-icons'
// Register only these icons we need
library.add(
faComment,
faCog,
faCompress,
faCrown,
faDesktop,
faExpand,
faHandPaper,
faHeadphones,
faMicrophone,
faMicrophoneSlash,
faMicrophoneAlt,
faPowerOff,
faUser,
faUsers,
faVideo,
faVideoSlash,
faVolumeMute
)
let roomRequest
const authHeader = 'X-Meet-Auth-Token'
export default {
components: {
LogonForm,
RoomOptions,
StatusMessage
},
data() {
return {
setup: {
cameras: [],
microphones: [],
},
canShareScreen: false,
camera: '',
channels: [],
languages: {
en: 'lang.en',
de: 'lang.de',
fr: 'lang.fr',
it: 'lang.it'
},
meet: null,
microphone: '',
nickname: '',
password: '',
room: null,
roomState: 'init',
roomStateLabels: {
init: 'meet.status-init',
323: 'meet.status-323',
324: 'meet.status-324',
325: 'meet.status-325',
326: 'meet.status-326',
327: 'meet.status-327',
404: 'meet.status-404',
429: 'meet.status-429',
500: 'meet.status-500'
},
session: {},
audioActive: false,
videoActive: false,
chatActive: false,
handRaised: false,
screenShareActive: false
}
},
mounted() {
this.room = this.$route.params.room
// Initialize OpenVidu and do some basic checks
this.meet = new Meet($('#meet-session')[0]);
this.canShareScreen = this.meet.isScreenSharingSupported()
// Check the room and init the session
this.initSession()
// Setup the room UI
this.setupSession()
// Configure dialog events
$('#leave-dialog')[0].addEventListener('hide.bs.modal', () => {
// FIXME: Where exactly the user should land? Currently he'll land
// on dashboard (if he's logged in) or login form (if he's not).
this.$router.push({ name: 'dashboard' })
})
const dialog = $('#media-setup-dialog')[0]
dialog.addEventListener('show.bs.modal', () => { this.meet.setupStart() })
dialog.addEventListener('hide.bs.modal', () => { this.meet.setupStop() })
},
beforeDestroy() {
clearTimeout(roomRequest)
$('#app').removeClass('meet')
if (this.meet) {
this.meet.leaveRoom()
}
delete axios.defaults.headers.common[authHeader]
$(document.body).off('keydown.meet')
},
methods: {
authSuccess() {
// The user authentication succeeded, we still don't know it's really the room owner
this.initSession()
$('#meet-setup').removeClass('hidden')
$('#meet-auth').addClass('hidden')
},
configUpdate(config) {
this.session.config = Object.assign({}, this.session.config, config)
},
dismissParticipant(id) {
axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections/' + id + '/dismiss')
},
initSession(init) {
const button = $('#join-button').prop('disabled', true)
this.post = {
password: this.password,
nickname: this.nickname,
screenShare: this.canShareScreen ? 1 : 0,
init: init ? 1 : 0,
picture: init ? this.makePicture() : '',
requestId: this.requestId(),
canPublish: !!this.camera || !!this.microphone
}
$('#setup-password,#setup-nickname').removeClass('is-invalid')
axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true })
.then(response => {
button.prop('disabled', false)
// We already have token, the response is redundant
if (this.roomState == 'ready' && this.session.token) {
return
}
this.roomState = 'ready'
this.session = response.data
if (init) {
this.joinSession()
}
if (this.session.authToken) {
axios.defaults.headers.common[authHeader] = this.session.authToken
}
})
.catch(error => {
if (!error.response) {
console.error(error)
return
}
const data = error.response.data || {}
if (data.code) {
this.roomState = data.code
} else {
this.roomState = error.response.status
}
button.prop('disabled', this.roomState == 'init' || this.roomState == 327 || this.roomState >= 400)
if (data.config) {
this.session.config = data.config
}
switch (this.roomState) {
case 323:
// Waiting for the owner to open the room...
// Update room state every 10 seconds
roomRequest = setTimeout(() => { this.initSession() }, 10000)
break;
case 324:
// Room is ready for the owner, but the 'init' was not requested yet
clearTimeout(roomRequest)
break;
case 325:
// Missing/invalid password
if (init) {
$('#setup-password').addClass('is-invalid').focus()
}
break;
case 326:
// Locked room prerequisites error
if (init && !$('#setup-nickname').val()) {
$('#setup-nickname').addClass('is-invalid').focus()
}
break;
case 327:
// Waiting for the owner's approval to join
// Update room state every 10 seconds
roomRequest = setTimeout(() => { this.initSession(true) }, 10000)
break;
case 429:
// Rate limited, wait and try again
const waitTime = error.response.headers['retry-after'] || 10
roomRequest = setTimeout(() => { this.initSession(init) }, waitTime * 1000)
break;
default:
if (this.roomState >= 400 && this.roomState != 404) {
this.roomState = 500
}
}
})
if (document.fullscreenEnabled) {
$('#meet-session-menu').find('.link-fullscreen.closed').removeClass('hidden')
}
},
isModerator() {
return this.isRoomOwner() || (!!this.session.role && (this.session.role & Roles.MODERATOR) > 0)
},
isPublisher() {
return !!this.session.role && (this.session.role & Roles.PUBLISHER) > 0
},
isRoomOwner() {
return !!this.session.role && (this.session.role & Roles.OWNER) > 0
},
isRoomReady() {
return ['ready', 322, 324, 325, 326, 327].includes(this.roomState)
},
// An event received by the room owner when a participant is asking for a permission to join the room
joinRequest(data) {
// The toast for this user request already exists, ignore
// It's not really needed as we do this on server-side already
if ($('#i' + data.requestId).length) {
return
}
// FIXME: Should the message close button act as the Deny button? Do we need the Deny button?
let body = $(
`<div>`
+ `<div class="picture"><img src="${data.picture}"></div>`
+ `<div class="content">`
+ `<p class="mb-2"></p>`
+ `<div class="text-end">`
+ `<button type="button" class="btn btn-sm btn-success accept">${this.$t('btn.accept')}</button>`
+ `<button type="button" class="btn btn-sm btn-danger deny ms-2">${this.$t('btn.deny')}</button>`
)
this.$toast.message({
className: 'join-request',
icon: 'user',
timeout: 0,
title: this.$t('meet.join-request'),
// titleClassName: '',
body: body.html(),
onShow: element => {
const id = data.requestId
$(element).find('p').text(this.$t('meet.join-requested', { user: data.nickname || '' }))
// add id attribute, so we can identify it
$(element).attr('id', 'i' + id)
// add action to the buttons
.find('button.accept,button.deny').on('click', e => {
const action = $(e.target).is('.accept') ? 'accept' : 'deny'
axios.post('/api/v4/openvidu/rooms/' + this.room + '/request/' + id + '/' + action)
.then(response => {
$('#i' + id).remove()
})
})
}
})
},
// Entering the room
joinSession() {
// The form can be submitted not only via the submit button,
// make sure the submit is allowed
if ($('#meet-setup [type=submit]').prop('disabled')) {
return;
}
if (this.roomState == 323) {
$('#meet-setup').addClass('hidden')
$('#meet-auth').removeClass('hidden')
return
}
if (this.roomState != 'ready' && !this.session.token) {
this.initSession(true)
return
}
clearTimeout(roomRequest)
this.session.nickname = this.nickname
this.session.languages = this.languages
this.session.menuElement = $('#meet-session-menu')[0]
this.session.chatElement = $('#meet-chat')[0]
this.session.queueElement = $('#meet-queue')[0]
this.session.counterElement = $('#meet-counter span')[0]
this.session.translate = (label, args) => this.$t(label, args)
this.session.onSuccess = () => {
$('#app').addClass('meet')
$('#meet-setup').addClass('hidden')
$('#meet-session-toolbar,#meet-session-layout').removeClass('hidden')
}
this.session.onError = () => {
this.roomState = 500
}
this.session.onDestroy = event => {
// TODO: Display different message for each reason: forceDisconnectByUser,
// forceDisconnectByServer, sessionClosedByServer?
if (event.reason != 'disconnect' && event.reason != 'networkDisconnect' && !this.isRoomOwner()) {
new Modal('#leave-dialog').show()
}
}
this.session.onDismiss = connId => { this.dismissParticipant(connId) }
this.session.onSessionDataUpdate = data => { this.updateSession(data) }
this.session.onConnectionChange = (connId, data) => { this.updateParticipant(connId, data) }
this.session.onJoinRequest = data => { this.joinRequest(data) }
this.session.onMediaSetup = () => { this.setupMedia() }
this.meet.joinRoom(this.session)
this.keyboardShortcuts()
},
keyboardShortcuts() {
$(document.body).on('keydown.meet', e => {
if ($(e.target).is('select,input,textarea')) {
return
}
// Self-Mute with 'm' key
if (e.key == 'm' || e.key == 'M') {
if ($('#meet-session-menu').find('.link-audio:not(:disabled)').length) {
this.switchSound()
}
}
})
},
logout() {
const logout = () => {
this.meet.leaveRoom()
this.meet = null
this.$router.push({ name: 'dashboard' })
}
if (this.isRoomOwner()) {
axios.post('/api/v4/openvidu/rooms/' + this.room + '/close').then(logout)
} else {
logout()
}
},
makePicture() {
const video = $("#meet-setup video")[0];
// Skip if video is not "playing"
if (!video.videoWidth || !this.camera) {
return ''
}
// we're going to crop a square from the video and resize it
const maxSize = 64
// Calculate sizing
let sh = Math.floor(video.videoHeight / 1.5)
let sw = sh
let sx = (video.videoWidth - sw) / 2
let sy = (video.videoHeight - sh) / 2
let dh = Math.min(sh, maxSize)
let dw = sh < maxSize ? sw : Math.floor(sw * dh/sh)
const canvas = $("<canvas>")[0];
canvas.width = dw;
canvas.height = dh;
// draw the image on the canvas (square cropped and resized)
canvas.getContext('2d').drawImage(video, sx, sy, sw, sh, 0, 0, dw, dh);
// convert it to a usable data URL (png format)
return canvas.toDataURL();
},
requestId() {
const key = 'kolab-meet-uid'
if (!this.reqId) {
this.reqId = localStorage.getItem(key)
}
if (!this.reqId) {
// We store the identifier in the browser to make sure that it is the same after
// page refresh for the avg user. This will not prevent hackers from sending
// the new identifier on every request.
// If we're afraid of a room owner being spammed with join requests we might invent
// a way to silently ignore all join requests after the owner pressed some button
// stating "all attendees already joined, lock the room for good!".
// This will create max. 24-char numeric string
this.reqId = (String(Date.now()) + String(Math.random()).substring(2)).substring(0, 24)
localStorage.setItem(key, this.reqId)
}
return this.reqId
},
roomOptions() {
new Modal('#room-options-dialog').show()
},
setupMedia() {
const dialog = $('#media-setup-dialog')[0]
if (!$('video', dialog).length) {
$('#meet-setup').find('video,div.volume').appendTo($('.media-setup-preview', dialog))
}
new Modal(dialog).show()
},
setupSession() {
this.meet.setupStart({
videoElement: $('#meet-setup video')[0],
volumeElement: $('#meet-setup .volume')[0],
onSuccess: setup => {
this.setup = setup
this.microphone = setup.audioSource
this.camera = setup.videoSource
this.audioActive = setup.audioActive
this.videoActive = setup.videoActive
},
onError: error => {
this.audioActive = false
this.videoActive = false
}
})
},
setupCameraChange() {
this.meet.setupSetVideoDevice(this.camera).then(enabled => {
this.videoActive = enabled
})
},
setupMicrophoneChange() {
this.meet.setupSetAudioDevice(this.microphone).then(enabled => {
this.audioActive = enabled
})
},
switchChannel(e) {
let channel = $(e.target).data('code')
this.$set(this.session, 'channel', channel)
this.meet.switchChannel(channel)
},
switchChat() {
let chat = $('#meet-chat')
let enabled = chat.is('.open')
chat.toggleClass('open')
if (!enabled) {
chat.find('textarea').focus()
}
this.chatActive = !enabled
// Trigger resize, so participant matrix can update its layout
window.dispatchEvent(new Event('resize'));
},
switchFullscreen() {
const element = this.$el
$(element).off('fullscreenchange').on('fullscreenchange', (e) => {
let enabled = document.fullscreenElement == element
let buttons = $('#meet-session-menu').find('.link-fullscreen')
buttons.first()[enabled ? 'addClass' : 'removeClass']('hidden')
buttons.last()[!enabled ? 'addClass' : 'removeClass']('hidden')
})
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
element.requestFullscreen()
}
},
switchHand() {
this.updateSelf({ hand: !this.handRaised })
},
switchSound() {
this.audioActive = this.meet.switchAudio()
},
switchVideo() {
this.videoActive = this.meet.switchVideo()
},
switchScreen() {
const switchScreenAction = () => {
this.meet.switchScreen((enabled, error) => {
this.screenShareActive = enabled
if (!enabled && !error) {
// Closing a screen sharing connection invalidates the token
delete this.session.shareToken
}
})
}
if (this.session.shareToken || this.screenShareActive) {
switchScreenAction()
} else {
axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections')
.then(response => {
this.session.shareToken = response.data.token
this.meet.updateSession(this.session)
switchScreenAction()
})
}
},
updateParticipant(connId, params) {
if (this.isModerator()) {
axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + connId, params)
}
},
updateSelf(params, onSuccess) {
axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + this.session.connectionId, params)
.then(response => {
if (onSuccess) {
onSuccess(response)
}
})
},
updateSession(data) {
this.session = data
this.channels = data.channels || []
const isPublisher = this.isPublisher()
this.videoActive = isPublisher ? data.videoActive : false
this.audioActive = isPublisher ? data.audioActive : false
this.handRaised = data.hand
}
}
}
</script>
diff --git a/src/resources/vue/Meet/RoomOptions.vue b/src/resources/vue/Meet/RoomOptions.vue
index 676e57de..c1638f41 100644
--- a/src/resources/vue/Meet/RoomOptions.vue
+++ b/src/resources/vue/Meet/RoomOptions.vue
@@ -1,113 +1,113 @@
<template>
<div v-if="config">
<div id="room-options-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('meet.options') }}</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
</div>
<div class="modal-body">
<form id="room-options-password">
<div id="password-input" class="input-group input-group-activable mb-2">
<span class="input-group-text label">{{ $t('meet.password') }}:</span>
<span v-if="config.password" id="password-input-text" class="input-group-text">{{ config.password }}</span>
<span v-else id="password-input-text" class="input-group-text text-muted">{{ $t('meet.password-none') }}</span>
<input type="text" :value="config.password" name="password" class="form-control rounded-start activable">
- <button type="button" @click="passwordSave" id="password-save-btn" class="btn btn-outline-primary activable rounded-end">{{ $t('btn.save') }}</button>
- <button type="button" v-if="config.password" id="password-clear-btn" @click="passwordClear" class="btn btn-outline-danger rounded">{{ $t('meet.password-clear') }}</button>
- <button type="button" v-else @click="passwordSet" id="password-set-btn" class="btn btn-outline-primary rounded">{{ $t('meet.password-set') }}</button>
+ <btn @click="passwordSave" id="password-save-btn" class="btn-outline-primary activable rounded-end">{{ $t('btn.save') }}</btn>
+ <btn v-if="config.password" id="password-clear-btn" @click="passwordClear" class="btn-outline-danger rounded">{{ $t('meet.password-clear') }}</btn>
+ <btn v-else @click="passwordSet" id="password-set-btn" class="btn-outline-primary rounded">{{ $t('meet.password-set') }}</btn>
</div>
<small class="text-muted">
{{ $t('meet.password-text') }}
</small>
</form>
<hr>
<form id="room-options-lock">
<div id="room-lock" class="mb-2">
<label for="room-lock-input">{{ $t('meet.lock') }}:</label>
<input type="checkbox" id="room-lock-input" name="lock" value="1" :checked="config.locked" @click="lockSave">
</div>
<small class="text-muted">
{{ $t('meet.lock-text') }}
</small>
</form>
<hr>
<form id="room-options-nomedia">
<div id="room-nomedia" class="mb-2">
<label for="room-nomedia-input">{{ $t('meet.nomedia') }}:</label>
<input type="checkbox" id="room-nomedia-input" name="lock" value="1" :checked="config.nomedia" @click="nomediaSave">
</div>
<small class="text-muted">
{{ $t('meet.nomedia-text') }}
</small>
</form>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-action" data-bs-dismiss="modal">{{ $t('btn.close') }}</button>
+ <btn class="btn-secondary modal-action" data-bs-dismiss="modal">{{ $t('btn.close') }}</btn>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
config: { type: Object, default: () => null },
room: { type: String, default: () => null }
},
mounted() {
$('#room-options-dialog')[0].addEventListener('show.bs.modal', e => {
$(e.target).find('.input-group-activable.active').removeClass('active')
})
},
methods: {
configSave(name, value, callback) {
const post = {}
post[name] = value
axios.post('/api/v4/openvidu/rooms/' + this.room + '/config', post)
.then(response => {
this.$set(this.config, name, value)
if (callback) {
callback(response.data)
}
this.$emit('config-update', this.config)
this.$toast.success(response.data.message)
})
},
lockSave(e) {
this.configSave('locked', $(e.target).prop('checked') ? 1 : 0)
},
nomediaSave(e) {
this.configSave('nomedia', $(e.target).prop('checked') ? 1 : 0)
},
passwordClear() {
this.configSave('password', '')
},
passwordSave() {
this.configSave('password', $('#password-input input').val(), () => {
$('#password-input').removeClass('active')
})
},
passwordSet() {
$('#password-input').addClass('active').find('input')
.off('keydown.pass')
.on('keydown.pass', e => {
if (e.which == 13) {
// On ENTER save the password
this.passwordSave()
e.preventDefault()
} else if (e.which == 27) {
// On ESC escape from the input, but not the dialog
$('#password-input').removeClass('active')
e.stopPropagation()
}
})
.focus()
}
}
}
</script>
diff --git a/src/resources/vue/PasswordReset.vue b/src/resources/vue/PasswordReset.vue
index b7b26877..60a7d8c2 100644
--- a/src/resources/vue/PasswordReset.vue
+++ b/src/resources/vue/PasswordReset.vue
@@ -1,169 +1,169 @@
<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>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.continue') }}</button>
+ <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>
- <button class="btn btn-secondary" type="button" @click="stepBack">{{ $t('btn.back') }}</button>
- <button class="btn btn-primary ms-2" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.continue') }}</button>
+ <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="sr-only">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="Second factor code" v-model="secondFactor">
</div>
<small class="form-text text-muted">Second factor code is optional for users with no 2-Factor Authentication setup.</small>
</div>
- <button class="btn btn-secondary" type="button" @click="stepBack">{{ $t('btn.back') }}</button>
- <button class="btn btn-primary ms-2" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ <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'
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) {
if (bylink === true) {
this.displayForm(2, false)
}
this.$root.clearFormValidation($('#step2 form'))
axios.post('/api/auth/password-reset/verify', {
code: this.code,
short_code: this.short_code
}).then(response => {
this.userId = response.data.userId
this.displayForm(3, true)
}).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 reset the password
submitStep3() {
this.$root.clearFormValidation($('#step3 form'))
axios.post('/api/auth/password-reset', {
code: this.code,
short_code: this.short_code,
password: this.pass.password,
password_confirmation: this.pass.password_confirmation,
secondfactor: this.secondFactor
}).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/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue
index 4ec609fd..8d373a4e 100644
--- a/src/resources/vue/Reseller/Invitations.vue
+++ b/src/resources/vue/Reseller/Invitations.vue
@@ -1,223 +1,215 @@
<template>
<div class="container">
<div class="card" id="invitations">
<div class="card-body">
<div class="card-title">
{{ $t('invitation.title') }}
</div>
<div class="card-text">
<div class="mb-2 d-flex">
<list-search :placeholder="$t('invitation.search')" :on-search="searchInvitations"></list-search>
- <div>
- <button class="btn btn-success create-invite ms-1" @click="inviteUserDialog">
- <svg-icon icon="envelope-open-text"></svg-icon> {{ $t('invitation.create') }}
- </button>
- </div>
+ <btn class="btn-success create-invite ms-1" @click="inviteUserDialog" icon="envelope-open-text">{{ $t('invitation.create') }}</btn>
</div>
<table id="invitations-list" class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">{{ $t('user.ext-email') }}</th>
<th scope="col">{{ $t('form.created') }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invitations" :id="'i' + inv.id" :key="inv.id">
<td class="email">
<svg-icon icon="envelope-open-text" :class="statusClass(inv)" :title="$t('invitation.status-' + statusLabel(inv))"></svg-icon>
<span>{{ inv.email }}</span>
</td>
<td class="datetime">
{{ inv.created }}
</td>
<td class="buttons">
- <button class="btn text-danger button-delete p-0 ms-1" @click="deleteInvite(inv.id)">
- <svg-icon icon="trash-alt"></svg-icon>
+ <btn class="text-danger button-delete p-0 ms-1" @click="deleteInvite(inv.id)" icon="trash-alt">
<span class="btn-label">{{ $t('btn.delete') }}</span>
- </button>
- <button class="btn button-resend p-0 ms-1" :disabled="inv.isNew || inv.isCompleted" @click="resendInvite(inv.id)">
- <svg-icon icon="redo"></svg-icon>
+ </btn>
+ <btn class="button-resend p-0 ms-1" :disabled="inv.isNew || inv.isCompleted" @click="resendInvite(inv.id)" icon="redo">
<span class="btn-label">{{ $t('btn.resend') }}</span>
- </button>
+ </btn>
</td>
</tr>
</tbody>
<list-foot :text="$t('invitation.empty-list')" colspan="3"></list-foot>
</table>
<list-more v-if="hasMore" :on-click="loadInvitations"></list-more>
</div>
</div>
</div>
<div id="invite-create" 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('invitation.create-title') }}</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
</div>
<div class="modal-body">
<form>
<p>{{ $t('invitation.create-email') }}</p>
<div>
<input id="email" type="text" class="form-control" name="email">
</div>
<div class="form-separator"><hr><span>{{ $t('form.or') }}</span></div>
<p>{{ $t('invitation.create-csv') }}</p>
<div>
<input id="file" type="file" class="form-control" name="csv">
</div>
</form>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
- <button type="button" class="btn btn-primary modal-action" @click="inviteUser()">
- <svg-icon icon="paper-plane"></svg-icon> {{ $t('invitation.send') }}
- </button>
+ <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
+ <btn class="btn-primary modal-action" icon="paper-plane" @click="inviteUser()">{{ $t('invitation.send') }}</btn>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from 'bootstrap'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEnvelopeOpenText, faPaperPlane, faRedo } from '@fortawesome/free-solid-svg-icons'
import ListTools from '../Widgets/ListTools'
library.add(faEnvelopeOpenText, faPaperPlane, faRedo)
export default {
mixins: [ ListTools ],
data() {
return {
invitations: []
}
},
mounted() {
this.loadInvitations({ init: true })
$('#invite-create')[0].addEventListener('shown.bs.modal', event => {
$('input', event.target).first().focus()
})
},
methods: {
deleteInvite(id) {
axios.delete('/api/v4/invitations/' + id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
// Remove the invitation record from the list
const index = this.invitations.findIndex(item => item.id == id)
this.invitations.splice(index, 1)
}
})
},
fileChange(e) {
let label = this.$t('btn.file')
let files = e.target.files
if (files.length) {
label = files[0].name
if (files.length > 1) {
label += ', ...'
}
}
$(e.target).next().text(label)
},
inviteUser() {
let dialog = $('#invite-create')
let post = new FormData()
let params = { headers: { 'Content-Type': 'multipart/form-data' } }
post.append('email', dialog.find('#email').val())
this.$root.clearFormValidation(dialog.find('form'))
// Append the file to POST data
let files = dialog.find('#file').get(0).files
if (files.length) {
post.append('file', files[0])
}
axios.post('/api/v4/invitations', post, params)
.then(response => {
if (response.data.status == 'success') {
this.dialog.hide()
this.$toast.success(response.data.message)
if (response.data.count) {
this.loadInvitations({ reset: true })
}
}
})
},
inviteUserDialog() {
const dialog = $('#invite-create')[0]
const form = $('form', dialog)
form.get(0).reset()
this.fileChange({ target: form.find('#file')[0] }) // resets file input label
this.$root.clearFormValidation(form)
this.dialog = new Modal(dialog)
this.dialog.show()
},
loadInvitations(params) {
this.listSearch('invitations', '/api/v4/invitations', params)
},
resendInvite(id) {
axios.post('/api/v4/invitations/' + id + '/resend')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
// Update the invitation record
const index = this.invitations.findIndex(item => item.id == id)
if (index > -1) {
this.$set(this.invitations, index, response.data.invitation)
}
}
})
},
searchInvitations(search) {
this.loadInvitations({ reset: true, search })
},
statusClass(invitation) {
if (invitation.isCompleted) {
return 'text-success'
}
if (invitation.isFailed) {
return 'text-danger'
}
if (invitation.isSent) {
return 'text-primary'
}
return ''
},
statusLabel(invitation) {
if (invitation.isCompleted) {
return 'completed'
}
if (invitation.isFailed) {
return 'failed'
}
if (invitation.isSent) {
return 'sent'
}
return 'new'
}
}
}
</script>
diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/Resource/Info.vue
index a9f1ad10..eb420ccb 100644
--- a/src/resources/vue/Resource/Info.vue
+++ b/src/resources/vue/Resource/Info.vue
@@ -1,189 +1,187 @@
<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) }}
- <button class="btn btn-outline-danger button-delete float-end" @click="deleteResource()" tag="button">
- <svg-icon icon="trash-alt"></svg-icon> {{ $t('resource.delete') }}
- </button>
+ <btn class="btn-outline-danger button-delete float-end" @click="deleteResource()" icon="trash-alt">{{ $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>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ <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>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ <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)
.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')
.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.resource.config}
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 71c3877c..118fe4b3 100644
--- a/src/resources/vue/Resource/List.vue
+++ b/src/resources/vue/Resource/List.vue
@@ -1,61 +1,61 @@
<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>
- <router-link v-if="!$root.isDegraded()" class="btn btn-success float-end create-resource" :to="{ path: 'resource/new' }" tag="button">
- <svg-icon icon="cog"></svg-icon> {{ $t('resource.create') }}
- </router-link>
+ <btn-router v-if="!$root.isDegraded()" to="resource/new" class="btn-success float-end" icon="cog">
+ {{ $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="cog" :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>
export default {
data() {
return {
resources: []
}
},
created() {
this.$root.startLoading()
axios.get('/api/v4/resources')
.then(response => {
this.$root.stopLoading()
this.resources = response.data
})
.catch(this.$root.errorHandler)
}
}
</script>
diff --git a/src/resources/vue/Settings.vue b/src/resources/vue/Settings.vue
index 6e4c91ff..37123fe6 100644
--- a/src/resources/vue/Settings.vue
+++ b/src/resources/vue/Settings.vue
@@ -1,79 +1,79 @@
<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('user.passwordpolicy') }}</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">
<label :for="'policy-' + rule.label" class="form-check-label pe-2">{{ 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>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
passwordPolicy: []
}
},
created() {
this.wallet = this.$store.state.authInfo.wallet
},
mounted() {
this.$root.startLoading()
axios.get('/api/v4/password-policy')
.then(response => {
this.$root.stopLoading()
if (response.data.list) {
this.passwordPolicy = response.data.list
}
})
.catch(this.$root.errorHandler)
},
methods: {
submit() {
this.$root.clearFormValidation($('#settings form'))
let password_policy = [];
$('#password_policy > li > input:checked').each((i, element) => {
let entry = element.name
const input = $(element.parentNode).find('input[type=text]')[0]
if (input) {
entry += ':' + input.value
}
password_policy.push(entry)
})
let post = { 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 06152c1a..501ec9ac 100644
--- a/src/resources/vue/SharedFolder/Info.vue
+++ b/src/resources/vue/SharedFolder/Info.vue
@@ -1,177 +1,175 @@
<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) }}
- <button class="btn btn-outline-danger button-delete float-end" @click="deleteFolder()" tag="button">
- <svg-icon icon="trash-alt"></svg-icon> {{ $t('shf.delete') }}
- </button>
+ <btn class="btn-outline-danger button-delete float-end" @click="deleteFolder()" icon="trash-alt">{{ $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 v-if="folder.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="folder.email">
</div>
</div>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ <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>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ <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 StatusComponent from '../Widgets/Status'
export default {
components: {
AclInput,
StatusComponent
},
data() {
return {
domains: [],
folder_id: null,
folder: { type: 'mail', config: {} },
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)
.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')
.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'])
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.folder.config}
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 63c9a9eb..b3d2753f 100644
--- a/src/resources/vue/SharedFolder/List.vue
+++ b/src/resources/vue/SharedFolder/List.vue
@@ -1,61 +1,61 @@
<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>
- <router-link v-if="!$root.isDegraded()" class="btn btn-success float-end create-folder" :to="{ path: 'shared-folder/new' }" tag="button">
- <svg-icon icon="cog"></svg-icon> {{ $t('shf.create') }}
- </router-link>
+ <btn-router v-if="!$root.isDegraded()" to="shared-folder/new" class="btn-success float-end" icon="cog">
+ {{ $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>
<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>
</template>
<script>
export default {
data() {
return {
folders: []
}
},
created() {
this.$root.startLoading()
axios.get('/api/v4/shared-folders')
.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 23c06e3a..6bff675b 100644
--- a/src/resources/vue/Signup.vue
+++ b/src/resources/vue/Signup.vue
@@ -1,301 +1,302 @@
<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">
- <button class="btn btn-primary" :data-title="item.title" @click="selectPlan(item.title)" v-html="item.button"></button>
+ <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>
- <button class="btn btn-secondary" type="button" @click="stepBack">{{ $t('btn.back') }}</button>
- <button class="btn btn-primary ms-2" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.continue') }}</button>
+ <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>
- <button class="btn btn-secondary" type="button" @click="stepBack">{{ $t('btn.back') }}</button>
- <button class="btn btn-primary ms-2" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.continue') }}</button>
+ <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>
- <button v-if="!invitation" class="btn btn-secondary me-2" type="button" @click="stepBack">{{ $t('btn.back') }}</button>
- <button class="btn btn-primary" type="submit">
- <svg-icon icon="check"></svg-icon> <span v-if="invitation">{{ $t('btn.signup') }}</span><span v-else>{{ $t('btn.submit') }}</span>
- </button>
+ <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'
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)
.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()
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'))
axios.post('/api/auth/signup/init', {
email: this.email,
last_name: this.last_name,
first_name: this.first_name,
plan: this.plan,
voucher: this.voucher
}).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'))
axios.post('/api/auth/signup/verify', {
code: this.code,
short_code: this.short_code
}).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 = {
login: this.login,
domain: this.domain,
password: this.pass.password,
password_confirmation: this.pass.password_confirmation,
voucher: this.voucher
}
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 8330b8a2..519b7daa 100644
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -1,334 +1,325 @@
<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') }}
- <button
- class="btn btn-outline-danger button-delete float-end"
- @click="showDeleteConfirmation()" type="button"
- >
- <svg-icon icon="trash-alt"></svg-icon> {{ $t('user.delete') }}
- </button>
+ <btn icon="trash-alt" 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">
- <button class="btn btn-link p-1" type="button" :title="$t('btn.copy')" @click="passwordLinkCopy">
- <svg-icon :icon="['far', 'clipboard']"></svg-icon>
- </button>
- <button v-if="user.passwordLinkCode" class="btn btn-link text-danger p-1" type="button" :title="$t('btn.delete')" @click="passwordLinkDelete">
- <svg-icon icon="trash-alt"></svg-icon>
- </button>
+ <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-alt" :title="$t('btn.delete')" @click="passwordLinkDelete"></btn>
</span>
</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>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ <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>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ <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>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ <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">
- <button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
- <button type="button" class="btn btn-danger modal-action" @click="deleteUser()">
- <svg-icon icon="trash-alt"></svg-icon> {{ $t('btn.delete') }}
- </button>
+ <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
+ <btn class="btn-danger modal-action" icon="trash-alt" @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'
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.$store.state.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)
.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() becouse 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', [])
.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.$store.state.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.$store.state.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/User/List.vue b/src/resources/vue/User/List.vue
index 7e1030e4..fca0c8c8 100644
--- a/src/resources/vue/User/List.vue
+++ b/src/resources/vue/User/List.vue
@@ -1,62 +1,60 @@
<template>
<div class="container">
<div class="card" id="user-list">
<div class="card-body">
<div class="card-title">
{{ $t('user.list-title') }}
</div>
<div class="card-text">
<div class="mb-2 d-flex">
<list-search :placeholder="$t('user.search')" :on-search="searchUsers"></list-search>
- <div v-if="!$root.isDegraded()">
- <router-link class="btn btn-success ms-1 create-user" :to="{ path: 'user/new' }" tag="button">
- <svg-icon icon="user"></svg-icon> {{ $t('user.create') }}
- </router-link>
- </div>
+ <btn-router v-if="!$root.isDegraded()" to="user/new" class="btn-success ms-1" icon="user">
+ {{ $t('user.create') }}
+ </btn-router>
</div>
<table id="users-list" class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">{{ $t('form.primary-email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :id="'user' + user.id" :key="user.id" @click="$root.clickRecord">
<td>
<svg-icon icon="user" :class="$root.statusClass(user)" :title="$root.statusText(user)"></svg-icon>
<router-link :to="{ path: 'user/' + user.id }">{{ user.email }}</router-link>
</td>
</tr>
</tbody>
<list-foot :text="$t('user.users-none')"></list-foot>
</table>
<list-more v-if="hasMore" :on-click="loadUsers"></list-more>
</div>
</div>
</div>
</div>
</template>
<script>
import ListTools from '../Widgets/ListTools'
export default {
mixins: [ ListTools ],
data() {
return {
users: []
}
},
mounted() {
this.loadUsers({ init: true })
},
methods: {
loadUsers(params) {
this.listSearch('users', '/api/v4/users', params)
},
searchUsers(search) {
this.loadUsers({ reset: true, search })
}
}
}
</script>
diff --git a/src/resources/vue/User/Profile.vue b/src/resources/vue/User/Profile.vue
index 61515202..1f5171d7 100644
--- a/src/resources/vue/User/Profile.vue
+++ b/src/resources/vue/User/Profile.vue
@@ -1,118 +1,114 @@
<template>
<div class="container">
<div class="card" id="user-profile">
<div class="card-body">
<div class="card-title">
{{ $t('user.profile-title') }}
- <router-link
- v-if="$root.isController(wallet.id)"
- class="btn btn-outline-danger button-delete float-end"
- to="/profile/delete" tag="button"
- >
- <svg-icon icon="trash-alt"></svg-icon> {{ $t('user.profile-delete') }}
- </router-link>
+ <btn-router v-if="$root.isController(wallet.id)" to="profile/delete" class="btn-outline-danger float-end" icon="trash-alt">
+ {{ $t('user.profile-delete') }}
+ </btn-router>
</div>
<div class="card-text">
<form @submit.prevent="submit">
<div class="row mb-3 plaintext">
<label class="col-sm-4 col-form-label">{{ $t('user.custno') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="userid">{{ user_id }}</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="profile.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="profile.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="profile.organization">
</div>
</div>
<div class="row mb-3">
<label for="phone" class="col-sm-4 col-form-label">{{ $t('form.phone') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="phone" v-model="profile.phone">
</div>
</div>
<div class="row mb-3">
<label for="external_email" class="col-sm-4 col-form-label">{{ $t('user.ext-email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="external_email" v-model="profile.external_email">
</div>
</div>
<div class="row mb-3">
<label for="billing_address" class="col-sm-4 col-form-label">{{ $t('user.address') }}</label>
<div class="col-sm-8">
<textarea class="form-control" id="billing_address" rows="3" v-model="profile.billing_address"></textarea>
</div>
</div>
<div class="row mb-3">
<label for="country" class="col-sm-4 col-form-label">{{ $t('user.country') }}</label>
<div class="col-sm-8">
<select class="form-select" id="country" v-model="profile.country">
<option value="">-</option>
<option v-for="(item, code) in countries" :value="code" :key="code">{{ item[1] }}</option>
</select>
</div>
</div>
<div class="row mb-3">
<label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
<password-input class="col-sm-8" v-model="profile"></password-input>
</div>
- <button class="btn btn-primary button-submit mt-2" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ <btn class="btn-primary button-submit mt-2" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
import PasswordInput from '../Widgets/PasswordInput'
export default {
components: {
PasswordInput
},
data() {
return {
profile: {},
user_id: null,
wallet: {},
countries: window.config.countries
}
},
created() {
this.wallet = this.$store.state.authInfo.wallet
this.profile = this.$store.state.authInfo.settings
this.user_id = this.$store.state.authInfo.id
},
mounted() {
$('#first_name').focus()
},
methods: {
submit() {
this.$root.clearFormValidation($('#user-profile form'))
axios.put('/api/v4/users/' + this.user_id, this.profile)
.then(response => {
delete this.profile.password
delete this.profile.password_confirm
this.$toast.success(response.data.message)
this.$router.push({ name: 'dashboard' })
})
}
}
}
</script>
diff --git a/src/resources/vue/User/ProfileDelete.vue b/src/resources/vue/User/ProfileDelete.vue
index 529e956c..a52f85e8 100644
--- a/src/resources/vue/User/ProfileDelete.vue
+++ b/src/resources/vue/User/ProfileDelete.vue
@@ -1,48 +1,48 @@
<template>
<div class="container">
<div class="card" id="user-delete">
<div class="card-body">
<div class="card-title">{{ $t('user.delete-account') }}</div>
<div class="card-text">
<p>{{ $t('user.profile-delete-text1') }} <strong>{{ $t('user.profile-delete-warning') }}</strong>.</p>
<p>{{ $t('user.profile-delete-text2') }}</p>
<p v-if="supportEmail" v-html="$t('user.profile-delete-support', { href: 'mailto:' + supportEmail, email: supportEmail })"></p>
<p>{{ $t('user.profile-delete-contact', { app: $root.appName }) }}</p>
- <button class="btn btn-secondary button-cancel" @click="$router.go(-1)">{{ $t('btn.cancel') }}</button>
- <button class="btn btn-danger button-delete" @click="deleteProfile">
- <svg-icon icon="trash-alt"></svg-icon> {{ $t('user.profile-delete') }}
- </button>
+ <p class="buttons">
+ <btn class="btn-secondary button-cancel" @click="$router.go(-1)">{{ $t('btn.cancel') }}</btn>
+ <btn class="btn-danger button-delete" @click="deleteProfile" icon="trash-alt">{{ $t('user.profile-delete') }}</btn>
+ </p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
supportEmail: window.config['app.support_email']
}
},
created() {
if (!this.$root.isController(this.$store.state.authInfo.wallet.id)) {
this.$root.errorPage(403)
}
},
mounted() {
$('button.btn-secondary').focus()
},
methods: {
deleteProfile() {
axios.delete('/api/v4/users/' + this.$store.state.authInfo.id)
.then(response => {
if (response.data.status == 'success') {
this.$root.logoutUser()
this.$toast.success(response.data.message)
}
})
}
}
}
</script>
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
index 15e09985..601bc83c 100644
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -1,435 +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>
- <button type="button" class="btn btn-primary" @click="paymentMethodForm('manual')">{{ $t('wallet.add-credit') }}</button>
+ <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>
- <button type="button" class="btn btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</button>
+ <btn class="btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</btn>
</template>
- <button type="button" class="btn btn-primary" @click="paymentMethodForm('auto')">{{ $t('wallet.auto-payment-setup') }}</button>
+ <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">
- <button type="button" class="btn btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</button>
- <button type="button" class="btn btn-primary" @click="autoPaymentChange">{{ $t('wallet.auto-payment-change') }}</button>
+ <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>
- <button type="button" class="btn btn-secondary" @click="receiptDownload">
- <svg-icon icon="download"></svg-icon> {{ $t('btn.download') }}
- </button>
+ <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>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ <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 :id="method.id" v-for="method in paymentMethods" :key="method.id" @click="selectPaymentMethod(method)" href="#" class="card link-profile">
<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">
- <button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
- <button type="button" class="btn btn-primary modal-action"
- v-if="paymentForm == 'auto' && (mandate.isValid || mandate.isPending)"
- @click="autoPayment"
+ <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)"
>
- <svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
- </button>
- <button type="button" class="btn btn-primary modal-action"
- v-if="paymentForm == 'auto' && !mandate.isValid && !mandate.isPending"
- @click="autoPayment"
+ {{ $t('btn.submit') }}
+ </btn>
+ <btn class="btn btn-primary modal-action" icon="check" @click="autoPayment"
+ v-if="paymentForm == 'auto' && !mandate.isValid && !mandate.isPending"
>
- <svg-icon icon="check"></svg-icon> {{ $t('btn.continue') }}
- </button>
- <button type="button" class="btn btn-primary modal-action"
- v-if="paymentForm == 'manual'"
- @click="payment"
+ {{ $t('btn.continue') }}
+ </btn>
+ <btn class="btn-primary modal-action" icon="check" @click="payment"
+ v-if="paymentForm == 'manual'"
>
- <svg-icon icon="check"></svg-icon> {{ $t('btn.continue') }}
- </button>
+ {{ $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'
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.$store.state.authInfo.wallets[0].id
this.$root.startLoading()
axios.get('/api/v4/wallets/' + this.walletId)
.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')
.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')
this.$root.removeLoader(mandate_form)
if (!this.mandate.id || this.mandate.isPending) {
this.$root.addLoader(mandate_form)
axios.get('/api/v4/payments/mandate')
.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'))
axios.post('/api/v4/payments', {amount: this.amount, methodId: this.selectedPaymentMethod.id, currency: this.selectedPaymentMethod.currency}, { 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')
this.$root.addLoader(form, false, {'min-height': '10em'})
axios.get('/api/v4/payments/methods', {params: {type: nextForm == 'manual' ? 'oneoff' : 'recurring'}})
.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)
},
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/Btn.vue b/src/resources/vue/Widgets/Btn.vue
new file mode 100644
index 00000000..c6f101ca
--- /dev/null
+++ b/src/resources/vue/Widgets/Btn.vue
@@ -0,0 +1,14 @@
+<template>
+ <button class="btn" :type="type" @click="$emit('click', $event)">
+ <svg-icon v-if="icon" :icon="icon"></svg-icon> <slot></slot>
+ </button>
+</template>
+
+<script>
+ export default {
+ props: {
+ type: { type: String, default: 'button' },
+ icon: { type: [ Array, String ], default: '' },
+ }
+ }
+</script>
diff --git a/src/resources/vue/Widgets/BtnRouter.vue b/src/resources/vue/Widgets/BtnRouter.vue
new file mode 100644
index 00000000..841b6c9b
--- /dev/null
+++ b/src/resources/vue/Widgets/BtnRouter.vue
@@ -0,0 +1,23 @@
+<template>
+ <router-link :to="to" custom v-slot="{ navigate }">
+ <btn :class="className()" :icon="icon" @click="navigate">
+ <slot></slot>
+ </btn>
+ </router-link>
+</template>
+
+<script>
+ export default {
+ props: {
+ to: { type: [ Object, String ], default: () => {} },
+ icon: { type: [ Array, String ], default: '' },
+ },
+ methods: {
+ className() {
+ let label = this.to.length ? this.to : this.to.name
+
+ return ['btn', label.replace('/', '-')]
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Widgets/PackageSelect.vue b/src/resources/vue/Widgets/PackageSelect.vue
index 171f34d6..b2ea61d9 100644
--- a/src/resources/vue/Widgets/PackageSelect.vue
+++ b/src/resources/vue/Widgets/PackageSelect.vue
@@ -1,87 +1,86 @@
<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">
- <button v-if="pkg.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip="pkg.description">
- <svg-icon icon="info-circle"></svg-icon>
+ <btn v-if="pkg.description" class="btn-link btn-lg p-0" v-tooltip="pkg.description" icon="info-circle">
<span class="visually-hidden">{{ $t('btn.moreinfo') }}</span>
- </button>
+ </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')
.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/Status.vue b/src/resources/vue/Widgets/Status.vue
index 5c33688f..ad216df6 100644
--- a/src/resources/vue/Widgets/Status.vue
+++ b/src/resources/vue/Widgets/Status.vue
@@ -1,198 +1,198 @@
<template>
<div v-if="!state.isReady" id="status-box" :class="'p-4 mb-3 rounded process-' + className">
<div v-if="state.step != 'domain-confirmed'" class="d-flex align-items-start">
<p id="status-body" class="flex-grow-1">
<span>{{ $t('status.prepare-' + scopeLabel()) }}</span>
<br>
{{ $t('status.prepare-hint') }}
<br>
<span id="refresh-text" v-if="refresh">{{ $t('status.prepare-refresh') }}</span>
</p>
- <button v-if="refresh" id="status-refresh" href="#" class="btn btn-secondary" @click="statusRefresh">
- <svg-icon icon="sync-alt"></svg-icon> {{ $t('btn.refresh') }}
- </button>
+ <btn v-if="refresh" id="status-refresh" href="#" class="btn-secondary" @click="statusRefresh" icon="sync-alt">
+ {{ $t('btn.refresh') }}
+ </btn>
</div>
<div v-else class="d-flex align-items-start">
<p id="status-body" class="flex-grow-1">
<span>{{ $t('status.ready-' + scopeLabel()) }}</span>
<br>
{{ $t('status.verify') }}
</p>
<div v-if="scope == 'domain'">
- <button id="status-verify" class="btn btn-secondary text-nowrap" @click="confirmDomain">
- <svg-icon icon="sync-alt"></svg-icon> {{ $t('btn.verify') }}
- </button>
+ <btn id="status-verify" class="btn-secondary text-nowrap" @click="confirmDomain" icon="sync-alt">
+ {{ $t('btn.verify') }}
+ </btn>
</div>
<div v-else-if="state.link && scope != 'domain'">
<router-link id="status-link" class="btn btn-secondary" :to="{ path: state.link }">{{ $t('status.verify-domain') }}</router-link>
</div>
</div>
<div class="status-progress text-center">
<div class="progress">
<div class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<span class="progress-label">{{ state.title || $t('msg.initializing') }}</span>
</div>
</div>
</template>
<script>
export default {
props: {
status: { type: Object, default: () => {} }
},
data() {
return {
className: 'pending',
refresh: false,
delay: 5000,
scope: 'user',
state: { isReady: true },
waiting: 0,
}
},
watch: {
// We use property watcher because parent component
// might set the property with a delay and we need to parse it
// FIXME: Problem with this and update-status event is that whenever
// we emit the event a watcher function is executed, causing
// duplicate parseStatusInfo() calls. Fortunaltely this does not
// cause duplicate http requests.
status: function (val, oldVal) {
this.parseStatusInfo(val)
}
},
destroyed() {
clearTimeout(window.infoRequest)
},
mounted() {
this.scope = this.$route.name
},
methods: {
// Displays account status information
parseStatusInfo(info) {
if (info) {
if (!info.isReady) {
let failedCount = 0
let allCount = info.process.length
info.process.forEach((step, idx) => {
if (!step.state) {
failedCount++
if (!info.title) {
info.title = step.title
info.step = step.label
info.link = step.link
}
}
})
info.percent = Math.floor((allCount - failedCount) / allCount * 100);
}
this.state = info || {}
this.$nextTick(function() {
$(this.$el).find('.progress-bar')
.css('width', info.percent + '%')
.attr('aria-valuenow', info.percent)
})
// Unhide the Refresh button, the process is in failure state
this.refresh = info.processState == 'failed' && this.waiting == 0
if (this.refresh || info.step == 'domain-confirmed') {
this.className = 'failed'
}
// A async job has been dispatched, switch to a waiting mode where
// we hide the Refresh button and pull status for about a minute,
// after that we switch to normal mode, i.e. user can Refresh again (if still not ready)
if (info.processState == 'waiting') {
this.waiting = 10
this.delay = 5000
} else if (this.waiting > 0) {
this.waiting -= 1
}
}
// Update status process info every 5,6,7,8,9,... seconds
clearTimeout(window.infoRequest)
if ((!this.refresh || this.waiting > 0) && (!info || !info.isReady)) {
window.infoRequest = setTimeout(() => {
delete window.infoRequest
// Stop updates after user logged out
if (!this.$store.state.isLoggedIn) {
return;
}
axios.get(this.getUrl())
.then(response => {
this.parseStatusInfo(response.data)
this.emitEvent(response.data)
})
.catch(error => {
this.parseStatusInfo(info)
})
}, this.delay);
this.delay += 1000;
}
},
statusRefresh() {
clearTimeout(window.infoRequest)
axios.get(this.getUrl() + '?refresh=1')
.then(response => {
this.$toast[response.data.status](response.data.message)
this.parseStatusInfo(response.data)
this.emitEvent(response.data)
})
.catch(error => {
this.parseStatusInfo(this.state)
})
},
confirmDomain() {
axios.get('/api/v4/domains/' + this.$route.params.domain + '/confirm')
.then(response => {
if (response.data.message) {
this.$toast[response.data.status](response.data.message)
}
if (response.data.status == 'success') {
this.parseStatusInfo(response.data.statusInfo)
response.data.isConfirmed = true
this.emitEvent(response.data)
}
})
},
emitEvent(data) {
// Remove useless data and emit the event (to parent components)
delete data.status
delete data.message
this.$emit('status-update', data)
},
getUrl() {
let scope = this.scope
let id = this.$route.params[scope]
if (scope == 'dashboard') {
id = this.$store.state.authInfo.id
scope = 'user'
} else if (scope =='distlist') {
id = this.$route.params.list
scope = 'group'
} else if (scope == 'shared-folder') {
id = this.$route.params.folder
}
return '/api/v4/' + scope + 's/' + id + '/status'
},
scopeLabel() {
return this.scope == 'dashboard' ? 'account' : this.scope
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/SubscriptionSelect.vue b/src/resources/vue/Widgets/SubscriptionSelect.vue
index 5b0c0de6..fb734b1c 100644
--- a/src/resources/vue/Widgets/SubscriptionSelect.vue
+++ b/src/resources/vue/Widgets/SubscriptionSelect.vue
@@ -1,207 +1,206 @@
<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">
- <button v-if="sku.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip="sku.description">
- <svg-icon icon="info-circle"></svg-icon>
+ <btn v-if="sku.description" class="btn-link btn-lg p-0" v-tooltip="sku.description" icon="info-circle">
<span class="visually-hidden">{{ $t('btn.moreinfo') }}</span>
- </button>
+ </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')
.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/SupportForm.vue b/src/resources/vue/Widgets/SupportForm.vue
index 2be3d804..930d6e1c 100644
--- a/src/resources/vue/Widgets/SupportForm.vue
+++ b/src/resources/vue/Widgets/SupportForm.vue
@@ -1,117 +1,115 @@
<template>
<div class="modal" id="support-dialog" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog" role="document">
<form class="modal-content" @submit.prevent="submit">
<div class="modal-header">
<h5 class="modal-title">{{ $t('support.title') }}</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
+ <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
</div>
<div class="modal-body">
- <form>
- <div class="mb-3">
- <label for="support-user" class="form-label">{{ $t('support.id') }}</label>
- <input id="support-user" type="text" class="form-control" :placeholder="$t('support.id-pl')" v-model="user" />
- <small class="text-muted">{{ $t('support.id-hint') }}</small>
- </div>
- <div class="mb-3">
- <label for="support-name" class="form-label">{{ $t('support.name') }}</label>
- <input id="support-name" type="text" class="form-control" :placeholder="$t('support.name-pl')" v-model="name" />
- </div>
- <div class="mb-3">
- <label for="support-email" class="form-label">{{ $t('support.email') }}</label>
- <input id="support-email" type="email" class="form-control" :placeholder="$t('support.email-pl')" v-model="email" required />
- </div>
- <div class="mb-3">
- <label for="support-summary" class="form-label">{{ $t('support.summary') }}</label>
- <input id="support-summary" type="text" class="form-control" :placeholder="$t('support.summary-pl')" v-model="summary" required />
- </div>
- <div>
- <label for="support-body" class="form-label">{{ $t('support.expl') }}</label>
- <textarea id="support-body" class="form-control" rows="5" v-model="body" required></textarea>
- </div>
- </form>
+ <div class="mb-3">
+ <label for="support-user" class="form-label">{{ $t('support.id') }}</label>
+ <input id="support-user" type="text" class="form-control" :placeholder="$t('support.id-pl')" v-model="user" />
+ <small class="text-muted">{{ $t('support.id-hint') }}</small>
+ </div>
+ <div class="mb-3">
+ <label for="support-name" class="form-label">{{ $t('support.name') }}</label>
+ <input id="support-name" type="text" class="form-control" :placeholder="$t('support.name-pl')" v-model="name" />
+ </div>
+ <div class="mb-3">
+ <label for="support-email" class="form-label">{{ $t('support.email') }}</label>
+ <input id="support-email" type="email" class="form-control" :placeholder="$t('support.email-pl')" v-model="email" required />
+ </div>
+ <div class="mb-3">
+ <label for="support-summary" class="form-label">{{ $t('support.summary') }}</label>
+ <input id="support-summary" type="text" class="form-control" :placeholder="$t('support.summary-pl')" v-model="summary" required />
+ </div>
+ <div>
+ <label for="support-body" class="form-label">{{ $t('support.expl') }}</label>
+ <textarea id="support-body" class="form-control" rows="5" v-model="body" required></textarea>
+ </div>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
- <button type="submit" class="btn btn-primary modal-action"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
+ <btn type="submit" class="btn-primary modal-action" icon="check">{{ $t('btn.submit') }}</btn>
</div>
</form>
</div>
</div>
</template>
<script>
import { Modal } from 'bootstrap'
export default {
data() {
return {
body: '',
email: '',
name: '',
summary: '',
user: ''
}
},
mounted() {
const dialog = this.$el
dialog.addEventListener('hide.bs.modal', () => {
this.lockForm(false)
if (this.cancelToken) {
this.cancelToken()
this.cancelToken = null
}
})
dialog.addEventListener('show.bs.modal', () => {
this.cancelToken = null
})
dialog.addEventListener('shown.bs.modal', () => {
$(dialog).find('input').first().focus()
})
this.dialog = new Modal(dialog)
},
methods: {
lockForm(lock) {
$(this.$el).find('input,textarea,.modal-action').prop('disabled', lock)
},
showDialog() {
this.dialog.show()
},
submit() {
this.lockForm(true)
let params = {
user: this.user,
name: this.name,
email: this.email,
summary: this.summary,
body: this.body
}
const CancelToken = axios.CancelToken
let args = {
cancelToken: new CancelToken((c) => {
this.cancelToken = c;
})
}
axios.post('/api/v4/support/request', params, args)
.then(response => {
this.summary = ''
this.body = ''
this.lockForm(false)
this.dialog.hide()
this.$toast.success(response.data.message)
})
.catch(error => {
this.lockForm(false)
})
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/ToastMessage.vue b/src/resources/vue/Widgets/ToastMessage.vue
index 1ef97324..6d25d120 100644
--- a/src/resources/vue/Widgets/ToastMessage.vue
+++ b/src/resources/vue/Widgets/ToastMessage.vue
@@ -1,61 +1,61 @@
<template>
<div :class="toastClassName()" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header" :class="className()">
<svg-icon icon="info-circle" v-if="data.type == 'info'"></svg-icon>
<svg-icon icon="check-circle" v-else-if="data.type == 'success'"></svg-icon>
<svg-icon icon="exclamation-circle" v-else-if="data.type == 'error'"></svg-icon>
<svg-icon icon="exclamation-circle" v-else-if="data.type == 'warning'"></svg-icon>
<svg-icon :icon="data.icon" v-else-if="data.type == 'custom' && data.icon"></svg-icon>
<strong>{{ data.title || $t('msg.' + data.type) }}</strong>
- <button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" :aria-label="$t('btn.close')"></button>
+ <btn class="btn-close btn-close-white" data-bs-dismiss="toast" :aria-label="$t('btn.close')"></btn>
</div>
<div v-if="data.body" v-html="data.body" class="toast-body"></div>
<div v-else class="toast-body">{{ data.msg }}</div>
</div>
</template>
<script>
import { Toast } from 'bootstrap'
export default {
props: {
data: { type: Object, default: () => {} }
},
mounted() {
this.$el.addEventListener('hidden.bs.toast', () => {
(this.$el).remove()
this.$destroy()
})
this.$el.addEventListener('shown.bs.toast', () => {
if (this.data.onShow) {
this.data.onShow(this.$el)
}
})
new Toast(this.$el, {
animation: true,
autohide: this.data.timeout > 0,
delay: this.data.timeout
}).show()
},
methods: {
className() {
switch (this.data.type) {
case 'error':
return 'text-danger'
case 'warning':
case 'info':
case 'success':
return 'text-' + this.data.type
case 'custom':
return this.data.titleClassName || ''
}
},
toastClassName() {
return 'toast hide toast-' + this.data.type
+ (this.data.className ? ' ' + this.data.className : '')
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/TransactionLog.vue b/src/resources/vue/Widgets/TransactionLog.vue
index e62e4f0b..8b1b5846 100644
--- a/src/resources/vue/Widgets/TransactionLog.vue
+++ b/src/resources/vue/Widgets/TransactionLog.vue
@@ -1,94 +1,92 @@
<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">
- <button class="btn btn-lg btn-link btn-action" title="Details" type="button"
- v-if="transaction.hasDetails"
- @click="loadTransaction(transaction.id)"
- >
- <svg-icon icon="info-circle"></svg-icon>
- </button>
+ <btn v-if="transaction.hasDetails" class="btn-lg btn-link btn-action" icon="info-circle"
+ :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)
.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>
diff --git a/src/resources/vue/Widgets/UserSearch.vue b/src/resources/vue/Widgets/UserSearch.vue
index 74c53c08..1bdca75a 100644
--- a/src/resources/vue/Widgets/UserSearch.vue
+++ b/src/resources/vue/Widgets/UserSearch.vue
@@ -1,76 +1,76 @@
<template>
<div id="search-box" class="card">
<div class="card-body">
<form @submit.prevent="searchUser" class="row justify-content-center">
<div class="input-group col-sm-8">
<input class="form-control" type="text" :placeholder="$t('user.search-pl')" v-model="search">
- <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> {{ $t('btn.search') }}</button>
+ <btn type="submit" class="btn-primary" icon="search">{{ $t('btn.search') }}</btn>
</div>
</form>
<table v-if="users.length" class="table table-sm table-hover mt-4">
<thead>
<tr>
<th scope="col">{{ $t('form.primary-email') }}</th>
<th scope="col">{{ $t('form.id') }}</th>
<th scope="col" class="d-none d-md-table-cell">{{ $t('form.created') }}</th>
<th scope="col" class="d-none d-md-table-cell">{{ $t('form.deleted') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :id="'user' + user.id" :key="user.id" :class="user.isDeleted ? 'text-secondary' : ''">
<td class="text-nowrap">
<svg-icon icon="user" :class="$root.statusClass(user)" :title="$root.statusText(user)"></svg-icon>
<router-link v-if="!user.isDeleted" :to="{ path: 'user/' + user.id }">{{ user.email }}</router-link>
<span v-if="user.isDeleted">{{ user.email }}</span>
</td>
<td>
<router-link v-if="!user.isDeleted" :to="{ path: 'user/' + user.id }">{{ user.id }}</router-link>
<span v-if="user.isDeleted">{{ user.id }}</span>
</td>
<td class="d-none d-md-table-cell">{{ toDate(user.created_at) }}</td>
<td class="d-none d-md-table-cell">{{ toDate(user.deleted_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
export default {
data() {
return {
search: '',
users: []
}
},
mounted() {
$('#search-box input', this.$el).focus()
},
methods: {
searchUser() {
this.users = []
axios.get('/api/v4/users', { params: { search: this.search } })
.then(response => {
if (response.data.count == 1 && !response.data.list[0].isDeleted) {
this.$router.push({ name: 'user', params: { user: response.data.list[0].id } })
return
}
if (response.data.message) {
this.$toast.info(response.data.message)
}
this.users = response.data.list
})
.catch(this.$root.errorHandler)
},
toDate(datetime) {
if (datetime) {
return datetime.split(' ')[0]
}
}
}
}
</script>
diff --git a/src/tests/Browser/DegradedAccountTest.php b/src/tests/Browser/DegradedAccountTest.php
index 8d449619..04387825 100644
--- a/src/tests/Browser/DegradedAccountTest.php
+++ b/src/tests/Browser/DegradedAccountTest.php
@@ -1,125 +1,125 @@
<?php
namespace Tests\Browser;
use App\User;
use Tests\Browser;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\DistlistList;
use Tests\Browser\Pages\DomainList;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\UserList;
use Tests\Browser\Pages\ResourceList;
use Tests\Browser\Pages\SharedFolderList;
use Tests\TestCaseDusk;
class DegradedAccountTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$john = $this->getTestUser('john@kolab.org');
if (!$john->isDegraded()) {
$john->status |= User::STATUS_DEGRADED;
User::where('id', $john->id)->update(['status' => $john->status]);
}
$this->clearBetaEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
if ($john->isDegraded()) {
$john->status ^= User::STATUS_DEGRADED;
User::where('id', $john->id)->update(['status' => $john->status]);
}
$this->clearBetaEntitlements();
parent::tearDown();
}
/**
* Test acting as an owner of a degraded account
*/
public function testDegradedAccountOwner(): void
{
// Add beta+distlist entitlements
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, ['beta-distlists', 'beta-resources', 'beta-shared-folders']);
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('#status-degraded p.alert', 'The account is degraded')
->assertSeeIn('#status-degraded p.alert', 'Please, make a payment');
// Goto /users and assert that the warning is also displayed there
$browser->visit(new UserList())
->assertSeeIn('#status-degraded p.alert', 'The account is degraded')
->assertSeeIn('#status-degraded p.alert', 'Please, make a payment')
->whenAvailable('@table', function (Browser $browser) {
$browser->waitFor('tbody tr')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-warning') // Jack
->assertText('tbody tr:nth-child(2) td:first-child svg.text-warning title', 'Degraded')
->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-warning') // John
->assertText('tbody tr:nth-child(3) td:first-child svg.text-warning title', 'Degraded');
})
- ->assertMissing('button.create-user');
+ ->assertMissing('button.user-new');
// Goto /domains and assert that the warning is also displayed there
$browser->visit(new DomainList())
->assertSeeIn('#status-degraded p.alert', 'The account is degraded')
->assertSeeIn('#status-degraded p.alert', 'Please, make a payment')
- ->assertMissing('button.create-domain');
+ ->assertMissing('button.domain-new');
// Goto /distlists and assert that the warning is also displayed there
$browser->visit(new DistlistList())
->assertSeeIn('#status-degraded p.alert', 'The account is degraded')
->assertSeeIn('#status-degraded p.alert', 'Please, make a payment')
- ->assertMissing('button.create-list');
+ ->assertMissing('button.distlist-new');
// Goto /resources and assert that the warning is also displayed there
$browser->visit(new ResourceList())
->assertSeeIn('#status-degraded p.alert', 'The account is degraded')
->assertSeeIn('#status-degraded p.alert', 'Please, make a payment')
- ->assertMissing('button.create-resource');
+ ->assertMissing('button.resource-new');
// Goto /shared-folders and assert that the warning is also displayed there
$browser->visit(new SharedFolderList())
->assertSeeIn('#status-degraded p.alert', 'The account is degraded')
->assertSeeIn('#status-degraded p.alert', 'Please, make a payment')
- ->assertMissing('button.create-resource');
+ ->assertMissing('button.shared-folder-new');
// Test that /rooms is not accessible
$browser->visit('/rooms')
->waitFor('#app > #error-page')
->assertSeeIn('#error-page .code', '403');
});
}
/**
* Test acting as non-owner of a degraded account
*/
public function testDegradedAccountUser(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('#status-degraded p.alert', 'The account is degraded')
->assertDontSeeIn('#status-degraded p.alert', 'Please, make a payment');
});
}
}
diff --git a/src/tests/Browser/DistlistTest.php b/src/tests/Browser/DistlistTest.php
index 2d316b2b..5928d594 100644
--- a/src/tests/Browser/DistlistTest.php
+++ b/src/tests/Browser/DistlistTest.php
@@ -1,314 +1,314 @@
<?php
namespace Tests\Browser;
use App\Group;
use App\Sku;
use Tests\Browser;
use Tests\Browser\Components\ListInput;
use Tests\Browser\Components\Status;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\DistlistInfo;
use Tests\Browser\Pages\DistlistList;
use Tests\TestCaseDusk;
class DistlistTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearBetaEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestGroup('group-test@kolab.org');
$this->clearBetaEntitlements();
parent::tearDown();
}
/**
* Test distlist info page (unauthenticated)
*/
public function testInfoUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/distlist/abc')->on(new Home());
});
}
/**
* Test distlist list page (unauthenticated)
*/
public function testListUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/distlists')->on(new Home());
});
}
/**
* Test distlist list page
*/
public function testList(): void
{
// Log on the user
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertMissing('@links .link-distlists');
});
// Test that Distribution lists page is not accessible without the 'beta-distlists' entitlement
$this->browse(function (Browser $browser) {
$browser->visit('/distlists')
->assertErrorPage(403);
});
// Create a single group, add beta+distlist entitlements
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, 'beta-distlists');
$group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']);
$group->assignToWallet($john->wallets->first());
// Test distribution lists page
$this->browse(function (Browser $browser) {
$browser->visit(new Dashboard())
->assertSeeIn('@links .link-distlists', 'Distribution lists')
->click('@links .link-distlists')
->on(new DistlistList())
->whenAvailable('@table', function (Browser $browser) {
$browser->waitFor('tbody tr')
->assertSeeIn('thead tr th:nth-child(1)', 'Name')
->assertSeeIn('thead tr th:nth-child(2)', 'Email')
->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Test Group')
->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-danger title', 'Not Ready')
->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2) a', 'group-test@kolab.org')
->assertMissing('tfoot');
});
});
}
/**
* Test distlist creation/editing/deleting
*
* @depends testList
*/
public function testCreateUpdateDelete(): void
{
// Test that the page is not available accessible without the 'beta-distlists' entitlement
$this->browse(function (Browser $browser) {
$browser->visit('/distlist/new')
->assertErrorPage(403);
});
// Add beta+distlist entitlements
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, 'beta-distlists');
$this->browse(function (Browser $browser) {
// Create a group
$browser->visit(new DistlistList())
- ->assertSeeIn('button.create-list', 'Create list')
- ->click('button.create-list')
+ ->assertSeeIn('button.distlist-new', 'Create list')
+ ->click('button.distlist-new')
->on(new DistlistInfo())
->assertSeeIn('#distlist-info .card-title', 'New distribution list')
->assertSeeIn('@nav #tab-general', 'General')
->assertMissing('@nav #tab-settings')
->with('@general', function (Browser $browser) {
// Assert form content
$browser->assertMissing('#status')
->assertFocused('#name')
->assertSeeIn('div.row:nth-child(1) label', 'Name')
->assertValue('div.row:nth-child(1) input[type=text]', '')
->assertSeeIn('div.row:nth-child(2) label', 'Email')
->assertValue('div.row:nth-child(2) input[type=text]', '')
->assertSeeIn('div.row:nth-child(3) label', 'Recipients')
->assertVisible('div.row:nth-child(3) .list-input')
->with(new ListInput('#members'), function (Browser $browser) {
$browser->assertListInputValue([])
->assertValue('@input', '');
})
->assertSeeIn('button[type=submit]', 'Submit');
})
// Test error conditions
->type('#name', str_repeat('A', 192))
->type('#email', 'group-test@kolabnow.com')
->click('@general button[type=submit]')
->waitFor('#members + .invalid-feedback')
->assertSeeIn('#email + .invalid-feedback', 'The specified domain is not available.')
->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.')
->assertSeeIn('#members + .invalid-feedback', 'At least one recipient is required.')
->assertFocused('#name')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
// Test successful group creation
->type('#name', 'Test Group')
->type('#email', 'group-test@kolab.org')
->with(new ListInput('#members'), function (Browser $browser) {
$browser->addListEntry('test1@gmail.com')
->addListEntry('test2@gmail.com');
})
->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'Distribution list created successfully.')
->on(new DistlistList())
->assertElementsCount('@table tbody tr', 1);
// Test group update
$browser->click('@table tr:nth-child(1) td:first-child a')
->on(new DistlistInfo())
->assertSeeIn('#distlist-info .card-title', 'Distribution list')
->with('@general', function (Browser $browser) {
// Assert form content
$browser->assertFocused('#name')
->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready')
->assertSeeIn('div.row:nth-child(2) label', 'Name')
->assertValue('div.row:nth-child(2) input[type=text]', 'Test Group')
->assertSeeIn('div.row:nth-child(3) label', 'Email')
->assertValue('div.row:nth-child(3) input[type=text]:disabled', 'group-test@kolab.org')
->assertSeeIn('div.row:nth-child(4) label', 'Recipients')
->assertVisible('div.row:nth-child(4) .list-input')
->with(new ListInput('#members'), function (Browser $browser) {
$browser->assertListInputValue(['test1@gmail.com', 'test2@gmail.com'])
->assertValue('@input', '');
})
->assertSeeIn('button[type=submit]', 'Submit');
})
// Test error handling
->with(new ListInput('#members'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('@general button[type=submit]')
->waitFor('#members + .invalid-feedback')
->assertSeeIn('#members + .invalid-feedback', 'The specified email address is invalid.')
->assertVisible('#members .input-group:nth-child(4) input.is-invalid')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
// Test successful update
->with(new ListInput('#members'), function (Browser $browser) {
$browser->removeListEntry(3)->removeListEntry(2);
})
->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'Distribution list updated successfully.')
->assertMissing('.invalid-feedback')
->on(new DistlistList())
->assertElementsCount('@table tbody tr', 1);
$group = Group::where('email', 'group-test@kolab.org')->first();
$this->assertSame(['test1@gmail.com'], $group->members);
// Test group deletion
$browser->click('@table tr:nth-child(1) td:first-child a')
->on(new DistlistInfo())
->assertSeeIn('button.button-delete', 'Delete list')
->click('button.button-delete')
->assertToast(Toast::TYPE_SUCCESS, 'Distribution list deleted successfully.')
->on(new DistlistList())
->assertElementsCount('@table tbody tr', 0)
->assertVisible('@table tfoot');
$this->assertNull(Group::where('email', 'group-test@kolab.org')->first());
});
}
/**
* Test distribution list status
*
* @depends testList
*/
public function testStatus(): void
{
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, 'beta-distlists');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($john->wallets->first());
$group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE;
$group->save();
$this->assertFalse($group->isLdapReady());
$this->browse(function ($browser) use ($group) {
// Test auto-refresh
$browser->visit('/distlist/' . $group->id)
->on(new DistlistInfo())
->with(new Status(), function ($browser) {
$browser->assertSeeIn('@body', 'We are preparing the distribution list')
->assertProgress(83, 'Creating a distribution list...', 'pending')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-link')
->assertMissing('#status-verify');
});
$group->status |= Group::STATUS_LDAP_READY;
$group->save();
// Test Verify button
$browser->waitUntilMissing('@status', 10);
});
// TODO: Test all group statuses on the list
}
/**
* Test distribution list settings
*/
public function testSettings(): void
{
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, 'beta-distlists');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($john->wallets->first());
$group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE;
$group->save();
$this->browse(function ($browser) use ($group) {
// Test auto-refresh
$browser->visit('/distlist/' . $group->id)
->on(new DistlistInfo())
->assertSeeIn('@nav #tab-general', 'General')
->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
->with('@settings form', function (Browser $browser) {
// Assert form content
$browser->assertSeeIn('div.row:nth-child(1) label', 'Sender Access List')
->assertVisible('div.row:nth-child(1) .list-input')
->with(new ListInput('#sender-policy'), function (Browser $browser) {
$browser->assertListInputValue([])
->assertValue('@input', '');
})
->assertSeeIn('button[type=submit]', 'Submit');
})
// Test error handling
->with(new ListInput('#sender-policy'), function (Browser $browser) {
$browser->addListEntry('test.com');
})
->click('@settings button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'Distribution list settings updated successfully.')
->assertMissing('.invalid-feedback')
->refresh()
->on(new DistlistInfo())
->click('@nav #tab-settings')
->with('@settings form', function (Browser $browser) {
$browser->with(new ListInput('#sender-policy'), function (Browser $browser) {
$browser->assertListInputValue(['test.com'])
->assertValue('@input', '');
});
});
});
}
}
diff --git a/src/tests/Browser/ResourceTest.php b/src/tests/Browser/ResourceTest.php
index 0d85e2b2..fba04676 100644
--- a/src/tests/Browser/ResourceTest.php
+++ b/src/tests/Browser/ResourceTest.php
@@ -1,301 +1,301 @@
<?php
namespace Tests\Browser;
use App\Resource;
use Tests\Browser;
use Tests\Browser\Components\Status;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\ResourceInfo;
use Tests\Browser\Pages\ResourceList;
use Tests\TestCaseDusk;
class ResourceTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
Resource::whereNotIn('email', ['resource-test1@kolab.org', 'resource-test2@kolab.org'])->delete();
$this->clearBetaEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
Resource::whereNotIn('email', ['resource-test1@kolab.org', 'resource-test2@kolab.org'])->delete();
$this->clearBetaEntitlements();
parent::tearDown();
}
/**
* Test resource info page (unauthenticated)
*/
public function testInfoUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/resource/abc')->on(new Home());
});
}
/**
* Test resource list page (unauthenticated)
*/
public function testListUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/resources')->on(new Home());
});
}
/**
* Test resources list page
*/
public function testList(): void
{
// Log on the user
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertMissing('@links .link-resources');
});
// Test that Resources lists page is not accessible without the 'beta-resources' entitlement
$this->browse(function (Browser $browser) {
$browser->visit('/resources')
->assertErrorPage(403);
});
// Add beta+beta-resources entitlements
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, 'beta-resources');
// Make sure the first resource is active
$resource = $this->getTestResource('resource-test1@kolab.org');
$resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE
| Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY;
$resource->save();
// Test resources lists page
$this->browse(function (Browser $browser) {
$browser->visit(new Dashboard())
->assertSeeIn('@links .link-resources', 'Resources')
->click('@links .link-resources')
->on(new ResourceList())
->whenAvailable('@table', function (Browser $browser) {
$browser->waitFor('tbody tr')
->assertSeeIn('thead tr th:nth-child(1)', 'Name')
->assertSeeIn('thead tr th:nth-child(2)', 'Email Address')
->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Conference Room #1')
->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-success title', 'Active')
->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2) a', 'kolab.org')
->assertMissing('tfoot');
});
});
}
/**
* Test resource creation/editing/deleting
*
* @depends testList
*/
public function testCreateUpdateDelete(): void
{
// Test that the page is not available accessible without the 'beta-resources' entitlement
$this->browse(function (Browser $browser) {
$browser->visit('/resource/new')
->assertErrorPage(403);
});
// Add beta+beta-resource entitlements
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, 'beta-resources');
$this->browse(function (Browser $browser) {
// Create a resource
$browser->visit(new ResourceList())
- ->assertSeeIn('button.create-resource', 'Create resource')
- ->click('button.create-resource')
+ ->assertSeeIn('button.resource-new', 'Create resource')
+ ->click('button.resource-new')
->on(new ResourceInfo())
->assertSeeIn('#resource-info .card-title', 'New resource')
->assertSeeIn('@nav #tab-general', 'General')
->assertMissing('@nav #tab-settings')
->with('@general', function (Browser $browser) {
// Assert form content
$browser->assertMissing('#status')
->assertFocused('#name')
->assertSeeIn('div.row:nth-child(1) label', 'Name')
->assertValue('div.row:nth-child(1) input[type=text]', '')
->assertSeeIn('div.row:nth-child(2) label', 'Domain')
->assertSelectHasOptions('div.row:nth-child(2) select', ['kolab.org'])
->assertValue('div.row:nth-child(2) select', 'kolab.org')
->assertSeeIn('button[type=submit]', 'Submit');
})
// Test error conditions
->type('#name', str_repeat('A', 192))
->click('@general button[type=submit]')
->waitFor('#name + .invalid-feedback')
->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.')
->assertFocused('#name')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
// Test successful resource creation
->type('#name', 'Test Resource')
->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'Resource created successfully.')
->on(new ResourceList())
->assertElementsCount('@table tbody tr', 3);
// Test resource update
$browser->click('@table tr:nth-child(3) td:first-child a')
->on(new ResourceInfo())
->assertSeeIn('#resource-info .card-title', 'Resource')
->with('@general', function (Browser $browser) {
// Assert form content
$browser->assertFocused('#name')
->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready')
->assertSeeIn('div.row:nth-child(2) label', 'Name')
->assertValue('div.row:nth-child(2) input[type=text]', 'Test Resource')
->assertSeeIn('div.row:nth-child(3) label', 'Email')
->assertAttributeRegExp(
'div.row:nth-child(3) input[type=text]:disabled',
'value',
'/^resource-[0-9]+@kolab\.org$/'
)
->assertSeeIn('button[type=submit]', 'Submit');
})
// Test error handling
->type('#name', str_repeat('A', 192))
->click('@general button[type=submit]')
->waitFor('#name + .invalid-feedback')
->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.')
->assertVisible('#name.is-invalid')
->assertFocused('#name')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
// Test successful update
->type('#name', 'Test Resource Update')
->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'Resource updated successfully.')
->on(new ResourceList())
->assertElementsCount('@table tbody tr', 3)
->assertSeeIn('@table tr:nth-child(3) td:first-child a', 'Test Resource Update');
$this->assertSame(1, Resource::where('name', 'Test Resource Update')->count());
// Test resource deletion
$browser->click('@table tr:nth-child(3) td:first-child a')
->on(new ResourceInfo())
->assertSeeIn('button.button-delete', 'Delete resource')
->click('button.button-delete')
->assertToast(Toast::TYPE_SUCCESS, 'Resource deleted successfully.')
->on(new ResourceList())
->assertElementsCount('@table tbody tr', 2);
$this->assertNull(Resource::where('name', 'Test Resource Update')->first());
});
}
/**
* Test resource status
*
* @depends testList
*/
public function testStatus(): void
{
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, 'beta-resources');
$resource = $this->getTestResource('resource-test2@kolab.org');
$resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE | Resource::STATUS_LDAP_READY;
$resource->created_at = \now();
$resource->save();
$this->assertFalse($resource->isImapReady());
$this->browse(function ($browser) use ($resource) {
// Test auto-refresh
$browser->visit('/resource/' . $resource->id)
->on(new ResourceInfo())
->with(new Status(), function ($browser) {
$browser->assertSeeIn('@body', 'We are preparing the resource')
->assertProgress(85, 'Creating a shared folder...', 'pending')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-link')
->assertMissing('#status-verify');
});
$resource->status |= Resource::STATUS_IMAP_READY;
$resource->save();
// Test Verify button
$browser->waitUntilMissing('@status', 10);
});
// TODO: Test all resource statuses on the list
}
/**
* Test resource settings
*/
public function testSettings(): void
{
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, 'beta-resources');
$resource = $this->getTestResource('resource-test2@kolab.org');
$resource->setSetting('invitation_policy', null);
$this->browse(function ($browser) use ($resource) {
// Test auto-refresh
$browser->visit('/resource/' . $resource->id)
->on(new ResourceInfo())
->assertSeeIn('@nav #tab-general', 'General')
->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
->with('@settings form', function (Browser $browser) {
// Assert form content
$browser->assertSeeIn('div.row:nth-child(1) label', 'Invitation policy')
->assertSelectHasOptions('div.row:nth-child(1) select', ['accept', 'manual', 'reject'])
->assertValue('div.row:nth-child(1) select', 'accept')
->assertMissing('div.row:nth-child(1) input')
->assertSeeIn('div.row:nth-child(1) small', 'manual acceptance')
->assertSeeIn('button[type=submit]', 'Submit');
})
// Test error handling
->select('#invitation_policy', 'manual')
->waitFor('#invitation_policy + input')
->type('#invitation_policy + input', 'kolab.org')
->click('@settings button[type=submit]')
->waitFor('#invitation_policy + input + .invalid-feedback')
->assertSeeIn(
'#invitation_policy + input + .invalid-feedback',
'The specified email address is invalid.'
)
->assertVisible('#invitation_policy + input.is-invalid')
->assertFocused('#invitation_policy + input')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('#invitation_policy + input', 'jack@kolab.org')
->click('@settings button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'Resource settings updated successfully.')
->assertMissing('.invalid-feedback')
->refresh()
->on(new ResourceInfo())
->click('@nav #tab-settings')
->with('@settings form', function (Browser $browser) {
$browser->assertValue('div.row:nth-child(1) select', 'manual')
->assertVisible('div.row:nth-child(1) input')
->assertValue('div.row:nth-child(1) input', 'jack@kolab.org');
});
});
}
}
diff --git a/src/tests/Browser/SharedFolderTest.php b/src/tests/Browser/SharedFolderTest.php
index aea65f7c..31c5bf39 100644
--- a/src/tests/Browser/SharedFolderTest.php
+++ b/src/tests/Browser/SharedFolderTest.php
@@ -1,333 +1,333 @@
<?php
namespace Tests\Browser;
use App\SharedFolder;
use Tests\Browser;
use Tests\Browser\Components\AclInput;
use Tests\Browser\Components\Status;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\SharedFolderInfo;
use Tests\Browser\Pages\SharedFolderList;
use Tests\TestCaseDusk;
class SharedFolderTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
SharedFolder::whereNotIn('email', ['folder-event@kolab.org', 'folder-contact@kolab.org'])->delete();
$this->clearBetaEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
SharedFolder::whereNotIn('email', ['folder-event@kolab.org', 'folder-contact@kolab.org'])->delete();
$this->clearBetaEntitlements();
parent::tearDown();
}
/**
* Test shared folder info page (unauthenticated)
*/
public function testInfoUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/shared-folder/abc')->on(new Home());
});
}
/**
* Test shared folder list page (unauthenticated)
*/
public function testListUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/shared-folders')->on(new Home());
});
}
/**
* Test shared folders list page
*/
public function testList(): void
{
// Log on the user
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertMissing('@links .link-shared-folders');
});
// Test that shared folders lists page is not accessible without the 'beta-shared-folders' entitlement
$this->browse(function (Browser $browser) {
$browser->visit('/shared-folders')
->assertErrorPage(403);
});
// Add beta+beta-shared-folders entitlements
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, 'beta-shared-folders');
// Make sure the first folder is active
$folder = $this->getTestSharedFolder('folder-event@kolab.org');
$folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
| SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
$folder->save();
// Test shared folders lists page
$this->browse(function (Browser $browser) {
$browser->visit(new Dashboard())
->assertSeeIn('@links .link-shared-folders', 'Shared folders')
->click('@links .link-shared-folders')
->on(new SharedFolderList())
->whenAvailable('@table', function (Browser $browser) {
$browser->waitFor('tbody tr')
->assertSeeIn('thead tr th:nth-child(1)', 'Name')
->assertSeeIn('thead tr th:nth-child(2)', 'Type')
->assertSeeIn('thead tr th:nth-child(3)', 'Email Address')
->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Calendar')
->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2)', 'Calendar')
->assertSeeIn('tbody tr:nth-child(1) td:nth-child(3) a', 'folder-event@kolab.org')
->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-success title', 'Active')
->assertSeeIn('tbody tr:nth-child(2) td:nth-child(1) a', 'Contacts')
->assertSeeIn('tbody tr:nth-child(2) td:nth-child(2)', 'Address Book')
->assertSeeIn('tbody tr:nth-child(2) td:nth-child(3) a', 'folder-contact@kolab.org')
->assertMissing('tfoot');
});
});
}
/**
* Test shared folder creation/editing/deleting
*
* @depends testList
*/
public function testCreateUpdateDelete(): void
{
// Test that the page is not available accessible without the 'beta-shared-folders' entitlement
$this->browse(function (Browser $browser) {
$browser->visit('/shared-folder/new')
->assertErrorPage(403);
});
// Add beta+beta-shared-folders entitlements
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, 'beta-shared-folders');
$this->browse(function (Browser $browser) {
// Create a folder
$browser->visit(new SharedFolderList())
- ->assertSeeIn('button.create-folder', 'Create folder')
- ->click('button.create-folder')
+ ->assertSeeIn('button.shared-folder-new', 'Create folder')
+ ->click('button.shared-folder-new')
->on(new SharedFolderInfo())
->assertSeeIn('#folder-info .card-title', 'New shared folder')
->assertSeeIn('@nav #tab-general', 'General')
->assertMissing('@nav #tab-settings')
->with('@general', function (Browser $browser) {
// Assert form content
$browser->assertMissing('#status')
->assertFocused('#name')
->assertSeeIn('div.row:nth-child(1) label', 'Name')
->assertValue('div.row:nth-child(1) input[type=text]', '')
->assertSeeIn('div.row:nth-child(2) label', 'Type')
->assertSelectHasOptions(
'div.row:nth-child(2) select',
['mail', 'event', 'task', 'contact', 'note', 'file']
)
->assertValue('div.row:nth-child(2) select', 'mail')
->assertSeeIn('div.row:nth-child(3) label', 'Domain')
->assertSelectHasOptions('div.row:nth-child(3) select', ['kolab.org'])
->assertValue('div.row:nth-child(3) select', 'kolab.org')
->assertSeeIn('button[type=submit]', 'Submit');
})
// Test error conditions
->type('#name', str_repeat('A', 192))
->click('@general button[type=submit]')
->waitFor('#name + .invalid-feedback')
->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.')
->assertFocused('#name')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
// Test successful folder creation
->type('#name', 'Test Folder')
->select('#type', 'event')
->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'Shared folder created successfully.')
->on(new SharedFolderList())
->assertElementsCount('@table tbody tr', 3);
// Test folder update
$browser->click('@table tr:nth-child(3) td:first-child a')
->on(new SharedFolderInfo())
->assertSeeIn('#folder-info .card-title', 'Shared folder')
->with('@general', function (Browser $browser) {
// Assert form content
$browser->assertFocused('#name')
->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready')
->assertSeeIn('div.row:nth-child(2) label', 'Name')
->assertValue('div.row:nth-child(2) input[type=text]', 'Test Folder')
->assertSeeIn('div.row:nth-child(3) label', 'Type')
->assertSelected('div.row:nth-child(3) select:disabled', 'event')
->assertSeeIn('div.row:nth-child(4) label', 'Email Address')
->assertAttributeRegExp(
'div.row:nth-child(4) input[type=text]:disabled',
'value',
'/^event-[0-9]+@kolab\.org$/'
)
->assertSeeIn('button[type=submit]', 'Submit');
})
// Test error handling
->type('#name', str_repeat('A', 192))
->click('@general button[type=submit]')
->waitFor('#name + .invalid-feedback')
->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.')
->assertVisible('#name.is-invalid')
->assertFocused('#name')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
// Test successful update
->type('#name', 'Test Folder Update')
->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'Shared folder updated successfully.')
->on(new SharedFolderList())
->assertElementsCount('@table tbody tr', 3)
->assertSeeIn('@table tr:nth-child(3) td:first-child a', 'Test Folder Update');
$this->assertSame(1, SharedFolder::where('name', 'Test Folder Update')->count());
// Test folder deletion
$browser->click('@table tr:nth-child(3) td:first-child a')
->on(new SharedFolderInfo())
->assertSeeIn('button.button-delete', 'Delete folder')
->click('button.button-delete')
->assertToast(Toast::TYPE_SUCCESS, 'Shared folder deleted successfully.')
->on(new SharedFolderList())
->assertElementsCount('@table tbody tr', 2);
$this->assertNull(SharedFolder::where('name', 'Test Folder Update')->first());
});
}
/**
* Test shared folder status
*
* @depends testList
*/
public function testStatus(): void
{
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, 'beta-shared-folders');
$folder = $this->getTestSharedFolder('folder-event@kolab.org');
$folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY;
$folder->created_at = \now();
$folder->save();
$this->assertFalse($folder->isImapReady());
$this->browse(function ($browser) use ($folder) {
// Test auto-refresh
$browser->visit('/shared-folder/' . $folder->id)
->on(new SharedFolderInfo())
->with(new Status(), function ($browser) {
$browser->assertSeeIn('@body', 'We are preparing the shared folder')
->assertProgress(85, 'Creating a shared folder...', 'pending')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-link')
->assertMissing('#status-verify');
});
$folder->status |= SharedFolder::STATUS_IMAP_READY;
$folder->save();
// Test Verify button
$browser->waitUntilMissing('@status', 10);
});
// TODO: Test all shared folder statuses on the list
}
/**
* Test shared folder settings
*/
public function testSettings(): void
{
$john = $this->getTestUser('john@kolab.org');
$this->addBetaEntitlement($john, 'beta-shared-folders');
$folder = $this->getTestSharedFolder('folder-event@kolab.org');
$folder->setSetting('acl', null);
$this->browse(function ($browser) use ($folder) {
$aclInput = new AclInput('@settings #acl');
// Test auto-refresh
$browser->visit('/shared-folder/' . $folder->id)
->on(new SharedFolderInfo())
->assertSeeIn('@nav #tab-general', 'General')
->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
->with('@settings form', function (Browser $browser) {
// Assert form content
$browser->assertSeeIn('div.row:nth-child(1) label', 'Access rights')
->assertSeeIn('div.row:nth-child(1) #acl-hint', 'permissions')
->assertSeeIn('button[type=submit]', 'Submit');
})
// Test the AclInput widget
->with($aclInput, function (Browser $browser) {
$browser->assertAclValue([])
->addAclEntry('anyone, read-only')
->addAclEntry('test, read-write')
->addAclEntry('john@kolab.org, full')
->assertAclValue([
'anyone, read-only',
'test, read-write',
'john@kolab.org, full',
]);
})
// Test error handling
->click('@settings button[type=submit]')
->with($aclInput, function (Browser $browser) {
$browser->assertFormError(2, 'The specified email address is invalid.');
})
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
// Test successful update
->with($aclInput, function (Browser $browser) {
$browser->removeAclEntry(2)
->assertAclValue([
'anyone, read-only',
'john@kolab.org, full',
])
->updateAclEntry(2, 'jack@kolab.org, read-write')
->assertAclValue([
'anyone, read-only',
'jack@kolab.org, read-write',
]);
})
->click('@settings button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'Shared folder settings updated successfully.')
->assertMissing('.invalid-feedback')
// Refresh the page and check if everything was saved
->refresh()
->on(new SharedFolderInfo())
->click('@nav #tab-settings')
->with($aclInput, function (Browser $browser) {
$browser->assertAclValue([
'anyone, read-only',
'jack@kolab.org, read-write',
]);
});
});
}
}
diff --git a/src/tests/Browser/UserProfileTest.php b/src/tests/Browser/UserProfileTest.php
index 48467f7a..c7123cbc 100644
--- a/src/tests/Browser/UserProfileTest.php
+++ b/src/tests/Browser/UserProfileTest.php
@@ -1,228 +1,228 @@
<?php
namespace Tests\Browser;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\UserProfile;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserProfileTest extends TestCaseDusk
{
private $profile = [
'first_name' => 'John',
'last_name' => 'Doe',
'currency' => 'USD',
'country' => 'US',
'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005",
'external_email' => 'john.doe.external@gmail.com',
'phone' => '+1 509-248-1111',
'organization' => 'Kolab Developers',
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
User::where('email', 'john@kolab.org')->first()->setSettings($this->profile);
$this->deleteTestUser('profile-delete@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
User::where('email', 'john@kolab.org')->first()->setSettings($this->profile);
$this->deleteTestUser('profile-delete@kolabnow.com');
parent::tearDown();
}
/**
* Test profile page (unauthenticated)
*/
public function testProfileUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/profile')->on(new Home());
});
}
/**
* Test profile page
*/
public function testProfile(): void
{
$user = $this->getTestUser('john@kolab.org');
$user->setSetting('password_policy', 'min:10,upper,digit');
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-profile', 'Your profile')
->click('@links .link-profile')
->on(new UserProfile())
- ->assertSeeIn('#user-profile .button-delete', 'Delete account')
+ ->assertSeeIn('#user-profile .profile-delete', 'Delete account')
->whenAvailable('@form', function (Browser $browser) {
$user = User::where('email', 'john@kolab.org')->first();
// Assert form content
$browser->assertFocused('div.row:nth-child(2) input')
->assertSeeIn('div.row:nth-child(1) label', 'Customer No.')
->assertSeeIn('div.row:nth-child(1) .form-control-plaintext', $user->id)
->assertSeeIn('div.row:nth-child(2) label', 'First Name')
->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name'])
->assertSeeIn('div.row:nth-child(3) label', 'Last Name')
->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name'])
->assertSeeIn('div.row:nth-child(4) label', 'Organization')
->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization'])
->assertSeeIn('div.row:nth-child(5) label', 'Phone')
->assertValue('div.row:nth-child(5) input[type=text]', $this->profile['phone'])
->assertSeeIn('div.row:nth-child(6) label', 'External Email')
->assertValue('div.row:nth-child(6) input[type=text]', $this->profile['external_email'])
->assertSeeIn('div.row:nth-child(7) label', 'Address')
->assertValue('div.row:nth-child(7) textarea', $this->profile['billing_address'])
->assertSeeIn('div.row:nth-child(8) label', 'Country')
->assertValue('div.row:nth-child(8) select', $this->profile['country'])
->assertSeeIn('div.row:nth-child(9) label', 'Password')
->assertValue('div.row:nth-child(9) input#password', '')
->assertValue('div.row:nth-child(9) input#password_confirmation', '')
->assertAttribute('#password', 'placeholder', 'Password')
->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password')
->whenAvailable('#password_policy', function (Browser $browser) {
$browser->assertElementsCount('li', 3)
->assertMissing('li:nth-child(1) svg.text-success')
->assertSeeIn('li:nth-child(1) small', "Minimum password length: 10 characters")
->assertMissing('li:nth-child(2) svg.text-success')
->assertSeeIn('li:nth-child(2) small', "Password contains an upper-case character")
->assertMissing('li:nth-child(3) svg.text-success')
->assertSeeIn('li:nth-child(3) small', "Password contains a digit");
})
->assertSeeIn('button[type=submit]', 'Submit');
// Test password policy checking
$browser->type('#password', '1A')
->whenAvailable('#password_policy', function (Browser $browser) {
$browser->waitFor('li:nth-child(2) svg.text-success')
->waitFor('li:nth-child(3) svg.text-success')
->assertMissing('li:nth-child(1) svg.text-success');
})
->vueClear('#password');
// Test form error handling
$browser->type('#phone', 'aaaaaa')
->type('#external_email', 'bbbbb')
->click('button[type=submit]')
->waitFor('#phone + .invalid-feedback')
->assertSeeIn('#phone + .invalid-feedback', 'The phone format is invalid.')
->assertSeeIn(
'#external_email + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertFocused('#phone')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->clearToasts();
// Clear all fields and submit
// FIXME: Should any of these fields be required?
$browser->vueClear('#first_name')
->vueClear('#last_name')
->vueClear('#organization')
->vueClear('#phone')
->vueClear('#external_email')
->vueClear('#billing_address')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
// On success we're redirected to Dashboard
->on(new Dashboard());
});
}
/**
* Test profile of non-controller user
*/
public function testProfileNonController(): void
{
$user = $this->getTestUser('john@kolab.org');
$user->setSetting('password_policy', 'min:10,upper,digit');
// Test acting as non-controller
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->visit(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-profile', 'Your profile')
->click('@links .link-profile')
->on(new UserProfile())
- ->assertMissing('#user-profile .button-delete')
+ ->assertMissing('#user-profile .profile-delete')
->whenAvailable('@form', function (Browser $browser) {
// TODO: decide on what fields the non-controller user should be able
// to see/change
})
// Check that the account policy is used
->whenAvailable('#password_policy', function (Browser $browser) {
$browser->assertElementsCount('li', 3)
->assertMissing('li:nth-child(1) svg.text-success')
->assertSeeIn('li:nth-child(1) small', "Minimum password length: 10 characters")
->assertMissing('li:nth-child(2) svg.text-success')
->assertSeeIn('li:nth-child(2) small', "Password contains an upper-case character")
->assertMissing('li:nth-child(3) svg.text-success')
->assertSeeIn('li:nth-child(3) small', "Password contains a digit");
});
// Test that /profile/delete page is not accessible
$browser->visit('/profile/delete')
->assertErrorPage(403);
});
}
/**
* Test profile delete page
*/
public function testProfileDelete(): void
{
$user = $this->getTestUser('profile-delete@kolabnow.com', ['password' => 'simple123']);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('profile-delete@kolabnow.com', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-profile', 'Your profile')
->click('@links .link-profile')
->on(new UserProfile())
- ->click('#user-profile .button-delete')
+ ->click('#user-profile .profile-delete')
->waitForLocation('/profile/delete')
->assertSeeIn('#user-delete .card-title', 'Delete this account?')
->assertSeeIn('#user-delete .button-cancel', 'Cancel')
->assertSeeIn('#user-delete .card-text', 'This operation is irreversible')
->assertFocused('#user-delete .button-cancel')
->click('#user-delete .button-cancel')
->waitForLocation('/profile')
->on(new UserProfile());
// Test deleting the user
- $browser->click('#user-profile .button-delete')
+ $browser->click('#user-profile .profile-delete')
->waitForLocation('/profile/delete')
->click('#user-delete .button-delete')
->waitForLocation('/login')
->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.');
$this->assertTrue($user->fresh()->trashed());
});
}
// TODO: Test that Ned (John's "delegatee") can delete himself
// TODO: Test that Ned (John's "delegatee") can/can't delete John ?
}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
index a1392a5a..09cf763c 100644
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -1,893 +1,893 @@
<?php
namespace Tests\Browser;
use App\Discount;
use App\Entitlement;
use App\Sku;
use App\User;
use App\UserAlias;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\ListInput;
use Tests\Browser\Components\QuotaInput;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\UserInfo;
use Tests\Browser\Pages\UserList;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UsersTest extends TestCaseDusk
{
private $profile = [
'first_name' => 'John',
'last_name' => 'Doe',
'organization' => 'Kolab Developers',
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
$activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete();
Entitlement::where('cost', '>=', 5000)->delete();
Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->currency = 'CHF';
$wallet->save();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
$activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete();
Entitlement::where('cost', '>=', 5000)->delete();
Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user account editing page (not profile page)
*/
public function testInfo(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$john->verificationcodes()->delete();
$jack->verificationcodes()->delete();
$john->setSetting('password_policy', 'min:10,upper,digit');
// Test that the page requires authentication
$browser->visit('/user/' . $john->id)
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', false)
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@general', function (Browser $browser) {
// Assert form content
$browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) #status', 'Active')
->assertFocused('div.row:nth-child(2) input')
->assertSeeIn('div.row:nth-child(2) label', 'First Name')
->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name'])
->assertSeeIn('div.row:nth-child(3) label', 'Last Name')
->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name'])
->assertSeeIn('div.row:nth-child(4) label', 'Organization')
->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization'])
->assertSeeIn('div.row:nth-child(5) label', 'Email')
->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org')
->assertDisabled('div.row:nth-child(5) input[type=text]')
->assertSeeIn('div.row:nth-child(6) label', 'Email Aliases')
->assertVisible('div.row:nth-child(6) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['john.doe@kolab.org'])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(7) label', 'Password')
->assertValue('div.row:nth-child(7) input#password', '')
->assertValue('div.row:nth-child(7) input#password_confirmation', '')
->assertAttribute('#password', 'placeholder', 'Password')
->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password')
->assertMissing('div.row:nth-child(7) .btn-group')
->assertMissing('div.row:nth-child(7) #password-link')
->assertSeeIn('button[type=submit]', 'Submit')
// Clear some fields and submit
->vueClear('#first_name')
->vueClear('#last_name')
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@general', function (Browser $browser) {
// Test error handling (password)
$browser->type('#password', 'aaaaaA')
->vueClear('#password_confirmation')
->whenAvailable('#password_policy', function (Browser $browser) {
$browser->assertElementsCount('li', 3)
->assertMissing('li:nth-child(1) svg.text-success')
->assertSeeIn('li:nth-child(1) small', "Minimum password length: 10 characters")
->waitFor('li:nth-child(2) svg.text-success')
->assertSeeIn('li:nth-child(2) small', "Password contains an upper-case character")
->assertMissing('li:nth-child(3) svg.text-success')
->assertSeeIn('li:nth-child(3) small', "Password contains a digit");
})
->click('button[type=submit]')
->waitFor('#password_confirmation + .invalid-feedback')
->assertSeeIn(
'#password_confirmation + .invalid-feedback',
'The password confirmation does not match.'
)
->assertFocused('#password')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
// TODO: Test password change
// Test form error handling (aliases)
$browser->vueClear('#password')
->vueClear('#password_confirmation')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->scrollTo('button[type=submit]')->pause(500)
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(2, 'The specified alias is invalid.', false);
});
// Test adding aliases
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(2)
->addListEntry('john.test@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$alias = $john->aliases()->where('alias', 'john.test@kolab.org')->first();
$this->assertTrue(!empty($alias));
// Test subscriptions
$browser->with('@general', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(8) label', 'Subscriptions')
->assertVisible('@skus.row:nth-child(8)')
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox')
->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 CHF/month')
->assertChecked('tbody tr:nth-child(1) td.selection input')
->assertDisabled('tbody tr:nth-child(1) td.selection input')
->assertTip(
'tbody tr:nth-child(1) td.buttons button',
'Just a mailbox'
)
// Storage SKU
->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota')
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(2) td.selection input')
->assertDisabled('tbody tr:nth-child(2) td.selection input')
->assertTip(
'tbody tr:nth-child(2) td.buttons button',
'Some wiggle room'
)
->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) {
$browser->assertQuotaValue(5)->setQuotaValue(6);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features')
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,90 CHF/month')
->assertChecked('tbody tr:nth-child(3) td.selection input')
->assertEnabled('tbody tr:nth-child(3) td.selection input')
->assertTip(
'tbody tr:nth-child(3) td.buttons button',
'Groupware functions like Calendar, Tasks, Notes, etc.'
)
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync')
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(4) td.selection input')
->assertEnabled('tbody tr:nth-child(4) td.selection input')
->assertTip(
'tbody tr:nth-child(4) td.buttons button',
'Mobile synchronization'
)
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication')
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(5) td.selection input')
->assertEnabled('tbody tr:nth-child(5) td.selection input')
->assertTip(
'tbody tr:nth-child(5) td.buttons button',
'Two factor authentication for webmail and administration panel'
)
// Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(6) td.selection input')
->assertEnabled('tbody tr:nth-child(6) td.selection input')
->assertTip(
'tbody tr:nth-child(6) td.buttons button',
'Video conferencing tool'
)
->click('tbody tr:nth-child(4) td.selection input');
})
->assertMissing('@skus table + .hint')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$expected = ['activesync', 'groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage', 'storage'];
$this->assertEntitlements($john->fresh(), $expected);
// Test subscriptions interaction
$browser->with('@general', function (Browser $browser) {
$browser->with('@skus', function ($browser) {
// Uncheck 'groupware', expect activesync unchecked
$browser->click('#sku-input-groupware')
->assertNotChecked('#sku-input-groupware')
->assertNotChecked('#sku-input-activesync')
->assertEnabled('#sku-input-activesync')
->assertNotReadonly('#sku-input-activesync')
// Check 'activesync', expect an alert
->click('#sku-input-activesync')
->assertDialogOpened('Activesync requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-activesync')
// Check 'meet', expect an alert
->click('#sku-input-meet')
->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-meet')
// Check '2FA', expect 'activesync' unchecked and readonly
->click('#sku-input-2fa')
->assertChecked('#sku-input-2fa')
->assertNotChecked('#sku-input-activesync')
->assertReadonly('#sku-input-activesync')
// Uncheck '2FA'
->click('#sku-input-2fa')
->assertNotChecked('#sku-input-2fa')
->assertNotReadonly('#sku-input-activesync');
});
});
// Test password reset link delete and create
$code = new \App\VerificationCode(['mode' => 'password-reset']);
$jack->verificationcodes()->save($code);
$browser->visit('/user/' . $jack->id)
->on(new UserInfo())
->with('@general', function (Browser $browser) use ($jack, $john, $code) {
// Test displaying an existing password reset link
$link = Browser::$baseUrl . '/password-reset/' . $code->short_code . '-' . $code->code;
$browser->assertSeeIn('div.row:nth-child(7) label', 'Password')
->assertMissing('#password')
->assertMissing('#password_confirmation')
->assertMissing('#pass-mode-link:checked')
->assertMissing('#pass-mode-input:checked')
->assertSeeIn('#password-link code', $link)
->assertVisible('#password-link button.text-danger')
->assertVisible('#password-link button:not(.text-danger)')
->assertAttribute('#password-link button:not(.text-danger)', 'title', 'Copy')
->assertAttribute('#password-link button.text-danger', 'title', 'Delete');
// Test deleting an existing password reset link
$browser->click('#password-link button.text-danger')
->assertToast(Toast::TYPE_SUCCESS, 'Password reset code deleted successfully.')
->assertMissing('#password-link')
->assertMissing('#pass-mode-link:checked')
->assertMissing('#pass-mode-input:checked')
->assertMissing('#password');
$this->assertSame(0, $jack->verificationcodes()->count());
// Test creating a password reset link
$link = preg_replace('|/[a-z0-9A-Z-]+$|', '', $link) . '/';
$browser->click('#pass-mode-link + label')
->assertMissing('#password')
->assertMissing('#password_confirmation')
->waitFor('#password-link code')
->assertSeeIn('#password-link code', $link);
// Test copy to clipboard
/* TODO: Figure out how to give permission to do this operation
$code = $john->verificationcodes()->first();
$link .= $code->short_code . '-' . $code->code;
$browser->assertMissing('#password-link button.text-danger')
->click('#password-link button:not(.text-danger)')
->keys('#organization', ['{left_control}', 'v'])
->assertAttribute('#organization', 'value', $link)
->vueClear('#organization');
*/
// Finally submit the form
$browser->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$this->assertSame(1, $jack->verificationcodes()->where('active', true)->count());
$this->assertSame(0, $john->verificationcodes()->count());
});
});
}
/**
* Test user settings tab
*
* @depends testInfo
*/
public function testUserSettings(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('greylist_enabled', null);
$this->browse(function (Browser $browser) use ($john) {
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->assertElementsCount('@nav a', 2)
->assertSeeIn('@nav #tab-general', 'General')
->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
->with('#settings form', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting')
->click('div.row:nth-child(1) input[type=checkbox]:checked')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.');
});
});
$this->assertSame('false', $john->fresh()->getSetting('greylist_enabled'));
}
/**
* Test user adding page
*
* @depends testInfo
*/
public function testNewUser(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('password_policy', null);
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
- ->assertSeeIn('button.create-user', 'Create user')
- ->click('button.create-user')
+ ->assertSeeIn('button.user-new', 'Create user')
+ ->click('button.user-new')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'New user account')
->with('@general', function (Browser $browser) {
// Assert form content
$browser->assertFocused('div.row:nth-child(1) input')
->assertSeeIn('div.row:nth-child(1) label', 'First Name')
->assertValue('div.row:nth-child(1) input[type=text]', '')
->assertSeeIn('div.row:nth-child(2) label', 'Last Name')
->assertValue('div.row:nth-child(2) input[type=text]', '')
->assertSeeIn('div.row:nth-child(3) label', 'Organization')
->assertValue('div.row:nth-child(3) input[type=text]', '')
->assertSeeIn('div.row:nth-child(4) label', 'Email')
->assertValue('div.row:nth-child(4) input[type=text]', '')
->assertEnabled('div.row:nth-child(4) input[type=text]')
->assertSeeIn('div.row:nth-child(5) label', 'Email Aliases')
->assertVisible('div.row:nth-child(5) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue([])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(6) label', 'Password')
->assertValue('div.row:nth-child(6) input#password', '')
->assertValue('div.row:nth-child(6) input#password_confirmation', '')
->assertAttribute('#password', 'placeholder', 'Password')
->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password')
->assertSeeIn('div.row:nth-child(6) .btn-group input:first-child + label', 'Enter password')
->assertSeeIn('div.row:nth-child(6) .btn-group input:not(:first-child) + label', 'Set via link')
->assertChecked('div.row:nth-child(6) .btn-group input:first-child')
->assertMissing('div.row:nth-child(6) #password-link')
->assertSeeIn('div.row:nth-child(7) label', 'Package')
// assert packages list widget, select "Lite Account"
->with('@packages', function ($browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account')
->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account')
->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 CHF/month')
->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 CHF/month')
->assertChecked('tbody tr:nth-child(1) input')
->click('tbody tr:nth-child(2) input')
->assertNotChecked('tbody tr:nth-child(1) input')
->assertChecked('tbody tr:nth-child(2) input');
})
->assertMissing('@packages table + .hint')
->assertSeeIn('button[type=submit]', 'Submit');
// Test browser-side required fields and error handling
$browser->click('button[type=submit]')
->assertFocused('#email')
->type('#email', 'invalid email')
->type('#password', 'simple123')
->type('#password_confirmation', 'simple')
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.')
->assertSeeIn(
'#password_confirmation + .invalid-feedback',
'The password confirmation does not match.'
);
});
// Test form error handling (aliases)
$browser->with('@general', function (Browser $browser) {
$browser->type('#email', 'julia.roberts@kolab.org')
->type('#password_confirmation', 'simple123')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(1, 'The specified alias is invalid.', false);
});
});
// Successful account creation
$browser->with('@general', function (Browser $browser) {
$browser->type('#first_name', 'Julia')
->type('#last_name', 'Roberts')
->type('#organization', 'Test Org')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(1)
->addListEntry('julia.roberts2@kolab.org');
})
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.')
// check redirection to users list
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 5)
->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first();
$this->assertTrue(!empty($alias));
$this->assertEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']);
$this->assertSame('Julia', $julia->getSetting('first_name'));
$this->assertSame('Roberts', $julia->getSetting('last_name'));
$this->assertSame('Test Org', $julia->getSetting('organization'));
// Some additional tests for the list input widget
$browser->click('@table tbody tr:nth-child(4) a')
->on(new UserInfo())
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['julia.roberts2@kolab.org'])
->addListEntry('invalid address')
->type('.input-group:nth-child(2) input', '@kolab.org')
->keys('.input-group:nth-child(2) input', '{enter}');
})
// TODO: Investigate why this click does not work, for now we
// submit the form with Enter key above
//->click('@general button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertVisible('.input-group:nth-child(2) input.is-invalid')
->assertVisible('.input-group:nth-child(3) input.is-invalid')
->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org')
->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org')
->keys('.input-group:nth-child(3) input', '{enter}');
})
// TODO: Investigate why this click does not work, for now we
// submit the form with Enter key above
//->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all();
$this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases);
});
}
/**
* Test user delete
*
* @depends testNewUser
*/
public function testDeleteUser(): void
{
// First create a new user
$john = $this->getTestUser('john@kolab.org');
$julia = $this->getTestUser('julia.roberts@kolab.org');
$package_kolab = \App\Package::where('title', 'kolab')->first();
$john->assignPackage($package_kolab, $julia);
// Test deleting non-controller user
$this->browse(function (Browser $browser) use ($julia) {
$browser->visit('/user/' . $julia->id)
->on(new UserInfo())
->assertSeeIn('button.button-delete', 'Delete user')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org')
->assertFocused('@button-cancel')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Delete')
->click('@button-cancel');
})
->waitUntilMissing('#delete-warning')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->click('@button-action');
})
->waitUntilMissing('#delete-warning')
->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.')
->on(new UserList())
->with('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$this->assertTrue(empty($julia));
});
// Test that non-controller user cannot see/delete himself on the users list
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->visit('/users')
->assertErrorPage(403);
});
// Test that controller user (Ned) can see all the users
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('ned@kolab.org', 'simple123', true)
->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4);
});
// TODO: Test the delete action in details
});
// TODO: Test what happens with the logged in user session after he's been deleted by another user
}
/**
* Test discounted sku/package prices in the UI
*/
public function testDiscountedPrices(): void
{
// Add 10% discount
$discount = Discount::where('code', 'TEST')->first();
$john = User::where('email', 'john@kolab.org')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->save();
// SKUs on user edit page
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->visit(new UserList())
->waitFor('@table tr:nth-child(2)')
->click('@table tr:nth-child(2) a') // joe@kolab.org
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '4,50 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(100);
})
->assertSeeIn('tr:nth-child(2) td.price', '21,37 CHF/month¹')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,41 CHF/month¹')
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹')
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Packages on new user page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
- ->click('button.create-user')
+ ->click('button.user-new')
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@packages', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) .price', '8,91 CHF/month¹') // Groupware
->assertSeeIn('tbody tr:nth-child(2) .price', '4,50 CHF/month¹'); // Lite
})
->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Test using entitlement cost instead of the SKU cost
$this->browse(function (Browser $browser) use ($wallet) {
$joe = User::where('email', 'joe@kolab.org')->first();
$beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
// Add an extra storage and beta entitlement with different prices
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $beta_sku->id,
'cost' => 5010,
'entitleable_id' => $joe->id,
'entitleable_type' => User::class
]);
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $storage_sku->id,
'cost' => 5000,
'entitleable_id' => $joe->id,
'entitleable_type' => User::class
]);
$browser->visit('/user/' . $joe->id)
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
// Beta SKU
->assertSeeIn('tbody tr:nth-child(7) td.price', '45,09 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(7);
})
->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(5);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
}
/**
* Test non-default currency in the UI
*/
public function testCurrency(): void
{
// Add 10% discount
$john = User::where('email', 'john@kolab.org')->first();
$wallet = $john->wallet();
$wallet->balance = -1000;
$wallet->currency = 'EUR';
$wallet->save();
// On Dashboard and the wallet page
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-wallet .badge', '-10,00 €')
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('#wallet .card-title', 'Account balance -10,00 €');
});
// SKUs on user edit page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->waitFor('@table tr:nth-child(2)')
->click('@table tr:nth-child(2) a') // joe@kolab.org
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 €/month')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '0,00 €/month')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(100);
})
->assertSeeIn('tr:nth-child(2) td.price', '23,75 €/month');
});
});
});
// Packages on new user page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
- ->click('button.create-user')
+ ->click('button.user-new')
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@packages', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 €/month') // Groupware
->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 €/month'); // Lite
});
});
});
}
/**
* Test beta entitlements
*
* @depends testInfo
*/
public function testBetaEntitlements(): void
{
$this->browse(function (Browser $browser) {
$john = User::where('email', 'john@kolab.org')->first();
$sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$john->assignSku($sku);
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 10)
// Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(6) td.selection input')
->assertEnabled('tbody tr:nth-child(6) td.selection input')
->assertTip(
'tbody tr:nth-child(6) td.buttons button',
'Video conferencing tool'
)
// Beta SKU
->assertSeeIn('tbody tr:nth-child(7) td.name', 'Private Beta (invitation only)')
->assertSeeIn('tbody tr:nth-child(7) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(7) td.selection input')
->assertEnabled('tbody tr:nth-child(7) td.selection input')
->assertTip(
'tbody tr:nth-child(7) td.buttons button',
'Access to the private beta program subscriptions'
)
// Distlists SKU
->assertSeeIn('tbody tr:nth-child(8) td.name', 'Distribution lists')
->assertSeeIn('tr:nth-child(8) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(8) td.selection input')
->assertEnabled('tbody tr:nth-child(8) td.selection input')
->assertTip(
'tbody tr:nth-child(8) td.buttons button',
'Access to mail distribution lists'
)
// Resources SKU
->assertSeeIn('tbody tr:nth-child(9) td.name', 'Calendaring resources')
->assertSeeIn('tr:nth-child(9) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(9) td.selection input')
->assertEnabled('tbody tr:nth-child(9) td.selection input')
->assertTip(
'tbody tr:nth-child(9) td.buttons button',
'Access to calendaring resources'
)
// Shared folders SKU
->scrollTo('tbody tr:nth-child(10)')->pause(500)
->assertSeeIn('tbody tr:nth-child(10) td.name', 'Shared folders')
->assertSeeIn('tr:nth-child(10) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(10) td.selection input')
->assertEnabled('tbody tr:nth-child(10) td.selection input')
->assertTip(
'tbody tr:nth-child(10) td.buttons button',
'Access to shared folders'
)
// Check Distlist, Uncheck Beta, expect Distlist unchecked
->click('#sku-input-beta-distlists')
->click('#sku-input-beta')
->assertNotChecked('#sku-input-beta')
->assertNotChecked('#sku-input-beta-distlists')
// Click Distlists expect an alert
->click('#sku-input-beta-distlists')
->assertDialogOpened('Distribution lists requires Private Beta (invitation only).')
->acceptDialog()
// Enable Beta and Distlist and submit
->click('#sku-input-beta')
->click('#sku-input-beta-distlists');
})
->scrollTo('@general button[type=submit]')
->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$expected = [
'beta',
'beta-distlists',
'groupware',
'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage'
];
$this->assertEntitlements($john, $expected);
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->waitFor('#sku-input-beta')
->click('#sku-input-beta')
->scrollTo('@general button[type=submit]')->pause(500)
->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$expected = [
'groupware',
'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage'
];
$this->assertEntitlements($john, $expected);
});
// TODO: Test that the Distlists SKU is not available for users that aren't a group account owners
// TODO: Test that entitlements change has immediate effect on the available items in dashboard
// i.e. does not require a page reload nor re-login.
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Apr 18, 10:06 AM (2 h, 53 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
436143
Default Alt Text
(392 KB)

Event Timeline