Page MenuHomePhorge

No OneTemporary

Size
217 KB
Referenced Files
None
Subscribers
None
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index be2af755..390d542f 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,455 +1,455 @@
/**
* First we will load all of this project's JavaScript dependencies which
* includes Vue and other libraries. It is a great starting point when
* building robust, powerful web applications using Vue and Laravel.
*/
require('./bootstrap')
import AppComponent from '../vue/App'
import MenuComponent from '../vue/Widgets/Menu'
import SupportForm from '../vue/Widgets/SupportForm'
import { Tab } from 'bootstrap'
import { loadLangAsync, i18n } from './locale'
import { clearFormValidation, pick, startLoading, stopLoading } from './utils'
const routerState = {
afterLogin: null,
isLoggedIn: !!localStorage.getItem('token')
}
let loadingRoute
// Note: This has to be before the app is created
// Note: You cannot use app inside of the function
window.router.beforeEach((to, from, next) => {
// check if the route requires authentication and user is not logged in
if (to.meta.requiresAuth && !routerState.isLoggedIn) {
// remember the original request, to use after login
routerState.afterLogin = to;
// redirect to login page
next({ name: 'login' })
return
}
if (to.meta.loading) {
startLoading()
loadingRoute = to.name
}
next()
})
window.router.afterEach((to, from) => {
if (to.name && loadingRoute === to.name) {
stopLoading()
loadingRoute = null
}
// When changing a page remove old:
// - error page
// - modal backdrop
$('#error-page,.modal-backdrop.show').remove()
$('body').css('padding', 0) // remove padding added by unclosed modal
// Close the mobile menu
if ($('#header-menu .navbar-collapse.show').length) {
$('#header-menu .navbar-toggler').click();
}
})
const app = new Vue({
components: {
AppComponent,
MenuComponent,
},
i18n,
router: window.router,
data() {
return {
authInfo: null,
isUser: !window.isAdmin && !window.isReseller,
appName: window.config['app.name'],
appUrl: window.config['app.url'],
themeDir: '/themes/' + window.config['app.theme']
}
},
methods: {
clearFormValidation,
hasPermission(type) {
const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1)
return !!(this.authInfo && this.authInfo.statusInfo[key])
},
hasRoute(name) {
return this.$router.resolve({ name: name }).resolved.matched.length > 0
},
hasSKU(name) {
return this.authInfo.statusInfo.skus && this.authInfo.statusInfo.skus.indexOf(name) != -1
},
isController(wallet_id) {
if (wallet_id && this.authInfo) {
let i
for (i = 0; i < this.authInfo.wallets.length; i++) {
if (wallet_id == this.authInfo.wallets[i].id) {
return true
}
}
for (i = 0; i < this.authInfo.accounts.length; i++) {
if (wallet_id == this.authInfo.accounts[i].id) {
return true
}
}
}
return false
},
isDegraded() {
return this.authInfo && this.authInfo.isAccountDegraded
},
// Set user state to "logged in"
loginUser(response, dashboard, update) {
if (!update) {
routerState.isLoggedIn = true
this.authInfo = null
}
localStorage.setItem('token', response.access_token)
localStorage.setItem('refreshToken', response.refresh_token)
axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token
if (response.email) {
this.authInfo = response
}
if (dashboard !== false) {
this.$router.push(routerState.afterLogin || { name: 'dashboard' })
}
routerState.afterLogin = null
// Refresh the token before it expires
let timeout = response.expires_in || 0
// We'll refresh 60 seconds before the token expires
if (timeout > 60) {
timeout -= 60
}
// TODO: We probably should try a few times in case of an error
// TODO: We probably should prevent axios from doing any requests
// while the token is being refreshed
this.refreshTimeout = setTimeout(() => {
axios.post('api/auth/refresh', { refresh_token: response.refresh_token }).then(response => {
this.loginUser(response.data, false, true)
})
}, timeout * 1000)
},
// Set user state to "not logged in"
logoutUser(redirect) {
routerState.isLoggedIn = true
this.authInfo = null
localStorage.setItem('token', '')
localStorage.setItem('refreshToken', '')
delete axios.defaults.headers.common.Authorization
if (redirect !== false) {
this.$router.push({ name: 'login' })
}
clearTimeout(this.refreshTimeout)
},
logo(mode) {
let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png'
return `<img src="${src}" alt="${this.appName}">`
},
pick,
startLoading,
stopLoading,
tab(e) {
e.preventDefault()
new Tab(e.target).show()
},
errorPage(code, msg, hint) {
// Until https://github.com/vuejs/vue-router/issues/977 is implemented
// we can't really use router to display error page as it has two side
// effects: it changes the URL and adds the error page to browser history.
// For now we'll be replacing current view with error page "manually".
if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown')
if (!hint) hint = ''
const error_page = '<div id="error-page" class="error-page">'
+ `<div class="code">${code}</div><div class="message">${msg}</div><div class="hint">${hint}</div>`
+ '</div>'
$('#error-page').remove()
$('#app').append(error_page)
app.updateBodyClass('error')
},
errorHandler(error) {
stopLoading()
const status = error.response ? error.response.status : 500
const message = error.response ? error.response.statusText : ''
if (status == 401) {
// Remember requested route to come back to it after log in
if (this.$route.meta.requiresAuth) {
routerState.afterLogin = this.$route
this.logoutUser()
} else {
this.logoutUser(false)
}
} else {
this.errorPage(status, message)
}
},
price(price, currency) {
// TODO: Set locale argument according to the currently used locale
return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' })
},
priceLabel(cost, discount, currency) {
let index = ''
if (discount) {
cost = Math.floor(cost * ((100 - discount) / 100))
index = '\u00B9'
}
return this.price(cost, currency) + '/' + this.$t('wallet.month') + index
},
clickRecord(event) {
if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) {
$(event.target).closest('tr').find('a').trigger('click')
}
},
pageName(path) {
let page = this.$route.path
// check if it is a "menu page", find the page name
// otherwise we'll use the real path as page name
window.config.menu.every(item => {
if (item.location == page && item.page) {
page = item.page
return false
}
})
page = page.replace(/^\//, '')
return page ? page : '404'
},
supportDialog(container) {
let dialog = $('#support-dialog')[0]
if (!dialog) {
// FIXME: Find a nicer way of doing this
SupportForm.i18n = i18n
let form = new Vue(SupportForm)
form.$mount($('<div>').appendTo(container)[0])
form.$root = this
form.$toast = this.$toast
dialog = form.$el
}
- dialog.__vue__.showDialog()
+ dialog.__vue__.show()
},
statusClass(obj) {
if (obj.isDeleted) {
return 'text-muted'
}
if (obj.isDegraded || obj.isAccountDegraded || obj.isSuspended) {
return 'text-warning'
}
if (obj.isImapReady === false || obj.isLdapReady === false || obj.isVerified === false || obj.isConfirmed === false) {
return 'text-danger'
}
return 'text-success'
},
statusText(obj) {
if (obj.isDeleted) {
return this.$t('status.deleted')
}
if (obj.isDegraded || obj.isAccountDegraded) {
return this.$t('status.degraded')
}
if (obj.isSuspended) {
return this.$t('status.suspended')
}
if (obj.isImapReady === false || obj.isLdapReady === false || obj.isVerified === false || obj.isConfirmed === false) {
return this.$t('status.notready')
}
return this.$t('status.active')
},
// Append some wallet properties to the object
userWalletProps(object) {
let wallet = this.authInfo.accounts[0]
if (!wallet) {
wallet = this.authInfo.wallets[0]
}
if (wallet) {
object.currency = wallet.currency
if (wallet.discount) {
object.discount = wallet.discount
object.discount_description = wallet.discount_description
}
}
},
updateBodyClass(name) {
// Add 'class' attribute to the body, different for each page
// so, we can apply page-specific styles
document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '')
}
}
})
// Fetch the locale file and the start the app
loadLangAsync().then(() => app.$mount('#app'))
// Add a axios request interceptor
axios.interceptors.request.use(
config => {
// This is the only way I found to change configuration options
// on a running application. We need this for browser testing.
config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider
let loader = config.loader
if (loader) {
startLoading(loader)
}
return config
},
error => {
// Do something with request error
return Promise.reject(error)
}
)
// Add a axios response interceptor for general/validation error handler
axios.interceptors.response.use(
response => {
if (response.config.onFinish) {
response.config.onFinish()
}
let loader = response.config.loader
if (loader) {
stopLoading(loader)
}
return response
},
error => {
let loader = error.config.loader
if (loader) {
stopLoading(loader)
}
// Do not display the error in a toast message, pass the error as-is
if (axios.isCancel(error) || error.config.ignoreErrors) {
return Promise.reject(error)
}
if (error.config.onFinish) {
error.config.onFinish()
}
let error_msg
const status = error.response ? error.response.status : 200
const data = error.response ? error.response.data : {}
if (status == 422 && data.errors) {
error_msg = app.$t('error.form')
const modal = $('div.modal.show')
$(modal.length ? modal : 'form').each((i, form) => {
form = $(form)
$.each(data.errors, (idx, msg) => {
const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx
let input = form.find('#' + input_name)
if (!input.length) {
input = form.find('[name="' + input_name + '"]');
}
if (input.length) {
// Create an error message
// API responses can use a string, array or object
let msg_text = ''
if (typeof(msg) !== 'string') {
$.each(msg, (index, str) => {
msg_text += str + ' '
})
}
else {
msg_text = msg
}
let feedback = $('<div class="invalid-feedback">').text(msg_text)
if (input.is('.list-input')) {
// List input widget
let controls = input.children(':not(:first-child)')
if (!controls.length && typeof msg == 'string') {
// this is an empty list (the main input only)
// and the error message is not an array
input.find('.main-input').addClass('is-invalid')
} else {
controls.each((index, element) => {
if (msg[index]) {
$(element).find('input').addClass('is-invalid')
}
})
}
input.addClass('is-invalid').next('.invalid-feedback').remove()
input.after(feedback)
} else {
// a special case, e.g. the invitation policy widget
if (input.is('select') && input.parent().is('.input-group-select.selected')) {
input = input.next()
}
// Standard form element
input.addClass('is-invalid')
input.parent().find('.invalid-feedback').remove()
input.parent().append(feedback)
}
}
})
form.find('.is-invalid:not(.list-input)').first().focus()
})
}
else if (data.status == 'error') {
error_msg = data.message
}
else {
error_msg = error.request ? error.request.statusText : error.message
}
app.$toast.error(error_msg || app.$t('error.server'))
// Pass the error as-is
return Promise.reject(error)
}
)
diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss
index 29635e97..e7ce776e 100644
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -1,507 +1,518 @@
html,
body,
body > .outer-container {
height: 100%;
}
#app {
display: flex;
flex-direction: column;
min-height: 100%;
overflow: hidden;
& > nav {
flex-shrink: 0;
z-index: 12;
}
& > div.container {
flex-grow: 1;
margin-top: 2rem;
margin-bottom: 2rem;
}
& > .filler {
flex-grow: 1;
}
& > div.container + .filler {
display: none;
}
}
.error-page {
position: absolute;
top: 0;
height: 100%;
width: 100%;
align-content: center;
align-items: center;
display: flex;
flex-wrap: wrap;
justify-content: center;
color: #636b6f;
z-index: 10;
background: white;
.code {
text-align: right;
border-right: 2px solid;
font-size: 26px;
padding: 0 15px;
}
.message {
font-size: 18px;
padding: 0 15px;
}
.hint {
margin-top: 3em;
text-align: center;
width: 100%;
}
}
.app-loader {
background-color: $body-bg;
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 8;
.spinner-border {
width: 120px;
height: 120px;
border-width: 15px;
color: #b2aa99;
}
&.small .spinner-border {
width: 25px;
height: 25px;
border-width: 3px;
}
&.fadeOut {
visibility: hidden;
opacity: 0;
transition: visibility 300ms linear, opacity 300ms linear;
}
}
pre {
margin: 1rem 0;
padding: 1rem;
background-color: $menu-bg-color;
}
.card-title {
font-size: 1.2rem;
font-weight: bold;
}
tfoot.table-fake-body {
background-color: #f8f8f8;
color: grey;
text-align: center;
td {
vertical-align: middle;
height: 8em;
border: 0;
}
tbody:not(:empty) + & {
display: none;
}
}
table {
th {
white-space: nowrap;
}
td.email,
td.price,
td.datetime,
td.selection {
width: 1%;
white-space: nowrap;
}
td.buttons,
th.price,
td.price,
th.size,
td.size {
width: 1%;
text-align: right;
white-space: nowrap;
}
&.form-list {
margin: 0;
td {
border: 0;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
button {
line-height: 1;
}
}
.btn-action {
line-height: 1;
padding: 0;
}
&.files {
table-layout: fixed;
td {
white-space: nowrap;
}
td.name {
overflow: hidden;
text-overflow: ellipsis;
}
/*
td.size,
th.size {
width: 80px;
}
td.mtime,
th.mtime {
width: 140px;
@include media-breakpoint-down(sm) {
display: none;
}
}
*/
td.buttons,
th.buttons {
width: 50px;
}
}
}
.table > :not(:first-child) {
// Remove Bootstrap's 2px border
border-width: 0;
}
.list-details {
min-height: 1em;
& > ul {
margin: 0;
padding-left: 1.2em;
}
}
.plan-selector {
.plan-header {
display: flex;
}
.plan-ico {
margin:auto;
font-size: 3.8rem;
color: #f1a539;
border: 3px solid #f1a539;
width: 6rem;
height: 6rem;
border-radius: 50%;
}
}
.status-message {
display: flex;
align-items: center;
justify-content: center;
.app-loader {
width: auto;
position: initial;
.spinner-border {
color: $body-color;
}
}
svg {
font-size: 1.5em;
}
:first-child {
margin-right: 0.4em;
}
}
.form-separator {
position: relative;
margin: 1em 0;
display: flex;
justify-content: center;
hr {
border-color: #999;
margin: 0;
position: absolute;
top: 0.75em;
width: 100%;
}
span {
background: #fff;
padding: 0 1em;
z-index: 1;
}
}
+.modal {
+ .modal-dialog,
+ .modal-content {
+ max-height: calc(100vh - 3.5rem);
+ }
+
+ .modal-body {
+ overflow: auto !important;
+ }
+}
+
#status-box {
background-color: lighten($green, 35);
.progress {
background-color: #fff;
height: 10px;
}
.progress-label {
font-size: 0.9em;
}
.progress-bar {
background-color: $green;
}
&.process-failed {
background-color: lighten($orange, 30);
.progress-bar {
background-color: $red;
}
}
}
@keyframes blinker {
50% {
opacity: 0;
}
}
.blinker {
animation: blinker 750ms step-start infinite;
}
#dashboard-nav {
display: flex;
flex-wrap: wrap;
justify-content: center;
& > a {
padding: 1rem;
text-align: center;
white-space: nowrap;
margin: 0.25rem;
text-decoration: none;
width: 150px;
&.disabled {
pointer-events: none;
opacity: 0.6;
}
// Some icons are too big, scale them down
&.link-companionapp,
&.link-domains,
&.link-resources,
&.link-settings,
&.link-wallet,
&.link-invitations {
svg {
transform: scale(0.8);
}
}
&.link-distlists,
&.link-files,
&.link-shared-folders {
svg {
transform: scale(0.9);
}
}
.badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
}
svg {
width: 6rem;
height: 6rem;
margin: auto;
}
}
#payment-method-selection {
display: flex;
flex-wrap: wrap;
justify-content: center;
& > a {
padding: 1rem;
text-align: center;
white-space: nowrap;
margin: 0.25rem;
text-decoration: none;
width: 150px;
}
svg {
width: 6rem;
height: 6rem;
margin: auto;
}
.link-banktransfer svg {
transform: scale(.8);
}
}
#logon-form {
flex-basis: auto; // Bootstrap issue? See logon page with width < 992
}
#logon-form-footer {
a:not(:first-child) {
margin-left: 2em;
}
}
// Various improvements for mobile
@include media-breakpoint-down(sm) {
.card,
.card-footer {
border: 0;
}
.card-body {
padding: 0.5rem 0;
}
.nav-tabs {
flex-wrap: nowrap;
.nav-link {
white-space: nowrap;
padding: 0.5rem 0.75rem;
}
}
#app > div.container {
margin-bottom: 1rem;
margin-top: 1rem;
max-width: 100%;
}
#header-menu-navbar {
padding: 0;
}
#dashboard-nav > a {
width: 135px;
}
.table-sm:not(.form-list) {
tbody td {
padding: 0.75rem 0.5rem;
svg {
vertical-align: -0.175em;
}
& > svg {
font-size: 125%;
margin-right: 0.25rem;
}
}
}
.table.transactions {
thead {
display: none;
}
tbody {
tr {
position: relative;
display: flex;
flex-wrap: wrap;
}
td {
width: auto;
border: 0;
padding: 0.5rem;
&.datetime {
width: 50%;
padding-left: 0;
}
&.description {
order: 3;
width: 100%;
border-bottom: 1px solid $border-color;
color: $secondary;
padding: 0 1.5em 0.5rem 0;
margin-top: -0.25em;
}
&.selection {
position: absolute;
right: 0;
border: 0;
top: 1.7em;
padding-right: 0;
}
&.price {
width: 50%;
padding-right: 0;
}
&.email {
display: none;
}
}
}
}
}
@include media-breakpoint-down(sm) {
.tab-pane > .card-body {
padding: 0.5rem;
}
}
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
index 2d299e22..75c160f1 100644
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -1,726 +1,644 @@
<template>
<div class="container">
<div class="card" id="user-info">
<div class="card-body">
<h1 class="card-title">{{ user.email }}</h1>
<div class="card-text">
<form class="read-only short">
<div v-if="user.wallet.user_id != user.id" class="row plaintext">
<label for="manager" class="col-sm-4 col-form-label">{{ $t('user.managed-by') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="manager">
<router-link :to="{ path: '/user/' + user.wallet.user_id }">{{ user.wallet.user_email }}</router-link>
</span>
</div>
</div>
<div class="row plaintext">
<label for="userid" class="col-sm-4 col-form-label">ID <span class="text-muted">({{ $t('form.created') }})</span></label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="userid">
{{ user.id }} <span class="text-muted">({{ user.created_at }})</span>
</span>
</div>
</div>
<div class="row plaintext">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="status">
<span :class="$root.statusClass(user)">{{ $root.statusText(user) }}</span>
</span>
</div>
</div>
<div class="row plaintext" v-if="user.first_name">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="first_name">{{ user.first_name }}</span>
</div>
</div>
<div class="row plaintext" v-if="user.last_name">
<label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="last_name">{{ user.last_name }}</span>
</div>
</div>
<div class="row plaintext" v-if="user.organization">
<label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="organization">{{ user.organization }}</span>
</div>
</div>
<div class="row plaintext" v-if="user.phone">
<label for="phone" class="col-sm-4 col-form-label">{{ $t('form.phone') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="phone">{{ user.phone }}</span>
</div>
</div>
<div class="row plaintext">
<label for="external_email" class="col-sm-4 col-form-label">{{ $t('user.ext-email') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="external_email">
<a v-if="user.external_email" :href="'mailto:' + user.external_email">{{ user.external_email }}</a>
<btn class="btn-secondary btn-sm ms-2" @click="emailEdit">{{ $t('btn.edit') }}</btn>
</span>
</div>
</div>
<div class="row plaintext" v-if="user.billing_address">
<label for="billing_address" class="col-sm-4 col-form-label">{{ $t('user.address') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" style="white-space:pre" id="billing_address">{{ user.billing_address }}</span>
</div>
</div>
<div class="row plaintext">
<label for="country" class="col-sm-4 col-form-label">{{ $t('user.country') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="country">{{ user.country }}</span>
</div>
</div>
</form>
<div class="mt-2 buttons">
<btn v-if="!user.isSuspended" id="button-suspend" class="btn-warning" @click="suspendUser">
{{ $t('btn.suspend') }}
</btn>
<btn v-if="user.isSuspended" id="button-unsuspend" class="btn-warning" @click="unsuspendUser">
{{ $t('btn.unsuspend') }}
</btn>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-finances" href="#user-finances" role="tab" aria-controls="user-finances" aria-selected="true">
{{ $t('user.finances') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-aliases" href="#user-aliases" role="tab" aria-controls="user-aliases" aria-selected="false">
{{ $t('user.aliases') }} ({{ user.aliases.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-subscriptions" href="#user-subscriptions" role="tab" aria-controls="user-subscriptions" aria-selected="false">
{{ $t('user.subscriptions') }} ({{ skus.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-domains" href="#user-domains" role="tab" aria-controls="user-domains" aria-selected="false">
{{ $t('user.domains') }} ({{ domains.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-users" href="#user-users" role="tab" aria-controls="user-users" aria-selected="false">
{{ $t('user.users') }} ({{ users.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-distlists" href="#user-distlists" role="tab" aria-controls="user-distlists" aria-selected="false">
{{ $t('user.distlists') }} ({{ distlists.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-resources" href="#user-resources" role="tab" aria-controls="user-resources" aria-selected="false">
{{ $t('user.resources') }} ({{ resources.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-shared-folders" href="#user-shared-folders" role="tab" aria-controls="user-shared-folders" aria-selected="false">
{{ $t('dashboard.shared-folders') }} ({{ folders.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-settings" href="#user-settings" role="tab" aria-controls="user-settings" aria-selected="false">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="user-finances" role="tabpanel" aria-labelledby="tab-finances">
<div class="card-body">
<h2 class="card-title">
{{ $t('wallet.title') }}
<span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(wallet.balance, wallet.currency) }}</strong></span>
</h2>
<div class="card-text">
<form class="read-only short">
<div class="row">
<label class="col-sm-4 col-form-label">{{ $t('user.discount') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="discount">
<span>{{ wallet.discount ? (wallet.discount + '% - ' + wallet.discount_description) : 'none' }}</span>
<btn class="btn-secondary btn-sm ms-2" @click="discountEdit">{{ $t('btn.edit') }}</btn>
</span>
</div>
</div>
<div class="row" v-if="wallet.mandate && wallet.mandate.id">
<label class="col-sm-4 col-form-label">{{ $t('user.auto-payment') }}</label>
<div class="col-sm-8">
<span id="autopayment" :class="'form-control-plaintext' + (wallet.mandateState ? ' text-danger' : '')"
v-html="$t('user.auto-payment-text', {
amount: wallet.mandate.amount + ' ' + wallet.currency,
balance: wallet.mandate.balance + ' ' + wallet.currency,
method: wallet.mandate.method
})"
>
<span v-if="wallet.mandateState">({{ wallet.mandateState }})</span>.
</span>
</div>
</div>
<div class="row" v-if="wallet.providerLink">
<label class="col-sm-4 col-form-label">{{ capitalize(wallet.provider) }} {{ $t('form.id') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" v-html="wallet.providerLink"></span>
</div>
</div>
</form>
<div class="mt-2 buttons">
<btn id="button-award" class="btn-success" @click="awardDialog">{{ $t('user.add-bonus') }}</btn>
<btn id="button-penalty" class="btn-danger" @click="penalizeDialog">{{ $t('user.add-penalty') }}</btn>
</div>
</div>
<h2 class="card-title mt-4">{{ $t('wallet.transactions') }}</h2>
<transaction-log v-if="wallet.id && !walletReload" class="card-text" :wallet-id="wallet.id" :is-admin="true"></transaction-log>
</div>
</div>
<div class="tab-pane" id="user-aliases" role="tabpanel" aria-labelledby="tab-aliases">
<div class="card-body">
<div class="card-text">
<list-table :list="user.aliases" :setup="aliasesListSetup" class="mb-0"></list-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">
<list-table :list="skus" :setup="skusListSetup" class="mb-0"></list-table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
&sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
<div class="mt-2 buttons">
- <btn class="btn-danger" id="reset2fa" v-if="has2FA" @click="reset2FADialog">{{ $t('user.reset-2fa') }}</btn>
+ <btn class="btn-danger" id="reset2fa" v-if="has2FA" @click="$refs.reset2faDialog.show()">{{ $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">
<domain-list :list="domains" class="mb-0"></domain-list>
</div>
</div>
</div>
<div class="tab-pane" id="user-users" role="tabpanel" aria-labelledby="tab-users">
<div class="card-body">
<div class="card-text">
<user-list :list="users" :current="user" class="mb-0"></user-list>
</div>
</div>
</div>
<div class="tab-pane" id="user-distlists" role="tabpanel" aria-labelledby="tab-distlists">
<div class="card-body">
<div class="card-text">
<distlist-list :list="distlists" class="mb-0"></distlist-list>
</div>
</div>
</div>
<div class="tab-pane" id="user-resources" role="tabpanel" aria-labelledby="tab-resources">
<div class="card-body">
<div class="card-text">
<resource-list :list="resources" class="mb-0"></resource-list>
</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">
<shared-folder-list :list="folders" :with-email="true" class="mb-0"></shared-folder-list>
</div>
</div>
</div>
<div class="tab-pane" id="user-settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="greylist_enabled">
<span v-if="user.config.greylist_enabled" class="text-success">{{ $t('form.enabled') }}</span>
<span v-else class="text-danger">{{ $t('form.disabled') }}</span>
</span>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
- <div id="discount-dialog" class="modal" tabindex="-1" role="dialog">
- <div class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title">{{ $t('user.discount-title') }}</h5>
- <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
- </div>
- <div class="modal-body">
- <p>
- <select v-model="wallet.discount_id" class="form-select">
- <option value="">- {{ $t('form.none') }} -</option>
- <option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option>
- </select>
- </p>
- </div>
- <div class="modal-footer">
- <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
- <btn class="btn-primary modal-action" @click="submitDiscount()" icon="check">{{ $t('btn.submit') }}</btn>
- </div>
- </div>
+ <modal-dialog id="discount-dialog" ref="discountDialog" :title="$t('user.discount-title')" @click="submitDiscount()" :buttons="['submit']">
+ <div>
+ <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>
</div>
- </div>
+ </modal-dialog>
- <div id="email-dialog" class="modal" tabindex="-1" role="dialog">
- <div class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title">{{ $t('user.ext-email') }}</h5>
- <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
- </div>
- <div class="modal-body">
- <p>
- <input v-model="external_email" name="external_email" class="form-control">
- </p>
- </div>
- <div class="modal-footer">
- <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
- <btn class="btn-primary modal-action" @click="submitEmail()" icon="check">{{ $t('btn.submit') }}</btn>
- </div>
- </div>
+ <modal-dialog id="email-dialog" ref="emailDialog" :title="$t('user.ext-email')" @click="submitEmail()" :buttons="['submit']">
+ <div>
+ <input v-model="external_email" name="external_email" class="form-control">
</div>
- </div>
-
- <div id="oneoff-dialog" class="modal" tabindex="-1" role="dialog">
- <div class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title">{{ $t(oneoff_negative ? 'user.add-penalty-title' : 'user.add-bonus-title') }}</h5>
- <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
- </div>
- <div class="modal-body">
- <form data-validation-prefix="oneoff_">
- <div class="row mb-3">
- <label for="oneoff_amount" class="col-form-label">{{ $t('form.amount') }}</label>
- <div class="input-group">
- <input type="text" class="form-control" id="oneoff_amount" v-model="oneoff_amount" required>
- <span class="input-group-text">{{ wallet.currency }}</span>
- </div>
- </div>
- <div class="row">
- <label for="oneoff_description" class="col-form-label">{{ $t('form.description') }}</label>
- <input class="form-control" id="oneoff_description" v-model="oneoff_description" required>
- </div>
- </form>
- </div>
- <div class="modal-footer">
- <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
- <btn class="btn-primary modal-action" @click="submitOneOff()" icon="check">{{ $t('btn.submit') }}</btn>
+ </modal-dialog>
+
+ <modal-dialog id="oneoff-dialog" ref="oneoffDialog" @click="submitOneOff()" :buttons="['submit']"
+ :title="$t(oneoff_negative ? 'user.add-penalty-title' : 'user.add-bonus-title')"
+ >
+ <form data-validation-prefix="oneoff_">
+ <div class="mb-3">
+ <label for="oneoff_amount" class="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>
- </div>
-
- <div id="reset-2fa-dialog" class="modal" tabindex="-1" role="dialog">
- <div class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title">{{ $t('user.reset-2fa-title') }}</h5>
- <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
- </div>
- <div class="modal-body">
- <p>{{ $t('user.2fa-hint1') }}</p>
- <p>{{ $t('user.2fa-hint2') }}</p>
- </div>
- <div class="modal-footer">
- <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
- <btn class="btn-danger modal-action" @click="reset2FA()">{{ $t('btn.reset') }}</btn>
- </div>
+ <div>
+ <label for="oneoff_description" class="form-label">{{ $t('form.description') }}</label>
+ <input class="form-control" id="oneoff_description" v-model="oneoff_description" required>
</div>
- </div>
- </div>
+ </form>
+ </modal-dialog>
+
+ <modal-dialog id="reset-2fa-dialog" ref="reset2faDialog" :title="$t('user.reset-2fa-title')" @click="reset2FA()"
+ :buttons="[{className: 'btn-danger modal-action', label: 'btn.reset'}]"
+ >
+ <p>{{ $t('user.2fa-hint1') }}</p>
+ <p>{{ $t('user.2fa-hint2') }}</p>
+ </modal-dialog>
</div>
</template>
<script>
- import { Modal } from 'bootstrap'
+ import ModalDialog from '../Widgets/ModalDialog'
import TransactionLog from '../Widgets/TransactionLog'
import { ListTable } from '../Widgets/ListTools'
import { default as DistlistList } from '../Distlist/ListWidget'
import { default as DomainList } from '../Domain/ListWidget'
import { default as ResourceList } from '../Resource/ListWidget'
import { default as SharedFolderList } from '../SharedFolder/ListWidget'
import { default as UserList } from '../User/ListWidget'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faFolderOpen').definition,
require('@fortawesome/free-solid-svg-icons/faGear').definition,
require('@fortawesome/free-solid-svg-icons/faGlobe').definition,
require('@fortawesome/free-solid-svg-icons/faUsers').definition,
)
export default {
components: {
DistlistList,
DomainList,
ListTable,
+ ModalDialog,
ResourceList,
SharedFolderList,
TransactionLog,
UserList
},
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 {
aliasesListSetup: {
columns: [
{
prop: 'email',
content: item => item
},
],
footLabel: 'user.aliases-none'
},
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: [],
sku2FA: null,
skus: [],
skusListSetup: {
columns: [
{
prop: 'name',
label: 'user.subscription'
},
{
prop: 'price',
className: 'price',
label: 'user.price'
}
],
footLabel: 'user.subscriptions-none',
model: 'sku'
},
users: [],
user: {
aliases: [],
config: {},
wallet: {},
skus: {},
}
}
},
created() {
const user_id = this.$route.params.user
axios.get('/api/v4/users/' + user_id, { loader: true })
.then(response => {
this.user = response.data
const loader = '#user-finances'
const keys = ['first_name', 'last_name', 'external_email', 'billing_address', 'phone', 'organization']
let country = this.user.settings.country
if (country && country in window.config.countries) {
country = window.config.countries[country][1]
}
this.user.country = country
keys.forEach(key => { this.user[key] = this.user.settings[key] })
this.discount = this.user.wallet.discount
this.discount_description = this.user.wallet.discount_description
// TODO: currencies, multi-wallets, accounts
// Get more info about the wallet (e.g. payment provider related)
axios.get('/api/v4/wallets/' + this.user.wallets[0].id, { loader })
.then(response => {
this.wallet = response.data
this.setMandateState()
})
// 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)
+
+ this.$refs.discountDialog.events({
+ shown: () => {
+ // Note: Vue v-model is strict, convert null to a string
+ this.wallet.discount_id = this.wallet.discount_id || ''
+ }
+ })
},
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()
+ this.$refs.discountDialog.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()
+ this.$refs.emailDialog.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()
+ this.$refs.oneoffDialog.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()
+ this.$refs.reset2faDialog.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()
+ this.$refs.discountDialog.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.$refs.emailDialog.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.$refs.oneoffDialog.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/Domain/Info.vue b/src/resources/vue/Domain/Info.vue
index 7916c6c5..1fa54420 100644
--- a/src/resources/vue/Domain/Info.vue
+++ b/src/resources/vue/Domain/Info.vue
@@ -1,224 +1,207 @@
<template>
<div class="container">
<status-component v-if="domain_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card">
<div class="card-body">
<div class="card-title" v-if="domain_id === 'new'">{{ $t('domain.new') }}</div>
<div class="card-title" v-else>{{ $t('form.domain') }}
- <btn class="btn-outline-danger button-delete float-end" @click="showDeleteConfirmation()" icon="trash-can">{{ $t('domain.delete') }}</btn>
+ <btn class="btn-outline-danger button-delete float-end" @click="$refs.deleteDialog.show()" icon="trash-can">{{ $t('domain.delete') }}</btn>
</div>
<div class="card-text">
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
{{ $t('form.general') }}
</a>
</li>
<li class="nav-item" v-if="domain.id">
<a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="domain.id" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(domain) + ' form-control-plaintext'" id="status">{{ $root.statusText(domain) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="name" class="col-sm-4 col-form-label">{{ $t('domain.namespace') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="namespace" v-model="domain.namespace" :disabled="domain.id">
</div>
</div>
<div v-if="!domain.id" id="domain-packages" class="row">
<label class="col-sm-4 col-form-label">{{ $t('user.package') }}</label>
<package-select class="col-sm-8 pt-sm-1" type="domain"></package-select>
</div>
<div v-if="domain.id" id="domain-skus" class="row">
<label class="col-sm-4 col-form-label">{{ $t('user.subscriptions') }}</label>
<subscription-select v-if="domain.id" class="col-sm-8 pt-sm-1" type="domain" :object="domain" :readonly="true"></subscription-select>
</div>
<btn v-if="!domain.id" class="btn-primary mt-3" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
<hr class="m-0" v-if="domain.id">
<div v-if="domain.id && !domain.isConfirmed" class="card-body" id="domain-verify">
<h5 class="mb-3">{{ $t('domain.verify') }}</h5>
<div class="card-text">
<p>{{ $t('domain.verify-intro') }}</p>
<p>
<span v-html="$t('domain.verify-dns')"></span>
<ul>
<li>{{ $t('domain.verify-dns-txt') }} <code>{{ domain.hash_text }}</code></li>
<li>{{ $t('domain.verify-dns-cname') }} <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
</ul>
<span>{{ $t('domain.verify-outro') }}</span>
</p>
<p>{{ $t('domain.verify-sample') }} <pre>{{ domain.dns.join("\n") }}</pre></p>
<btn class="btn-primary" @click="confirm" icon="rotate">{{ $t('btn.verify') }}</btn>
</div>
</div>
<div v-if="domain.isConfirmed" class="card-body" id="domain-config">
<h5 class="mb-3">{{ $t('domain.config') }}</h5>
<div class="card-text">
<p>{{ $t('domain.config-intro', { app: $root.appName }) }}</p>
<p>{{ $t('domain.config-sample') }} <pre>{{ domain.mx.join("\n") }}</pre></p>
<p>{{ $t('domain.config-hint') }}</p>
</div>
</div>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<form @submit.prevent="submitSettings">
<div class="row mb-3">
<label for="spf_whitelist" class="col-sm-4 col-form-label">{{ $t('domain.spf-whitelist') }}</label>
<div class="col-sm-8">
<list-input id="spf_whitelist" name="spf_whitelist" :list="spf_whitelist"></list-input>
<small id="spf-hint" class="text-muted d-block mt-2">
{{ $t('domain.spf-whitelist-text') }}
<span class="d-block" v-html="$t('domain.spf-whitelist-ex')"></span>
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
- <div id="delete-warning" class="modal" tabindex="-1" role="dialog">
- <div class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title">{{ $t('domain.delete-domain', { domain: domain.namespace }) }}</h5>
- <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
- </div>
- <div class="modal-body">
- <p>{{ $t('domain.delete-text') }}</p>
- </div>
- <div class="modal-footer">
- <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
- <btn class="btn-danger modal-action" @click="deleteDomain()" icon="trash-can">{{ $t('btn.delete') }}</btn>
- </div>
- </div>
- </div>
- </div>
+
+ <modal-dialog id="delete-warning" ref="deleteDialog" @click="deleteDomain()" :buttons="['delete']" :cancel-focus="true"
+ :title="$t('domain.delete-domain', { domain: domain.namespace })"
+ >
+ <p>{{ $t('domain.delete-text') }}</p>
+ </modal-dialog>
</div>
</template>
<script>
- import { Modal } from 'bootstrap'
import ListInput from '../Widgets/ListInput'
+ import ModalDialog from '../Widgets/ModalDialog'
import PackageSelect from '../Widgets/PackageSelect'
import StatusComponent from '../Widgets/Status'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faRotate').definition,
)
export default {
components: {
ListInput,
+ ModalDialog,
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') {
axios.get('/api/v4/domains/' + this.domain_id, { loader: true })
.then(response => {
this.domain = response.data
this.spf_whitelist = this.domain.config.spf_whitelist || []
if (!this.domain.isConfirmed) {
$('#domain-verify button').focus()
}
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#namespace').focus()
- $('#delete-warning')[0].addEventListener('shown.bs.modal', event => {
- $(event.target).find('button.modal-cancel').focus()
- })
},
methods: {
confirm() {
axios.get('/api/v4/domains/' + this.domain_id + '/confirm')
.then(response => {
if (response.data.status == 'success') {
this.domain.isConfirmed = true
this.status = response.data.statusInfo
}
if (response.data.message) {
this.$toast[response.data.status](response.data.message)
}
})
},
deleteDomain() {
// Delete the domain from the confirm dialog
axios.delete('/api/v4/domains/' + this.domain_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'domains' })
}
})
},
- showDeleteConfirmation() {
- // Display the warning
- new Modal('#delete-warning').show()
- },
statusUpdate(domain) {
this.domain = Object.assign({}, this.domain, domain)
},
submit() {
this.$root.clearFormValidation($('#general form'))
let post = this.$root.pick(this.domain, ['namespace'])
post.package = $('#domain-packages input:checked').val()
axios.post('/api/v4/domains', post)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'domains' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
const post = this.$root.pick(this, ['spf_whitelist'])
axios.post('/api/v4/domains/' + this.domain_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue
index bc75a813..2bfd5379 100644
--- a/src/resources/vue/Meet/Room.vue
+++ b/src/resources/vue/Meet/Room.vue
@@ -1,646 +1,600 @@
<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' + (screenActive ? ' 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"></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')">
+ <button class="btn link-options" v-if="isRoomOwner()" @click="$refs.optionsDialog.show()" :title="$t('meet.options')">
<svg-icon icon="gear"></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-lines"></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>
- <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">
- <btn class="btn-danger modal-action" data-bs-dismiss="modal">{{ $t('btn.close') }}</btn>
- </div>
+ <modal-dialog id="leave-dialog" ref="leaveDialog" :title="$t('meet.leave-title')">
+ <p>{{ $t('meet.leave-body') }}</p>
+ </modal-dialog>
+
+ <modal-dialog id="media-setup-dialog" ref="setupDialog" :title="$t('meet.media-title')">
+ <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>
- </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>
- <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">
- <btn class="btn-secondary modal-action" data-bs-dismiss="modal">{{ $t('btn.close') }}</btn>
- </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>
- </div>
- </div>
+ </form>
+ </modal-dialog>
- <room-options v-if="session.config" :config="session.config" :room="room" @config-update="configUpdate"></room-options>
- <room-stats ref="roomStatsDialog" :stats="stats" :room="room"></room-stats>
+ <room-options v-if="session.config" :config="session.config" :room="room" @config-update="configUpdate" ref="optionsDialog"></room-options>
+ <room-stats ref="statsDialog" :room="room"></room-stats>
</div>
</template>
<script>
- import { Modal, Dropdown } from 'bootstrap'
+ import { Dropdown } from 'bootstrap'
import { Media } from '../../js/meet/media.js'
import { Room as Meet } from '../../js/meet/room.js'
import { Roles } from '../../js/meet/constants.js'
+ import ModalDialog from '../Widgets/ModalDialog'
import StatusMessage from '../Widgets/StatusMessage'
import LogonForm from '../Login'
import RoomOptions from './RoomOptions'
import RoomStats from './RoomStats'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faComment').definition,
require('@fortawesome/free-solid-svg-icons/faCompress').definition,
require('@fortawesome/free-solid-svg-icons/faCrown').definition,
require('@fortawesome/free-solid-svg-icons/faDesktop').definition,
require('@fortawesome/free-solid-svg-icons/faExpand').definition,
require('@fortawesome/free-solid-svg-icons/faHand').definition,
require('@fortawesome/free-solid-svg-icons/faHeadphones').definition,
require('@fortawesome/free-solid-svg-icons/faGear').definition,
require('@fortawesome/free-solid-svg-icons/faKey').definition,
require('@fortawesome/free-solid-svg-icons/faMicrophone').definition,
require('@fortawesome/free-solid-svg-icons/faMicrophoneLines').definition,
require('@fortawesome/free-solid-svg-icons/faMicrophoneSlash').definition,
require('@fortawesome/free-solid-svg-icons/faPowerOff').definition,
require('@fortawesome/free-solid-svg-icons/faUser').definition,
require('@fortawesome/free-solid-svg-icons/faUsers').definition,
require('@fortawesome/free-solid-svg-icons/faVideo').definition,
require('@fortawesome/free-solid-svg-icons/faVideoSlash').definition,
require('@fortawesome/free-solid-svg-icons/faVolumeMute').definition,
)
let roomRequest
- let statsRequest
const authHeader = 'X-Meet-Auth-Token'
export default {
components: {
LogonForm,
+ ModalDialog,
RoomOptions,
RoomStats,
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: {},
- stats: {},
audioActive: false,
videoActive: false,
chatActive: false,
handRaised: false,
screenActive: false
}
},
mounted() {
this.room = this.$route.params.room
// Initialize Meet client 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' })
+ this.$refs.leaveDialog.events({
+ hide: () => {
+ // 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.setupSession() })
- dialog.addEventListener('hide.bs.modal', () => { this.meet.setupStop() })
-
- this.roomStatsDialog = new Modal('#room-stats-dialog')
+ this.$refs.setupDialog.events({
+ show: () => { this.setupSession() },
+ hide: () => { this.meet.setupStop() }
+ })
},
beforeDestroy() {
clearTimeout(roomRequest)
- clearInterval(statsRequest)
$('#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)
},
- async refreshStats() {
- let stats = await this.meet.getStats()
- this.stats = stats
- },
initSession(init) {
const button = $('#join-button').prop('disabled', true)
const 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/meet/rooms/' + this.room, 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()
}
})
.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)
},
// 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)
- clearInterval(statsRequest)
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.toast = this.$toast
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 every other reason
if (event.reason == 'session-closed' && !this.isRoomOwner()) {
- new Modal('#leave-dialog').show()
+ this.$refs.leaveDialog.show()
}
}
this.session.onUpdate = data => { this.updateSession(data) }
this.session.onMediaSetup = () => { this.setupMedia() }
this.meet.joinRoom(this.session)
- this.refreshStats()
-
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()
}
}
// Show stats with '?' key
if (e.key == '?') {
- this.roomStats()
+ this.$refs.statsDialog.toggle(this.meet)
}
})
},
logout() {
this.meet.leaveRoom(true)
this.meet = null
this.$router.push({ name: 'dashboard' })
},
makePicture() {
return (new Media()).makePicture($('#meet-setup video')[0]) || '';
},
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()
},
- roomStats() {
- clearInterval(statsRequest)
- if (this.roomStatsDialog.visible) {
- this.roomStatsDialog.hide()
- } else {
- this.refreshStats()
- statsRequest = setInterval(() => {
- this.refreshStats()
- }, 3000)
- this.roomStatsDialog.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()
+ this.$refs.setupDialog.show()
},
async setupSession() {
this.meet.setupStart({
videoElement: $('#meet-setup video')[0] || $('#media-setup-dialog video')[0],
volumeElement: $('#meet-setup .volume')[0] || $('#media-setup-dialog .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 => {
console.warn("Media setup failed: ", error);
this.audioActive = false
this.videoActive = false
}
})
},
async setupCameraChange() {
this.videoActive = await this.meet.setupSetVideoDevice(this.camera)
},
async setupMicrophoneChange() {
this.audioActive = await this.meet.setupSetAudioDevice(this.microphone)
},
switchChannel(e) {
this.meet.switchChannel($(e.target).data('code'))
// FIXME: Why is the menu not closing by itself?
new Dropdown('#meet-session-menu .link-channel').hide()
},
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()
}
},
async switchHand() {
this.handRaised = await this.meet.raiseHand(!this.handRaised)
},
async switchSound() {
this.audioActive = await this.meet.switchAudio()
},
async switchVideo() {
this.videoActive = await this.meet.switchVideo()
},
async switchScreen() {
this.screenActive = await this.meet.switchScreen()
},
updateSession(data) {
this.session = Object.assign({}, 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.raisedHand
}
}
}
</script>
diff --git a/src/resources/vue/Meet/RoomOptions.vue b/src/resources/vue/Meet/RoomOptions.vue
index bbf5ff9c..0dd81d2c 100644
--- a/src/resources/vue/Meet/RoomOptions.vue
+++ b/src/resources/vue/Meet/RoomOptions.vue
@@ -1,112 +1,105 @@
<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>
- <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">
- <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">
- <btn class="btn-secondary modal-action" data-bs-dismiss="modal">{{ $t('btn.close') }}</btn>
- </div>
- </div>
+ <modal-dialog v-if="config" id="room-options-dialog" ref="dialog" :title="$t('meet.options')">
+ <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">
+ <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>
- </div>
- </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>
+ </modal-dialog>
</template>
<script>
+ import ModalDialog from '../Widgets/ModalDialog'
+
export default {
+ components: {
+ ModalDialog
+ },
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 = { [name]: value }
axios.post('/api/v4/meet/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()
+ },
+ show() {
+ this.$refs.dialog.show()
}
}
}
</script>
diff --git a/src/resources/vue/Meet/RoomStats.vue b/src/resources/vue/Meet/RoomStats.vue
index 330951c1..a2fb4f9d 100644
--- a/src/resources/vue/Meet/RoomStats.vue
+++ b/src/resources/vue/Meet/RoomStats.vue
@@ -1,64 +1,81 @@
<template>
- <div id="room-stats-dialog" class="modal" tabindex="-1" role="dialog">
- <div class="modal-dialog" role="document">
- <div class="modal-content" style="max-height: 90vh">
- <div class="modal-header">
- <h5 class="modal-title">Statistics</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
- </div>
- <div class="modal-body overflow-auto">
- <ul class="list-group list-group-flush">
- <li class="list-group-item">
- <span class="fw-bold">Room Id:</span>
- <pre class="text-muted m-0">{{ room }}</pre>
- </li>
- <li class="list-group-item">
- <span class="fw-bold">Mediaserver Room Id:</span>
- <pre class="text-muted m-0">{{ stats.roomId }}</pre>
- </li>
- <li class="list-group-item">
- <span class="fw-bold">Send Transport state:</span>
- <pre class="text-muted m-0">{{ stats.sendTransportState }}</pre>
- <pre class="text-muted m-0">{{ toText(stats.sendTransportStats) }}</pre>
- </li>
- <li class="list-group-item">
- <span class="fw-bold">Receive Transport state:</span>
- <pre class="text-muted m-0">{{ stats.receiveTransportState }}</pre>
- <pre class="text-muted m-0">{{ toText(stats.receiveTransportStats) }}</pre>
- </li>
- <li class="list-group-item">
- <span class="fw-bold">Consumers:</span>
- <pre class="text-muted m-0">{{ toText(stats.consumerStats) }}</pre>
- </li>
- <li class="list-group-item">
- <span class="fw-bold">Camera Producer:</span>
- <pre class="text-muted m-0">{{ toText(stats.camProducerStats) }}</pre>
- </li>
- <li class="list-group-item">
- <span class="fw-bold">Mic Producer:</span>
- <pre class="text-muted m-0">{{ toText(stats.micProducerStats) }}</pre>
- </li>
- <li class="list-group-item">
- <span class="fw-bold">Screen Producer:</span>
- <pre class="text-muted m-0">{{ toText(stats.screenProducerStats) }}</pre>
- </li>
- </ul>
- </div>
- </div>
- </div>
- </div>
+ <modal-dialog id="room-stats-dialog" ref="dialog" title="Statistics">
+ <p>
+ <span class="fw-bold">Room Id:</span>
+ <pre class="text-muted m-0">{{ room }}</pre>
+ </p>
+ <p>
+ <span class="fw-bold">Mediaserver Room Id:</span>
+ <pre class="text-muted m-0">{{ stats.roomId }}</pre>
+ </p>
+ <p>
+ <span class="fw-bold">Sender Transport:</span>
+ <pre class="text-muted m-0">{{ stats.sendTransportState }}</pre>
+ <pre class="text-muted m-0">{{ toText(stats.sendTransportStats) }}</pre>
+ </p>
+ <p>
+ <span class="fw-bold">Receiver Transport:</span>
+ <pre class="text-muted m-0">{{ stats.receiveTransportState }}</pre>
+ <pre class="text-muted m-0">{{ toText(stats.receiveTransportStats) }}</pre>
+ </p>
+ <p>
+ <span class="fw-bold">Consumers:</span>
+ <pre class="text-muted m-0">{{ toText(stats.consumerStats) }}</pre>
+ </p>
+ <p>
+ <span class="fw-bold">Camera Producer:</span>
+ <pre class="text-muted m-0">{{ toText(stats.camProducerStats) }}</pre>
+ </p>
+ <p>
+ <span class="fw-bold">Mic Producer:</span>
+ <pre class="text-muted m-0">{{ toText(stats.micProducerStats) }}</pre>
+ </p>
+ <p>
+ <span class="fw-bold">Screen Producer:</span>
+ <pre class="text-muted m-0">{{ toText(stats.screenProducerStats) }}</pre>
+ </p>
+ </modal-dialog>
</template>
<script>
+ import ModalDialog from '../Widgets/ModalDialog'
+
export default {
+ components: {
+ ModalDialog
+ },
props: {
- stats: { type: Object, default: () => null },
room: { type: String, default: () => null }
},
+ data() {
+ return {
+ stats: {}
+ }
+ },
+ mounted() {
+ this.$refs.dialog.events({
+ show: () => {
+ clearInterval(this.statsRequest)
+ this.refreshStats()
+ this.statsRequest = setInterval(() => { this.refreshStats() }, 3000)
+ },
+ hide: () => {
+ clearInterval(this.statsRequest)
+ }
+ })
+ },
methods: {
+ async refreshStats() {
+ let stats = await this.meet.getStats()
+ this.stats = stats
+ },
+ toggle(meet) {
+ this.meet = meet
+ this.$refs.dialog[this.statsRequest ? 'hide' : 'show']()
+ },
toText(o) {
return JSON.stringify(o, null, ' ')
}
}
}
</script>
diff --git a/src/resources/vue/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue
index 63cbe7c3..3a32086a 100644
--- a/src/resources/vue/Reseller/Invitations.vue
+++ b/src/resources/vue/Reseller/Invitations.vue
@@ -1,220 +1,204 @@
<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>
- <btn class="btn-success create-invite ms-1" @click="inviteUserDialog" icon="envelope-open-text">{{ $t('invitation.create') }}</btn>
+ <btn class="btn-success create-invite ms-1" @click="$refs.createDialog.show()" icon="envelope-open-text">{{ $t('invitation.create') }}</btn>
</div>
<list-table id="invitations-list" :list="invitations" :setup="setup">
<template #email="{ item }">
<svg-icon icon="envelope-open-text" :class="statusClass(item)" :title="$t('invitation.status-' + statusLabel(item))"></svg-icon>
&nbsp;<span>{{ item.email }}</span>
</template>
<template #buttons="{ item }">
<btn class="text-danger button-delete p-0 ms-1" @click="deleteInvite(item.id)" icon="trash-can">
<span class="btn-label">{{ $t('btn.delete') }}</span>
</btn>
<btn class="button-resend p-0 ms-1" :disabled="item.isNew || item.isCompleted" @click="resendInvite(item.id)" icon="rotate-left">
<span class="btn-label">{{ $t('btn.resend') }}</span>
</btn>
</template>
</list-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>
- <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">
- <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>
+ <modal-dialog id="invite-create" ref="createDialog" :title="$t('invitation.create-title')" @click="inviteUser()"
+ :buttons="[{ className: 'btn-primary modal-action', icon: 'paper-plane', label: 'invitation.send' }]"
+ >
+ <form>
+ <p>{{ $t('invitation.create-email') }}</p>
+ <div>
+ <input id="email" type="text" class="form-control" name="email">
</div>
- </div>
- </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>
+ </modal-dialog>
</div>
</template>
<script>
- import { Modal } from 'bootstrap'
import ListTools from '../Widgets/ListTools'
+ import ModalDialog from '../Widgets/ModalDialog'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faEnvelopeOpenText').definition,
require('@fortawesome/free-solid-svg-icons/faPaperPlane').definition,
require('@fortawesome/free-solid-svg-icons/faRotateLeft').definition,
)
export default {
+ components: {
+ ModalDialog
+ },
mixins: [ ListTools ],
data() {
return {
invitations: [],
setup: {
buttons: true,
model: 'invitation',
columns: [
{
prop: 'email',
label: 'user.ext-email',
className: 'email',
contentSlot: 'email'
},
{
prop: 'created',
className: 'datetime'
}
]
}
}
},
mounted() {
this.loadInvitations({ init: true })
- $('#invite-create')[0].addEventListener('shown.bs.modal', event => {
- $('input', event.target).first().focus()
+ this.$refs.createDialog.events({
+ show: (event) => {
+ const form = $(event.target).find('form')
+ form.get(0).reset()
+ this.fileChange({ target: form.find('#file')[0] }) // resets file input label
+ }
})
},
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.$refs.createDialog.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/User/Info.vue b/src/resources/vue/User/Info.vue
index ec989702..1ea85ed6 100644
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -1,322 +1,308 @@
<template>
<div class="container">
<status-component v-if="user_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="user-info">
<div class="card-body">
<div class="card-title" v-if="user_id !== 'new'">{{ $t('user.title') }}
<btn icon="trash-can" class="btn-outline-danger button-delete float-end" @click="showDeleteConfirmation()">
{{ $t('user.delete') }}
</btn>
</div>
<div class="card-title" v-if="user_id === 'new'">{{ $t('user.new') }}</div>
<div class="card-text">
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
{{ $t('form.general') }}
</a>
</li>
<li v-if="user_id !== 'new'" class="nav-item">
<a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="user_id !== 'new'" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(user) + ' form-control-plaintext'" id="status">{{ $root.statusText(user) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="first_name" v-model="user.first_name">
</div>
</div>
<div class="row mb-3">
<label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="last_name" v-model="user.last_name">
</div>
</div>
<div class="row mb-3">
<label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="organization" v-model="user.organization">
</div>
</div>
<div class="row mb-3">
<label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="email" :disabled="user_id !== 'new'" required v-model="user.email">
</div>
</div>
<div class="row mb-3">
<label for="aliases-input" class="col-sm-4 col-form-label">{{ $t('user.aliases-email') }}</label>
<div class="col-sm-8">
<list-input id="aliases" :list="user.aliases"></list-input>
</div>
</div>
<div class="row mb-3">
<label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
<div class="col-sm-8">
<div v-if="!isSelf" class="btn-group w-100" role="group">
<input type="checkbox" id="pass-mode-input" value="input" class="btn-check" @change="setPasswordMode" :checked="passwordMode == 'input'">
<label class="btn btn-outline-secondary" for="pass-mode-input">{{ $t('user.pass-input') }}</label>
<input type="checkbox" id="pass-mode-link" value="link" class="btn-check" @change="setPasswordMode">
<label class="btn btn-outline-secondary" for="pass-mode-link">{{ $t('user.pass-link') }}</label>
</div>
<password-input v-if="passwordMode == 'input'" :class="isSelf ? '' : 'mt-2'" v-model="user"></password-input>
<div id="password-link" v-if="passwordMode == 'link' || user.passwordLinkCode" class="mt-2">
<span>{{ $t('user.pass-link-label') }}</span>&nbsp;<code>{{ passwordLink }}</code>
<span class="d-inline-block">
<btn class="btn-link p-1" :icon="['far', 'clipboard']" :title="$t('btn.copy')" @click="passwordLinkCopy"></btn>
<btn v-if="user.passwordLinkCode" class="btn-link text-danger p-1" icon="trash-can" :title="$t('btn.delete')" @click="passwordLinkDelete"></btn>
</span>
<div v-if="!user.passwordLinkCode" class="form-text m-0">{{ $t('user.pass-link-hint') }}</div>
</div>
</div>
</div>
<div v-if="user_id === 'new'" id="user-packages" class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('user.package') }}</label>
<package-select class="col-sm-8 pt-sm-1"></package-select>
</div>
<div v-if="user_id !== 'new'" id="user-skus" class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('user.subscriptions') }}</label>
<subscription-select v-if="user.id" class="col-sm-8 pt-sm-1" :object="user"></subscription-select>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<form @submit.prevent="submitSettings" class="card-body">
<div class="row checkbox mb-3">
<label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
<div class="col-sm-8 pt-2">
<input type="checkbox" id="greylist_enabled" name="greylist_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.greylist_enabled">
<small id="greylisting-hint" class="text-muted">
{{ $t('user.greylisting-text') }}
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
- <div id="delete-warning" class="modal" tabindex="-1" role="dialog">
- <div class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title">{{ $t('user.delete-email', { email: user.email }) }}</h5>
- <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
- </div>
- <div class="modal-body">
- <p>{{ $t('user.delete-text') }}</p>
- </div>
- <div class="modal-footer">
- <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
- <btn class="btn-danger modal-action" icon="trash-can" @click="deleteUser()">{{ $t('btn.delete') }}</btn>
- </div>
- </div>
- </div>
- </div>
+ <modal-dialog id="delete-warning" ref="deleteWarning" :buttons="['delete']" :cancel-focus="true" @click="deleteUser()"
+ :title="$t('user.delete-email', { email: user.email })"
+ >
+ <p>{{ $t('user.delete-text') }}</p>
+ </modal-dialog>
</div>
</template>
<script>
- import { Modal } from 'bootstrap'
import ListInput from '../Widgets/ListInput'
+ import ModalDialog from '../Widgets/ModalDialog'
import PackageSelect from '../Widgets/PackageSelect'
import PasswordInput from '../Widgets/PasswordInput'
import StatusComponent from '../Widgets/Status'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-regular-svg-icons/faClipboard').definition,
)
export default {
components: {
ListInput,
+ ModalDialog,
PackageSelect,
PasswordInput,
StatusComponent,
SubscriptionSelect
},
data() {
return {
passwordLinkCode: '',
passwordMode: '',
user_id: null,
user: { aliases: [], config: [] },
status: {}
}
},
computed: {
isSelf: function () {
return this.user_id == this.$root.authInfo.id
},
passwordLink: function () {
return this.$root.appUrl + '/password-reset/' + this.passwordLinkCode
}
},
created() {
this.user_id = this.$route.params.user
if (this.user_id !== 'new') {
axios.get('/api/v4/users/' + this.user_id, { loader: true })
.then(response => {
this.user = response.data
this.user.first_name = response.data.settings.first_name
this.user.last_name = response.data.settings.last_name
this.user.organization = response.data.settings.organization
this.status = response.data.statusInfo
this.passwordLinkCode = this.user.passwordLinkCode
})
.catch(this.$root.errorHandler)
if (this.isSelf) {
this.passwordMode = 'input'
}
} else {
this.passwordMode = 'input'
}
},
mounted() {
$('#first_name').focus()
- $('#delete-warning')[0].addEventListener('shown.bs.modal', event => {
- $(event.target).find('button.modal-cancel').focus()
- })
},
methods: {
passwordLinkCopy() {
navigator.clipboard.writeText($('#password-link code').text());
},
passwordLinkDelete() {
this.passwordMode = ''
$('#pass-mode-link')[0].checked = false
// Delete the code for real
axios.delete('/api/v4/password-reset/code/' + this.passwordLinkCode)
.then(response => {
this.passwordLinkCode = ''
this.user.passwordLinkCode = ''
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
}
})
},
setPasswordMode(event) {
const mode = event.target.checked ? event.target.value : ''
// In the "new user" mode the password mode cannot be unchecked
if (!mode && this.user_id === 'new') {
event.target.checked = true
return
}
this.passwordMode = mode
if (!event.target.checked) {
return
}
$('#pass-mode-' + (mode == 'link' ? 'input' : 'link'))[0].checked = false
// Note: we use $nextTick() because we have to wait for the HTML elements to exist
this.$nextTick().then(() => {
if (mode == 'link' && !this.passwordLinkCode) {
axios.post('/api/v4/password-reset/code', {}, { loader: '#password-link' })
.then(response => {
this.passwordLinkCode = response.data.short_code + '-' + response.data.code
})
} else if (mode == 'input') {
$('#password').focus();
}
})
},
submit() {
this.$root.clearFormValidation($('#general form'))
let method = 'post'
let location = '/api/v4/users'
let post = this.$root.pick(this.user, ['aliases', 'email', 'first_name', 'last_name', 'organization'])
if (this.user_id !== 'new') {
method = 'put'
location += '/' + this.user_id
let skus = {}
$('#user-skus input[type=checkbox]:checked').each((idx, input) => {
let id = $(input).val()
let range = $(input).parents('tr').first().find('input[type=range]').val()
skus[id] = range || 1
})
post.skus = skus
} else {
post.package = $('#user-packages input:checked').val()
}
if (this.passwordMode == 'link' && this.passwordLinkCode) {
post.passwordLinkCode = this.passwordLinkCode
} else if (this.passwordMode == 'input') {
post.password = this.user.password
post.password_confirmation = this.user.password_confirmation
}
axios[method](location, post)
.then(response => {
if (response.data.statusInfo) {
this.$root.authInfo.statusInfo = response.data.statusInfo
}
this.$toast.success(response.data.message)
this.$router.push({ name: 'users' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
let post = { greylist_enabled: $('#greylist_enabled').prop('checked') ? 1 : 0 }
axios.post('/api/v4/users/' + this.user_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
},
statusUpdate(user) {
this.user = Object.assign({}, this.user, user)
},
deleteUser() {
// Delete the user from the confirm dialog
axios.delete('/api/v4/users/' + this.user_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'users' })
}
})
},
showDeleteConfirmation() {
if (this.user_id == this.$root.authInfo.id) {
// Deleting self, redirect to /profile/delete page
this.$router.push({ name: 'profile-delete' })
} else {
// Display the warning
- new Modal('#delete-warning').show()
+ this.$refs.deleteWarning.show()
}
}
}
}
</script>
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
index 91b8a0fc..abc7ecb5 100644
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -1,430 +1,426 @@
<template>
<div class="container" dusk="wallet-component">
<div v-if="wallet.id" id="wallet" class="card">
<div class="card-body">
<div class="card-title">{{ $t('wallet.title') }} <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'">{{ $root.price(wallet.balance, wallet.currency) }}</span></div>
<div class="card-text">
<p v-if="wallet.notice" id="wallet-notice">{{ wallet.notice }}</p>
<div v-if="showPendingPayments" class="alert alert-warning">
{{ $t('wallet.pending-payments-warning') }}
</div>
<p>
<btn class="btn-primary" @click="paymentMethodForm('manual')">{{ $t('wallet.add-credit') }}</btn>
</p>
<div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending">
<template v-if="mandate.id && !mandate.isValid">
<div class="alert alert-danger">
{{ $t('wallet.auto-payment-failed') }}
</div>
<btn class="btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</btn>
</template>
<btn class="btn-primary" @click="paymentMethodForm('auto')">{{ $t('wallet.auto-payment-setup') }}</btn>
</div>
<div id="mandate-info" v-else>
<div v-if="mandate.isDisabled" class="disabled-mandate alert alert-danger">
{{ $t('wallet.auto-payment-disabled') }}
</div>
<template v-else>
<p v-html="$t('wallet.auto-payment-info', { amount: mandate.amount + ' ' + wallet.currency, balance: mandate.balance + ' ' + wallet.currency})"></p>
<p>{{ $t('wallet.payment-method', { method: mandate.method }) }}</p>
</template>
<div v-if="mandate.isPending" class="alert alert-warning">
{{ $t('wallet.auto-payment-inprogress') }}
</div>
<p class="buttons">
<btn class="btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</btn>
<btn class="btn-primary" @click="autoPaymentChange">{{ $t('wallet.auto-payment-change') }}</btn>
</p>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-receipts" href="#wallet-receipts" role="tab" aria-controls="wallet-receipts" aria-selected="true">
{{ $t('wallet.receipts') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-history" href="#wallet-history" role="tab" aria-controls="wallet-history" aria-selected="false">
{{ $t('wallet.history') }}
</a>
</li>
<li v-if="showPendingPayments" class="nav-item">
<a class="nav-link" id="tab-payments" href="#wallet-payments" role="tab" aria-controls="wallet-payments" aria-selected="false">
{{ $t('wallet.pending-payments') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="wallet-receipts" role="tabpanel" aria-labelledby="tab-receipts">
<div class="card-body">
<div class="card-text">
<p v-if="receipts.length">
{{ $t('wallet.receipts-hint') }}
</p>
<div v-if="receipts.length" class="input-group">
<select id="receipt-id" class="form-control">
<option v-for="(receipt, index) in receipts" :key="index" :value="receipt">{{ receipt }}</option>
</select>
<btn class="btn-secondary" @click="receiptDownload" icon="download">{{ $t('btn.download') }}</btn>
</div>
<p v-if="!receipts.length">
{{ $t('wallet.receipts-none') }}
</p>
</div>
</div>
</div>
<div class="tab-pane" id="wallet-history" role="tabpanel" aria-labelledby="tab-history">
<div class="card-body">
<transaction-log v-if="walletId && loadTransactions" class="card-text" :wallet-id="walletId"></transaction-log>
</div>
</div>
<div class="tab-pane" id="wallet-payments" role="tabpanel" aria-labelledby="tab-payments">
<div class="card-body">
<payment-log v-if="walletId && loadPayments" class="card-text" :wallet-id="walletId"></payment-log>
</div>
</div>
</div>
- <div id="payment-dialog" class="modal" tabindex="-1" role="dialog">
- <div class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title">{{ paymentDialogTitle }}</h5>
- <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
+ <modal-dialog id="payment-dialog" ref="paymentDialog" :title="paymentDialogTitle" @click="payment" :buttons="dialogButtons">
+ <div id="payment-method" v-if="paymentForm == 'method'">
+ <form data-validation-prefix="mandate_">
+ <div id="payment-method-selection">
+ <a v-for="method in paymentMethods" :key="method.id" @click="selectPaymentMethod(method)" href="#" :class="'card link-' + method.id">
+ <svg-icon v-if="method.icon" :icon="[method.icon.prefix, method.icon.name]" />
+ <img v-if="method.image" :src="method.image" />
+ <span class="name">{{ method.name }}</span>
+ </a>
</div>
- <div class="modal-body">
- <div id="payment-method" v-if="paymentForm == 'method'">
- <form data-validation-prefix="mandate_">
- <div id="payment-method-selection">
- <a v-for="method in paymentMethods" :key="method.id" @click="selectPaymentMethod(method)" href="#" :class="'card link-' + method.id">
- <svg-icon v-if="method.icon" :icon="[method.icon.prefix, method.icon.name]" />
- <img v-if="method.image" :src="method.image" />
- <span class="name">{{ method.name }}</span>
- </a>
- </div>
- </form>
- </div>
- <div id="manual-payment" v-if="paymentForm == 'manual'">
- <p v-if="wallet.currency != selectedPaymentMethod.currency">
- {{ $t('wallet.currency-conv', { wc: wallet.currency, pc: selectedPaymentMethod.currency }) }}
- </p>
- <p v-if="selectedPaymentMethod.id == 'banktransfer'">
- {{ $t('wallet.banktransfer-hint') }}
- </p>
- <p>
- {{ $t('wallet.payment-amount-hint') }}
- </p>
- <form id="payment-form" @submit.prevent="payment">
- <div class="input-group">
- <input type="text" class="form-control" id="amount" v-model="amount" required>
- <span class="input-group-text">{{ wallet.currency }}</span>
- </div>
- <div v-if="wallet.currency != selectedPaymentMethod.currency && !isNaN(amount)" class="alert alert-warning m-0 mt-3">
- {{ $t('wallet.payment-warning', { price: $root.price(amount * selectedPaymentMethod.exchangeRate * 100, selectedPaymentMethod.currency) }) }}
- </div>
- </form>
+ </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 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 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>
- <div class="modal-footer">
- <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
- <btn class="btn-primary modal-action" icon="check" @click="autoPayment"
- v-if="paymentForm == 'auto' && (mandate.isValid || mandate.isPending)"
- >
- {{ $t('btn.submit') }}
- </btn>
- <btn class="btn btn-primary modal-action" icon="check" @click="autoPayment"
- v-if="paymentForm == 'auto' && !mandate.isValid && !mandate.isPending"
- >
- {{ $t('btn.continue') }}
- </btn>
- <btn class="btn-primary modal-action" icon="check" @click="payment"
- v-if="paymentForm == 'manual'"
- >
- {{ $t('btn.continue') }}
- </btn>
+ <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>
- </div>
+ </form>
</div>
- </div>
+ </modal-dialog>
</div>
</template>
<script>
- import { Modal } from 'bootstrap'
+ import ModalDialog from './Widgets/ModalDialog'
import TransactionLog from './Widgets/TransactionLog'
import PaymentLog from './Widgets/PaymentLog'
import { downloadFile } from '../js/utils'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-brands-svg-icons/faPaypal').definition,
require('@fortawesome/free-regular-svg-icons/faCreditCard').definition,
require('@fortawesome/free-solid-svg-icons/faBuildingColumns').definition,
)
export default {
components: {
+ ModalDialog,
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
}
},
+ computed: {
+ dialogButtons() {
+ if (this.paymentForm == 'method') {
+ return []
+ }
+
+ const button = {
+ className: 'btn-primary modal-action',
+ icon: 'check',
+ label: 'btn.submit'
+ }
+
+ if (this.paymentForm == 'manual'
+ || (this.paymentForm == 'auto' && !this.mandate.isValid && !this.mandate.isPending)
+ ) {
+ button.label = 'btn.continue'
+ }
+
+ return [ button ]
+ }
+ },
mounted() {
$('#wallet button').focus()
this.walletId = this.$root.authInfo.wallets[0].id
axios.get('/api/v4/wallets/' + this.walletId, { loader: true })
.then(response => {
this.wallet = response.data
axios.get('/api/v4/wallets/' + this.walletId + '/receipts', { loader: '#wallet-receipts' })
.then(response => {
this.receipts = response.data.list
})
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 loader = '#mandate-form'
this.$root.stopLoading(loader)
if (!this.mandate.id || this.mandate.isPending) {
axios.get('/api/v4/payments/mandate', { loader })
.then(response => {
this.mandate = response.data
})
}
},
selectPaymentMethod(method) {
this.formLock = false
this.selectedPaymentMethod = method
this.paymentForm = this.nextForm
setTimeout(() => { $('#payment-dialog').find('#amount,#mandate_amount').focus() }, 10)
},
payment() {
+ if (this.paymentForm == 'auto') {
+ return this.autoPayment()
+ }
+
if (this.formLock) {
return
}
// Lock the form to prevent from double submission
this.formLock = true
let onFinish = () => { this.formLock = false }
this.$root.clearFormValidation($('#payment-form'))
const post = {
amount: this.amount,
methodId: this.selectedPaymentMethod.id,
currency: this.selectedPaymentMethod.currency
}
axios.post('/api/v4/payments', post, { onFinish })
.then(response => {
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
} else {
this.stripeCheckout(response.data)
}
})
},
autoPayment() {
if (this.formLock) {
return
}
// Lock the form to prevent from double submission
this.formLock = true
let onFinish = () => { this.formLock = false }
const method = this.mandate.id && (this.mandate.isValid || this.mandate.isPending) ? 'put' : 'post'
let post = {
amount: this.mandate.amount,
balance: this.mandate.balance,
}
// Modifications can't change the method of payment
if (this.selectedPaymentMethod) {
post.methodId = this.selectedPaymentMethod.id
post.currency = this.selectedPaymentMethod.currency
}
this.$root.clearFormValidation($('#auto-payment form'))
axios[method]('/api/v4/payments/mandate', post, { onFinish })
.then(response => {
if (method == 'post') {
this.mandate.id = null
// a new mandate, redirect to the chackout page
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
} else if (response.data.id) {
this.stripeCheckout(response.data)
}
} else {
// an update
if (response.data.status == 'success') {
- this.dialog.hide();
+ this.$refs.paymentDialog.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.$refs.paymentDialog.show()
this.$nextTick().then(() => {
const type = nextForm == 'manual' ? 'oneoff' : 'recurring'
const loader = ['#payment-method', { 'min-height': '10em', small: false }]
axios.get('/api/v4/payments/methods', { params: { type }, loader })
.then(response => {
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()
+ this.$refs.paymentDialog.show()
},
receiptDownload() {
const receipt = $('#receipt-id').val()
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/CompanionappList.vue b/src/resources/vue/Widgets/CompanionappList.vue
index 12bd4359..6743c533 100644
--- a/src/resources/vue/Widgets/CompanionappList.vue
+++ b/src/resources/vue/Widgets/CompanionappList.vue
@@ -1,78 +1,62 @@
<template>
<div>
- <btn icon="trash-can" class="btn-outline-danger button-delete float-end" @click="showDeleteConfirmation()">
+ <btn icon="trash-can" class="btn-outline-danger button-delete float-end" @click="$refs.deleteDialog.show()">
{{ $t('companion.delete') }}
</btn>
+
<list-table class="m-0" :list="entries" :setup="setup"></list-table>
<list-more v-if="hasMore" :on-click="loadMore"></list-more>
- <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('companion.remove-devices') }}</h5>
- <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
- </div>
- <div class="modal-body">
- <p>{{ $t('companion.remove-devices-text') }}</p>
- </div>
- <div class="modal-footer">
- <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
- <btn class="btn-danger modal-action" data-bs-dismiss="modal" @click="removeDevices()" icon="trash-can">{{ $t('btn.delete') }}</btn>
- </div>
- </div>
- </div>
- </div>
+
+ <modal-dialog id="delete-warning" ref="deleteDialog" :title="$t('companion.remove-devices')" @click="removeDevices()" :buttons="['delete']" :cancel-focus="true">
+ <p>{{ $t('companion.remove-devices-text') }}</p>
+ </modal-dialog>
</div>
</template>
<script>
- import { Modal } from 'bootstrap'
import ListTools from './ListTools'
+ import ModalDialog from './ModalDialog'
export default {
+ components: {
+ ModalDialog
+ },
mixins: [ ListTools ],
data() {
return {
entries: [],
setup: {
model: 'companion',
columns: [
{
prop: 'name'
},
{
prop: 'device_id',
label: 'companion.deviceid'
}
]
}
}
},
mounted() {
this.loadMore({ reset: true })
- $('#delete-warning')[0].addEventListener('shown.bs.modal', event => {
- $(event.target).find('button.modal-cancel').focus()
- })
},
methods: {
loadMore(params) {
this.listSearch('entries', '/api/v4/companion/', params)
},
removeDevices() {
axios.post('/api/v4/companion/revoke')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
}
this.loadMore({ reset: true })
})
.catch(this.$root.errorHandler)
- },
- showDeleteConfirmation() {
- // Display the warning
- new Modal('#delete-warning').show()
- },
+ }
}
}
</script>
diff --git a/src/resources/vue/Widgets/ModalDialog.vue b/src/resources/vue/Widgets/ModalDialog.vue
new file mode 100644
index 00000000..7381acb0
--- /dev/null
+++ b/src/resources/vue/Widgets/ModalDialog.vue
@@ -0,0 +1,90 @@
+<template>
+ <div class="modal" tabindex="-1" role="dialog" aria-hidden="true">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content" style="max-height: 90vh">
+ <div class="modal-header">
+ <h5 class="modal-title">{{ title }}</h5>
+ <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
+ </div>
+ <div class="modal-body overflow-auto">
+ <slot></slot>
+ </div>
+ <div class="modal-footer">
+ <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t(buttons.length ? 'btn.cancel' : 'btn.close') }}</btn>
+ <btn v-for="(button, index) in buttons" :key="index"
+ :class="btnProperty(button, 'className')"
+ :icon="btnProperty(button, 'icon')"
+ :data-bs-dismiss="btnProperty(button, 'dismiss')"
+ @click="$emit('click', $event)"
+ >
+ {{ $t(btnProperty(button, 'label')) }}
+ </btn>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import { Modal } from 'bootstrap'
+ import { clearFormValidation } from '../../js/utils'
+
+ // Common buttons
+ const buttonTypes = {
+ 'delete': {
+ className: 'btn-danger modal-action',
+ dismiss: 'modal',
+ label: 'btn.delete',
+ icon: 'trash-can'
+ },
+ submit: {
+ className: 'btn-primary modal-action',
+ label: 'btn.submit',
+ icon: 'check'
+ },
+ }
+
+ export default {
+ props: {
+ buttons: { type: Array, default: () => [] },
+ cancelFocus: { type: Boolean, default: false },
+ title: { type: String, default: '' }
+ },
+ mounted() {
+ this.$el.addEventListener('shown.bs.modal', event => {
+ clearFormValidation(event.target)
+
+ if (this.cancelFocus) {
+ $(event.target).find('button.modal-cancel').focus()
+ } else {
+ $(event.target).find('input,select').first().focus()
+ }
+ })
+
+ this.dialog = new Modal(this.$el)
+ },
+ methods: {
+ btnProperty(button, property) {
+ const isString = typeof button == 'string'
+ if (!isString && property in button) {
+ return button[property]
+ }
+
+ if (isString && button in buttonTypes && property in buttonTypes[button]) {
+ return buttonTypes[button][property]
+ }
+ },
+ events(events) {
+ for (const name in events) {
+ this.$el.addEventListener(name + '.bs.modal', events[name])
+ }
+ },
+ hide() {
+ this.dialog.hide()
+ },
+ show() {
+ this.dialog.show()
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Widgets/SupportForm.vue b/src/resources/vue/Widgets/SupportForm.vue
index 930d6e1c..bdf0cc92 100644
--- a/src/resources/vue/Widgets/SupportForm.vue
+++ b/src/resources/vue/Widgets/SupportForm.vue
@@ -1,115 +1,99 @@
<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>
- <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
- </div>
- <div class="modal-body">
- <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">
- <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>
+ <modal-dialog id="support-dialog" ref="dialog" :title="$t('support.title')" @click="submit" :buttons="['submit']">
+ <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>
+ </modal-dialog>
</template>
<script>
- import { Modal } from 'bootstrap'
+ import ModalDialog from '../Widgets/ModalDialog'
export default {
+ components: {
+ ModalDialog
+ },
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.$refs.dialog.events({
+ hide: () => {
+ this.lockForm(false)
+ if (this.cancelToken) {
+ this.cancelToken()
+ this.cancelToken = null
+ }
+ },
+ show: () => {
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()
+ show() {
+ this.$refs.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.$refs.dialog.hide()
this.$toast.success(response.data.message)
})
.catch(error => {
this.lockForm(false)
})
}
}
}
</script>
diff --git a/src/tests/Browser.php b/src/tests/Browser.php
index 2a59a87b..0b067216 100644
--- a/src/tests/Browser.php
+++ b/src/tests/Browser.php
@@ -1,299 +1,300 @@
<?php
namespace Tests;
use Facebook\WebDriver\WebDriverKeys;
use PHPUnit\Framework\Assert;
use Tests\Browser\Components\Error;
use Tests\Browser\Components\Toast;
/**
* Laravel Dusk Browser extensions
*/
class Browser extends \Laravel\Dusk\Browser
{
/**
* Assert that the given element attribute contains specified text.
*/
public function assertAttributeRegExp($selector, $attribute, $regexp)
{
$element = $this->resolver->findOrFail($selector);
$value = (string) $element->getAttribute($attribute);
$error = "No expected text in [$selector][$attribute]. Found: $value";
Assert::assertMatchesRegularExpression($regexp, $value, $error);
return $this;
}
/**
* Assert number of (visible) elements
*/
public function assertElementsCount($selector, $expected_count, $visible = true)
{
$elements = $this->elements($selector);
$count = count($elements);
if ($visible) {
foreach ($elements as $element) {
if (!$element->isDisplayed()) {
$count--;
}
}
}
Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $expected_count");
return $this;
}
/**
* Assert Tip element content
*/
public function assertTip($selector, $content)
{
return $this->click($selector)
->withinBody(function ($browser) use ($content) {
$browser->waitFor('div.tooltip .tooltip-inner')
->assertSeeIn('div.tooltip .tooltip-inner', $content);
})
->click($selector);
}
/**
* Assert Toast element content (and close it)
*/
public function assertToast(string $type, string $message, $title = null)
{
return $this->withinBody(function ($browser) use ($type, $title, $message) {
$browser->with(new Toast($type), function (Browser $browser) use ($title, $message) {
$browser->assertToastTitle($title)
->assertToastMessage($message)
->closeToast();
});
});
}
/**
* Assert specified error page is displayed.
*/
public function assertErrorPage(int $error_code, string $hint = '')
{
$this->with(new Error($error_code, $hint), function ($browser) {
// empty, assertions will be made by the Error component itself
});
return $this;
}
/**
* Assert that the given element has specified class assigned.
*/
public function assertHasClass($selector, $class_name)
{
$element = $this->resolver->findOrFail($selector);
$classes = explode(' ', (string) $element->getAttribute('class'));
Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'");
return $this;
}
/**
* Assert that the given element is readonly
*/
public function assertReadonly($selector)
{
$element = $this->resolver->findOrFail($selector);
$value = $element->getAttribute('readonly');
Assert::assertTrue($value == 'true', "Element [$selector] is not readonly");
return $this;
}
/**
* Assert that the given element is not readonly
*/
public function assertNotReadonly($selector)
{
$element = $this->resolver->findOrFail($selector);
$value = $element->getAttribute('readonly');
Assert::assertTrue($value != 'true', "Element [$selector] is not readonly");
return $this;
}
/**
* Assert that the given element contains specified text,
* no matter it's displayed or not.
*/
public function assertText($selector, $text)
{
$element = $this->resolver->findOrFail($selector);
if ($text === '') {
Assert::assertTrue((string) $element->getText() === $text, "Element's text is not empty [$selector]");
} else {
Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]");
}
return $this;
}
/**
* Assert that the given element contains specified text,
* no matter it's displayed or not - using a regular expression.
*/
public function assertTextRegExp($selector, $regexp)
{
$element = $this->resolver->findOrFail($selector);
Assert::assertMatchesRegularExpression($regexp, $element->getText(), "No expected text in [$selector]");
return $this;
}
/**
* Remove all toast messages
*/
public function clearToasts()
{
$this->script("\$('.toast-container > *').remove()");
return $this;
}
/**
* Wait until a button becomes enabled and click it
*/
public function clickWhenEnabled($selector)
{
return $this->waitFor($selector . ':not([disabled])')->click($selector);
}
/**
* Check if in Phone mode
*/
public static function isPhone()
{
return getenv('TESTS_MODE') == 'phone';
}
/**
* Check if in Tablet mode
*/
public static function isTablet()
{
return getenv('TESTS_MODE') == 'tablet';
}
/**
* Check if in Desktop mode
*/
public static function isDesktop()
{
return !self::isPhone() && !self::isTablet();
}
/**
* Returns content of a downloaded file
*/
public function readDownloadedFile($filename, $sleep = 5)
{
$filename = __DIR__ . "/Browser/downloads/$filename";
// Give the browser a chance to finish download
if (!file_exists($filename) && $sleep) {
sleep($sleep);
}
Assert::assertFileExists($filename);
return file_get_contents($filename);
}
/**
* Removes downloaded file
*/
public function removeDownloadedFile($filename)
{
@unlink(__DIR__ . "/Browser/downloads/$filename");
return $this;
}
/**
* Clears the input field and related vue v-model data.
*/
public function vueClear($selector)
{
$selector = $this->resolver->format($selector);
// The existing clear(), and type() with empty string do not work.
// We have to clear the field and dispatch 'input' event programatically.
$this->script(
"var element = document.querySelector('$selector');"
. "element.value = '';"
. "element.dispatchEvent(new Event('input'))"
);
return $this;
}
/**
* Execute code within body context.
* Useful to execute code that selects elements outside of a component context
*/
public function withinBody($callback)
{
if ($this->resolver->prefix != 'body') {
$orig_prefix = $this->resolver->prefix;
$this->resolver->prefix = 'body';
}
call_user_func($callback, $this);
if (isset($orig_prefix)) {
$this->resolver->prefix = $orig_prefix;
}
return $this;
}
/**
* Store the console output with the given name. Overwrites Dusk's method.
*
* @param string $name
* @return $this
*/
public function storeConsoleLog($name)
{
if (in_array($this->driver->getCapabilities()->getBrowserName(), static::$supportsRemoteLogs)) {
$console = $this->driver->manage()->getLog('browser');
// Ignore errors/warnings irrelevant for testing
foreach ($console as $idx => $entry) {
if (
$entry['level'] != 'SEVERE'
|| strpos($entry['message'], 'Failed to load resource: the server responded with a status of')
+ || preg_match('/^\S+\.js [0-9]+:[0-9]+\s*$/', $entry['message'])
) {
$console[$idx] = null;
}
}
$console = array_values(array_filter($console));
if (!empty($console)) {
$file = sprintf('%s/%s.log', rtrim(static::$storeConsoleLogAt, '/'), $name);
$content = json_encode($console, JSON_PRETTY_PRINT);
file_put_contents($file, $content);
}
}
return $this;
}
}
diff --git a/src/tests/Browser/Meet/RoomOptionsTest.php b/src/tests/Browser/Meet/RoomOptionsTest.php
index 19feb4f4..c16b0104 100644
--- a/src/tests/Browser/Meet/RoomOptionsTest.php
+++ b/src/tests/Browser/Meet/RoomOptionsTest.php
@@ -1,326 +1,326 @@
<?php
namespace Tests\Browser\Meet;
use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
class RoomOptionsTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->setupTestRoom();
}
public function tearDown(): void
{
$this->resetTestRoom();
parent::tearDown();
}
/**
* Test password protected room
*
* @group meet
*/
public function testRoomPassword(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
$room = Room::where('name', 'john')->first();
// Join the room as an owner (authenticate)
$owner->visit(new RoomPage('john'))
->click('@setup-button')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-password-input')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
// Enter room option dialog
->click('@menu button.link-options')
->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) {
$browser->assertSeeIn('@title', 'Room options')
- ->assertSeeIn('@button-action', 'Close')
+ ->assertSeeIn('@button-cancel', 'Close')
->assertElementsCount('.modal-footer button', 1)
->assertSeeIn('#password-input .label', 'Password:')
->assertSeeIn('#password-input-text.text-muted', 'none')
->assertVisible('#password-input + small')
->assertSeeIn('#password-set-btn', 'Set password')
->assertElementsCount('#password-input button', 1)
->assertMissing('#password-input input')
// Test setting a password
->click('#password-set-btn')
->assertMissing('#password-input-text')
->assertVisible('#password-input input')
->assertValue('#password-input input', '')
->assertSeeIn('#password-input #password-save-btn', 'Save')
->assertElementsCount('#password-input button', 1)
->type('#password-input input', 'pass')
->click('#password-input #password-save-btn')
->assertToast(Toast::TYPE_SUCCESS, 'Room configuration updated successfully.')
->assertMissing('#password-input input')
->assertSeeIn('#password-input-text:not(.text-muted)', 'pass')
->assertSeeIn('#password-clear-btn.btn-outline-danger', 'Clear password')
->assertElementsCount('#password-input button', 1)
- ->click('@button-action');
+ ->click('@button-cancel');
$this->assertSame('pass', $room->fresh()->getSetting('password'));
});
// In another browser act as a guest, expect password required
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertSeeIn('@setup-status-message', "Please, provide a valid password.")
->assertVisible('@setup-form .input-group:nth-child(4) svg')
->assertAttribute('@setup-form .input-group:nth-child(4) .input-group-text', 'title', 'Password')
->assertAttribute('@setup-password-input', 'placeholder', 'Password')
->assertValue('@setup-password-input', '')
->assertSeeIn('@setup-button', "JOIN")
// Try to join w/o password
->clickWhenEnabled('@setup-button')
->waitFor('#setup-password.is-invalid')
// Try to join with a valid password
->type('#setup-password', 'pass')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Test removing the password
$owner->click('@menu button.link-options')
->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) {
$browser->assertSeeIn('@title', 'Room options')
->assertSeeIn('#password-input-text:not(.text-muted)', 'pass')
->assertSeeIn('#password-clear-btn.btn-outline-danger', 'Clear password')
->assertElementsCount('#password-input button', 1)
->click('#password-clear-btn')
->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.")
->assertMissing('#password-input input')
->assertSeeIn('#password-input-text.text-muted', 'none')
->assertSeeIn('#password-set-btn', 'Set password')
->assertElementsCount('#password-input button', 1)
- ->click('@button-action');
+ ->click('@button-cancel');
$this->assertSame(null, $room->fresh()->getSetting('password'));
});
});
}
/**
* Test locked room (denying the join request)
*
* @group meet
*/
public function testLockedRoomDeny(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
$room = Room::where('name', 'john')->first();
// Join the room as an owner (authenticate)
$owner->visit(new RoomPage('john'))
// ->click('@setup-button')
// ->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'John')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
// Enter room option dialog
->click('@menu button.link-options')
->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) {
$browser->assertSeeIn('@title', 'Room options')
->assertSeeIn('#room-lock label', 'Locked room:')
->assertVisible('#room-lock input[type=checkbox]:not(:checked)')
->assertVisible('#room-lock + small')
// Test setting the lock
->click('#room-lock input')
->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.")
- ->click('@button-action');
+ ->click('@button-cancel');
$this->assertSame('true', $room->fresh()->getSetting('locked'));
});
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertButtonEnabled('@setup-button')
->assertSeeIn('@setup-button.btn-success', 'JOIN NOW')
// try without the nickname
->clickWhenEnabled('@setup-button')
->waitFor('@setup-nickname-input.is-invalid')
->assertSeeIn(
'@setup-status-message',
"The room is locked. Please, enter your name and try again."
)
->assertMissing('@setup-password-input')
->assertButtonEnabled('@setup-button')
->assertSeeIn('@setup-button.btn-success', 'JOIN NOW')
->type('@setup-nickname-input', 'Guest<p>')
->clickWhenEnabled('@setup-button')
->assertMissing('@setup-nickname-input.is-invalid')
->waitForText("Waiting for permission to join the room.")
->assertButtonDisabled('@setup-button');
// Test denying the request (this will also test custom toasts)
$owner
->whenAvailable(new Toast(Toast::TYPE_CUSTOM), function ($browser) {
$browser->assertToastTitle('Join request')
->assertVisible('.toast-header svg.fa-user')
->assertSeeIn('@message', 'Guest<p> requested to join.')
->assertAttributeRegExp('@message img', 'src', '|^data:image|')
->assertSeeIn('@message button.accept.btn-success', 'Accept')
->assertSeeIn('@message button.deny.btn-danger', 'Deny')
->click('@message button.deny');
})
->waitUntilMissing('.toast')
// wait 10 seconds to make sure the request message does not show up again
->pause(10 * 1000)
->assertMissing('.toast');
});
}
/**
* Test locked room (accepting the join request, and dismissing a user)
*
* @group meet
*/
public function testLockedRoomAcceptAndDismiss(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
$room = Room::where('name', 'john')->first();
// Join the room as an owner (authenticate)
$owner->visit(new RoomPage('john'))
// ->click('@setup-button')
// ->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'John')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
// Enter room option dialog
->click('@menu button.link-options')
->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) {
$browser->assertSeeIn('@title', 'Room options')
->assertSeeIn('#room-lock label', 'Locked room:')
->assertVisible('#room-lock input[type=checkbox]:not(:checked)')
->assertVisible('#room-lock + small')
// Test setting the lock
->click('#room-lock input')
->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.")
- ->click('@button-action');
+ ->click('@button-cancel');
$this->assertSame('true', $room->fresh()->getSetting('locked'));
});
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'guest')
->clickWhenEnabled('@setup-button')
->waitForText("Waiting for permission to join the room.")
->assertButtonDisabled('@setup-button');
$owner
->whenAvailable(new Toast(Toast::TYPE_CUSTOM), function ($browser) {
$browser->assertToastTitle('Join request')
->assertSeeIn('@message', 'guest requested to join.')
->click('@message button.accept');
});
// Guest automatically anters the room
$guest->waitFor('@session', 12)
// make sure he has no access to the Options menu
->waitFor('@session .meet-video:not(.self)')
->assertSeeIn('@session .meet-video:not(.self) .meet-nickname', 'John')
// TODO: Assert title and icon
->click('@session .meet-video:not(.self) .meet-nickname')
->pause(100)
->assertMissing('.dropdown-menu');
// Test dismissing the participant
$owner->click('@session .meet-video:not(.self) .meet-nickname')
->waitFor('@session .meet-video:not(.self) .dropdown-menu')
->assertSeeIn('@session .meet-video:not(.self) .dropdown-menu > .action-dismiss', 'Dismiss')
->click('@session .meet-video:not(.self) .dropdown-menu > .action-dismiss')
->waitUntilMissing('.dropdown-menu')
->waitUntilMissing('@session .meet-video:not(.self)');
// Expect a "end of session" dialog on the participant side
$guest->with(new Dialog('#leave-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Room closed')
->assertSeeIn('@body', "The session has been closed by the room owner.")
- ->assertMissing('@button-cancel')
- ->assertSeeIn('@button-action', 'Close');
+ ->assertMissing('@button-action')
+ ->assertSeeIn('@button-cancel', 'Close');
});
});
}
/**
* Test nomedia (subscribers only) feature
*
* @group meet
*/
public function testSubscribersOnly(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
$room = Room::where('name', 'john')->first();
// Join the room as an owner (authenticate)
$owner->visit(new RoomPage('john'))
// ->click('@setup-button')
// ->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'John')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
// Enter room option dialog
->click('@menu button.link-options')
->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) {
$browser->assertSeeIn('@title', 'Room options')
->assertSeeIn('#room-nomedia label', 'Subscribers only:')
->assertVisible('#room-nomedia input[type=checkbox]:not(:checked)')
->assertVisible('#room-nomedia + small')
// Test enabling the option
->click('#room-nomedia input')
->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.")
- ->click('@button-action');
+ ->click('@button-cancel');
$this->assertSame('true', $room->fresh()->getSetting('nomedia'));
});
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'John')
->clickWhenEnabled('@setup-button')
// expect the owner to have a video, but the guest to have none
->waitFor('@session .meet-video')
->waitFor('@session .meet-subscriber.self');
// Unset the option back
$owner->click('@menu button.link-options')
->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) {
$browser->assertVisible('#room-nomedia input[type=checkbox]:checked')
// Test enabling the option
->click('#room-nomedia input')
->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.")
- ->click('@button-action');
+ ->click('@button-cancel');
$this->assertSame(null, $room->fresh()->getSetting('nomedia'));
});
});
}
}

File Metadata

Mime Type
text/x-diff
Expires
Mon, Aug 17, 9:09 PM (1 d, 13 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1261620
Default Alt Text
(217 KB)

Event Timeline