Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F9785752
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
217 KB
Referenced Files
None
Subscribers
None
View Options
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">
¹ {{ $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>
<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> <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
Details
Attached
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)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment