Page MenuHomePhorge

No OneTemporary

Size
467 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index 09aa686a..31a6c847 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,522 +1,516 @@
/**
* 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 store from './store'
import { loadLangAsync, i18n } from './locale'
const loader = '<div class="app-loader"><div class="spinner-border" role="status"><span class="sr-only">Loading</span></div></div>'
let isLoading = 0
// Lock the UI with the 'loading...' element
const startLoading = () => {
isLoading++
let loading = $('#app > .app-loader').removeClass('fadeOut')
if (!loading.length) {
$('#app').append($(loader))
}
}
// Hide "loading" overlay
const stopLoading = () => {
if (isLoading > 0) {
$('#app > .app-loader').addClass('fadeOut')
isLoading--;
}
}
let loadingRoute
// Note: This has to be before the app is created
// Note: You cannot use app inside of the function
window.router.beforeEach((to, from, next) => {
// check if the route requires authentication and user is not logged in
if (to.meta.requiresAuth && !store.state.isLoggedIn) {
// remember the original request, to use after login
store.state.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()
})
const app = new Vue({
components: {
AppComponent,
MenuComponent,
},
i18n,
store,
router: window.router,
data() {
return {
isUser: !window.isAdmin && !window.isReseller,
appName: window.config['app.name'],
appUrl: window.config['app.url'],
themeDir: '/themes/' + window.config['app.theme']
}
},
methods: {
// Clear (bootstrap) form validation state
clearFormValidation(form) {
$(form).find('.is-invalid').removeClass('is-invalid')
$(form).find('.invalid-feedback').remove()
},
hasPermission(type) {
const authInfo = store.state.authInfo
const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1)
return !!(authInfo && authInfo.statusInfo[key])
},
hasRoute(name) {
return this.$router.resolve({ name: name }).resolved.matched.length > 0
},
hasSKU(name) {
const authInfo = store.state.authInfo
return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1
},
isController(wallet_id) {
if (wallet_id && store.state.authInfo) {
let i
for (i = 0; i < store.state.authInfo.wallets.length; i++) {
if (wallet_id == store.state.authInfo.wallets[i].id) {
return true
}
}
for (i = 0; i < store.state.authInfo.accounts.length; i++) {
if (wallet_id == store.state.authInfo.accounts[i].id) {
return true
}
}
}
return false
},
// Set user state to "logged in"
loginUser(response, dashboard, update) {
if (!update) {
store.commit('logoutUser') // destroy old state data
store.commit('loginUser')
}
localStorage.setItem('token', response.access_token)
axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token
if (response.email) {
store.state.authInfo = response
}
if (dashboard !== false) {
this.$router.push(store.state.afterLogin || { name: 'dashboard' })
}
store.state.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').then(response => {
this.loginUser(response.data, false, true)
})
}, timeout * 1000)
},
// Set user state to "not logged in"
logoutUser(redirect) {
store.commit('logoutUser')
localStorage.setItem('token', '')
delete axios.defaults.headers.common.Authorization
if (redirect !== false) {
this.$router.push({ name: 'login' })
}
clearTimeout(this.refreshTimeout)
},
logo(mode) {
let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png'
return `<img src="${src}" alt="${this.appName}">`
},
// Display "loading" overlay inside of the specified element
addLoader(elem, small = true) {
$(elem).css({position: 'relative'}).append(small ? $(loader).addClass('small') : $(loader))
},
// Remove loader element added in addLoader()
removeLoader(elem) {
$(elem).find('.app-loader').remove()
},
startLoading,
stopLoading,
isLoading() {
return isLoading > 0
},
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".
- const map = {
- 400: "Bad request",
- 401: "Unauthorized",
- 403: "Access denied",
- 404: "Not found",
- 405: "Method not allowed",
- 500: "Internal server error"
- }
- if (!msg) msg = map[code] || "Unknown Error"
+ if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown')
if (!hint) hint = ''
const error_page = '<div id="error-page" class="error-page">'
+ `<div class="code">${code}</div><div class="message">${msg}</div><div class="hint">${hint}</div>`
+ '</div>'
$('#error-page').remove()
$('#app').append(error_page)
app.updateBodyClass('error')
},
errorHandler(error) {
this.stopLoading()
if (!error.response) {
// TODO: probably network connection error
} else if (error.response.status === 401) {
// Remember requested route to come back to it after log in
if (this.$route.meta.requiresAuth) {
store.state.afterLogin = this.$route
this.logoutUser()
} else {
this.logoutUser(false)
}
} else {
this.errorPage(error.response.status, error.response.statusText)
}
},
downloadFile(url) {
// TODO: This might not be a best way for big files as the content
// will be stored (temporarily) in browser memory
// TODO: This method does not show the download progress in the browser
// but it could be implemented in the UI, axios has 'progress' property
axios.get(url, { responseType: 'blob' })
.then(response => {
const link = document.createElement('a')
const contentDisposition = response.headers['content-disposition']
let filename = 'unknown'
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match.length === 2) {
filename = match[1];
}
}
link.href = window.URL.createObjectURL(response.data)
link.download = filename
link.click()
})
},
price(price, currency) {
+ // TODO: Set locale argument according to the currently used locale
return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' })
},
priceLabel(cost, discount) {
let index = ''
if (discount) {
cost = Math.floor(cost * ((100 - discount) / 100))
index = '\u00B9'
}
return this.price(cost) + '/month' + index
},
clickRecord(event) {
if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) {
let link = $(event.target).closest('tr').find('a')[0]
if (link) {
link.click()
}
}
},
domainStatusClass(domain) {
if (domain.isDeleted) {
return 'text-muted'
}
if (domain.isSuspended) {
return 'text-warning'
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
return 'text-danger'
}
return 'text-success'
},
domainStatusText(domain) {
if (domain.isDeleted) {
- return 'Deleted'
+ return this.$t('status.deleted')
}
if (domain.isSuspended) {
- return 'Suspended'
+ return this.$t('status.suspended')
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
- return 'Not Ready'
+ return this.$t('status.notready')
}
- return 'Active'
+ return this.$t('status.active')
},
distlistStatusClass(list) {
if (list.isDeleted) {
return 'text-muted'
}
if (list.isSuspended) {
return 'text-warning'
}
if (!list.isLdapReady) {
return 'text-danger'
}
return 'text-success'
},
distlistStatusText(list) {
if (list.isDeleted) {
- return 'Deleted'
+ return this.$t('status.deleted')
}
if (list.isSuspended) {
- return 'Suspended'
+ return this.$t('status.suspended')
}
if (!list.isLdapReady) {
- return 'Not Ready'
+ return this.$t('status.notready')
}
- return 'Active'
+ return this.$t('status.active')
},
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')
// FIXME: Find a nicer way of doing this
if (!dialog.length) {
+ 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.on('shown.bs.modal', () => {
dialog.find('input').first().focus()
}).modal()
},
userStatusClass(user) {
if (user.isDeleted) {
return 'text-muted'
}
if (user.isSuspended) {
return 'text-warning'
}
if (!user.isImapReady || !user.isLdapReady) {
return 'text-danger'
}
return 'text-success'
},
userStatusText(user) {
if (user.isDeleted) {
- return 'Deleted'
+ return this.$t('status.deleted')
}
if (user.isSuspended) {
- return 'Suspended'
+ return this.$t('status.suspended')
}
if (!user.isImapReady || !user.isLdapReady) {
- return 'Not Ready'
+ return this.$t('status.notready')
}
- return 'Active'
+ return this.$t('status.active')
},
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
window.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
return config
},
error => {
// Do something with request error
return Promise.reject(error)
}
)
// Add a axios response interceptor for general/validation error handler
window.axios.interceptors.response.use(
response => {
if (response.config.onFinish) {
response.config.onFinish()
}
return response
},
error => {
let error_msg
let status = error.response ? error.response.status : 200
// Do not display the error in a toast message, pass the error as-is
if (error.config.ignoreErrors) {
return Promise.reject(error)
}
if (error.config.onFinish) {
error.config.onFinish()
}
if (error.response && status == 422) {
error_msg = "Form validation error"
const modal = $('div.modal.show')
$(modal.length ? modal : 'form').each((i, form) => {
form = $(form)
$.each(error.response.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 ($.type(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 {
// Standard form element
input.addClass('is-invalid')
input.parent().find('.invalid-feedback').remove()
input.parent().append(feedback)
}
}
})
form.find('.is-invalid:not(.listinput-widget)').first().focus()
})
}
else if (error.response && error.response.data) {
error_msg = error.response.data.message
}
else {
error_msg = error.request ? error.request.statusText : error.message
}
- app.$toast.error(error_msg || "Server Error")
+ app.$toast.error(error_msg || app.$t('error.server'))
// Pass the error as-is
return Promise.reject(error)
}
)
diff --git a/src/resources/lang/de/ui.php b/src/resources/lang/de/ui.php
index c012bf82..aac060b9 100644
--- a/src/resources/lang/de/ui.php
+++ b/src/resources/lang/de/ui.php
@@ -1,30 +1,30 @@
<?php
return [
- 'button' => [
+ 'btn' => [
'cancel' => "Stornieren",
'save' => "Speichern",
],
'lang' => [
'en' => "Englisch",
'de' => "Deutsch",
'fr' => "Französisch",
],
'login' => [
'forgot_password' => "Passwort vergessen?",
'sign_in' => "Anmelden",
'webmail' => "Webmail",
],
'menu' => [
'cockpit' => "Cockpit",
'login' => "Einloggen",
'logout' => "Ausloggen",
'signup' => "Signup",
'toggle' => "Navigation umschalten",
],
];
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
index 4c83ea23..4b9fecec 100644
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -1,178 +1,407 @@
<?php
/**
* This file will be converted to a Vue-i18n compatible JSON format on build time
*
* Note: The Laravel localization features do not work here. Vue-i18n rules are different
*/
return [
- 'button' => [
+ 'app' => [
+ 'faq' => "FAQ",
+ ],
+
+ 'btn' => [
+ 'add' => "Add",
'accept' => "Accept",
'back' => "Back",
'cancel' => "Cancel",
'close' => "Close",
'continue' => "Continue",
+ 'delete' => "Delete",
'deny' => "Deny",
+ 'download' => "Download",
+ 'edit' => "Edit",
+ 'file' => "Choose file...",
+ 'moreinfo' => "More information",
+ 'refresh' => "Refresh",
+ 'reset' => "Reset",
+ 'resend' => "Resend",
'save' => "Save",
+ 'search' => "Search",
+ 'signup' => "Sign Up",
'submit' => "Submit",
+ 'suspend' => "Suspend",
+ 'unsuspend' => "Unsuspend",
+ 'verify' => "Verify",
],
'dashboard' => [
'beta' => "beta",
+ 'distlists' => "Distribution lists",
+ 'chat' => "Video chat",
+ 'domains' => "Domains",
+ 'invitations' => "Invitations",
+ 'profile' => "Your profile",
+ 'users' => "User accounts",
+ 'wallet' => "Wallet",
+ 'webmail' => "Webmail",
+ 'stats' => "Stats",
],
'distlist' => [
'list-title' => "Distribution list | Distribution lists",
'create' => "Create list",
'delete' => "Delete list",
'email' => "Email",
'list-empty' => "There are no distribution lists in this account.",
'new' => "New distribution list",
'recipients' => "Recipients",
],
+ 'domain' => [
+ 'dns-verify' => "Domain DNS verification sample:",
+ 'dns-config' => "Domain DNS configuration sample:",
+ 'namespace' => "Namespace",
+ 'verify' => "Domain verification",
+ 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.",
+ 'verify-dns' => "The domain <b>must have one of the following entries</b> in DNS:",
+ 'verify-dns-txt' => "TXT entry with value:",
+ 'verify-dns-cname' => "or CNAME entry:",
+ 'verify-outro' => "When this is done press the button below to start the verification.",
+ 'verify-sample' => "Here's a sample zone file for your domain:",
+ 'config' => "Domain configuration",
+ 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.",
+ 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:",
+ 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.",
+ ],
+
+ 'error' => [
+ '400' => "Bad request",
+ '401' => "Unauthorized",
+ '403' => "Access denied",
+ '404' => "Not found",
+ '405' => "Method not allowed",
+ '500' => "Internal server error",
+ 'unknown' => "Unknown Error",
+ 'server' => "Server Error",
+ ],
+
'form' => [
+ 'amount' => "Amount",
'code' => "Confirmation Code",
+ 'config' => "Configuration",
+ 'date' => "Date",
+ 'description' => "Description",
+ 'details' => "Details",
+ 'domain' => "Domain",
'email' => "Email Address",
+ 'firstname' => "First Name",
+ 'lastname' => "Last Name",
'none' => "none",
+ 'or' => "or",
'password' => "Password",
'password-confirm' => "Confirm Password",
+ 'phone' => "Phone",
'status' => "Status",
+ 'surname' => "Surname",
+ 'user' => "User",
+ 'primary-email' => "Primary Email",
+ 'id' => "ID",
+ 'created' => "Created",
+ 'deleted' => "Deleted",
+ ],
+
+ 'invitation' => [
+ 'create' => "Create invite(s)",
+ 'create-title' => "Invite for a signup",
+ 'create-email' => "Enter an email address of the person you want to invite.",
+ 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.",
+ 'empty-list' => "There are no invitations in the database.",
+ 'title' => "Signup invitations",
+ 'search' => "Email address or domain",
+ 'send' => "Send invite(s)",
+ 'status-completed' => "User signed up",
+ 'status-failed' => "Sending failed",
+ 'status-sent' => "Sent",
+ 'status-new' => "Not sent yet",
],
'lang' => [
'en' => "English",
'de' => "German",
'fr' => "French",
'it' => "Italian",
],
'login' => [
'2fa' => "Second factor code",
'2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.",
'forgot_password' => "Forgot password?",
'header' => "Please sign in",
'sign_in' => "Sign in",
'webmail' => "Webmail"
],
'meet' => [
'title' => "Voice & Video Conferencing",
'welcome' => "Welcome to our beta program for Voice & Video Conferencing.",
'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.",
'notice' => "This is a work in progress and more features will be added over time. Current features include:",
'sharing' => "Screen Sharing",
'sharing-text' => "Share your screen for presentations or show-and-tell.",
'security' => "Room Security",
'security-text' => "Increase the room security by setting a password that attendees will need to know"
. " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.",
'qa' => "Raise Hand (Q&A)",
'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.",
'moderation' => "Moderator Delegation",
'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly"
. " interrupted with attendees knocking and other moderator duties.",
'eject' => "Eject Attendees",
'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy"
. " violations. Click the user icon for effective dismissal.",
'silent' => "Silent Audience Members",
'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.",
'interpreters' => "Language Specific Audio Channels",
'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions"
. " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.",
'beta-notice' => "Keep in mind that this is still in beta and might come with some issues."
. " Should you encounter any on your way, let us know by contacting support.",
// Room options dialog
'options' => "Room options",
'password' => "Password",
'password-none' => "none",
'password-clear' => "Clear password",
'password-set' => "Set password",
'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.",
'lock' => "Locked room",
'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.",
'nomedia' => "Subscribers only",
'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)."
. " Moderators will be able to promote them to publishers throughout the session.",
// Room menu
'partcnt' => "Number of participants",
'menu-audio-mute' => "Mute audio",
'menu-audio-unmute' => "Unmute audio",
'menu-video-mute' => "Mute video",
'menu-video-unmute' => "Unmute video",
'menu-screen' => "Share screen",
'menu-hand-lower' => "Lower hand",
'menu-hand-raise' => "Raise hand",
'menu-channel' => "Interpreted language channel",
'menu-chat' => "Chat",
'menu-fullscreen' => "Full screen",
'menu-fullscreen-exit' => "Exit full screen",
'menu-leave' => "Leave session",
// Room setup screen
'setup-title' => "Set up your session",
'mic' => "Microphone",
'cam' => "Camera",
'nick' => "Nickname",
'nick-placeholder' => "Your name",
'join' => "JOIN",
'joinnow' => "JOIN NOW",
'imaowner' => "I'm the owner",
// Room
'qa' => "Q & A",
'leave-title' => "Room closed",
'leave-body' => "The session has been closed by the room owner.",
'media-title' => "Media setup",
'join-request' => "Join request",
'join-requested' => "{user} requested to join.",
// Status messages
'status-init' => "Checking the room...",
'status-323' => "The room is closed. Please, wait for the owner to start the session.",
'status-324' => "The room is closed. It will be open for others after you join.",
'status-325' => "The room is ready. Please, provide a valid password.",
'status-326' => "The room is locked. Please, enter your name and try again.",
'status-327' => "Waiting for permission to join the room.",
'status-404' => "The room does not exist.",
'status-429' => "Too many requests. Please, wait.",
'status-500' => "Failed to connect to the room. Server error.",
// Other menus
'media-setup' => "Media setup",
'perm' => "Permissions",
'perm-av' => "Audio &amp; Video publishing",
'perm-mod' => "Moderation",
'lang-int' => "Language interpreter",
'menu-options' => "Options",
],
'menu' => [
'cockpit' => "Cockpit",
'login' => "Login",
'logout' => "Logout",
'signup' => "Signup",
'toggle' => "Toggle navigation",
],
'msg' => [
+ 'initializing' => "Initializing...",
'loading' => "Loading...",
+ 'loading-failed' => "Failed to load data.",
'notfound' => "Resource not found.",
+ 'info' => "Information",
+ 'error' => "Error",
+ 'warning' => "Warning",
+ 'success' => "Success",
],
'nav' => [
+ 'more' => "Load more",
'step' => "Step {i}/{n}",
],
'password' => [
'reset' => "Password Reset",
'reset-step1' => "Enter your email address to reset your password.",
'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.",
'reset-step2' => "We sent out a confirmation code to your external email address."
. " Enter the code we sent you, or click the link in the message.",
],
+ 'signup' => [
+ 'email' => "Existing Email Address",
+ 'login' => "Login",
+ 'title' => "Sign Up",
+ 'step1' => "Sign up to start your free month.",
+ 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.",
+ 'step3' => "Create your Kolab identity (you can choose additional addresses later).",
+ 'voucher' => "Voucher Code",
+ ],
+
+ 'status' => [
+ 'prepare-account' => "We are preparing your account.",
+ 'prepare-domain' => "We are preparing the domain.",
+ 'prepare-distlist' => "We are preparing the distribution list.",
+ 'prepare-user' => "We are preparing the user account.",
+ 'prepare-hint' => "Some features may be missing or readonly at the moment.",
+ 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.",
+ 'ready-account' => "Your account is almost ready.",
+ 'ready-domain' => "The domain is almost ready.",
+ 'ready-distlist' => "The distribution list is almost ready.",
+ 'ready-user' => "The user account is almost ready.",
+ 'verify' => "Verify your domain to finish the setup process.",
+ 'verify-domain' => "Verify domain",
+ 'deleted' => "Deleted",
+ 'suspended' => "Suspended",
+ 'notready' => "Not Ready",
+ 'active' => "Active",
+ ],
+
+ 'support' => [
+ 'title' => "Contact Support",
+ 'id' => "Customer number or email address you have with us",
+ 'id-pl' => "e.g. 12345678 or john@kolab.org",
+ 'id-hint' => "Leave blank if you are not a customer yet",
+ 'name' => "Name",
+ 'name-pl' => "how we should call you in our reply",
+ 'email' => "Working email address",
+ 'email-pl' => "make sure we can reach you at this address",
+ 'summary' => "Issue Summary",
+ 'summary-pl' => "one sentence that summarizes your issue",
+ 'expl' => "Issue Explanation",
+ ],
+
+ 'user' => [
+ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.",
+ '2fa-hint2' => "Please, make sure to confirm the user identity properly.",
+ 'address' => "Address",
+ 'aliases' => "Aliases",
+ 'aliases-email' => "Email Aliases",
+ 'aliases-none' => "This user has no email aliases.",
+ 'add-bonus' => "Add bonus",
+ 'add-bonus-title' => "Add a bonus to the wallet",
+ 'add-penalty' => "Add penalty",
+ 'add-penalty-title' => "Add a penalty to the wallet",
+ 'auto-payment' => "Auto-payment",
+ 'auto-payment-text' => "Fill up by <b>{amount} CHF</b> when under <b>{balance} CHF</b> using {method}",
+ 'country' => "Country",
+ 'create' => "Create user",
+ 'custno' => "Customer No.",
+ 'delete' => "Delete user",
+ 'delete-email' => "Delete {email}",
+ 'delete-text' => "Do you really want to delete this user permanently?"
+ . " This will delete all account data and withdraw the permission to access the email account."
+ . " Please note that this action cannot be undone.",
+ 'discount' => "Discount",
+ 'discount-hint' => "applied discount",
+ 'discount-title' => "Account discount",
+ 'distlists' => "Distribution lists",
+ 'distlists-none' => "There are no distribution lists in this account.",
+ 'domains' => "Domains",
+ 'domains-none' => "There are no domains in this account.",
+ 'ext-email' => "External Email",
+ 'finances' => "Finances",
+ 'list-title' => "User accounts",
+ 'managed-by' => "Managed by",
+ 'new' => "New user account",
+ 'org' => "Organization",
+ 'package' => "Package",
+ 'price' => "Price",
+ 'profile-title' => "Your profile",
+ 'profile-delete' => "Delete account",
+ 'profile-delete-title' => "Delete this account?",
+ 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.",
+ 'profile-delete-warning' => "This operation is irreversible",
+ 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.",
+ 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. "
+ . "The best tool for improvement is feedback from users, and we would like to ask "
+ . "for a few words about your reasons for leaving our service. Please send your feedback to <a href=\"{href}\">{email}</a>.",
+ 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.",
+ 'reset-2fa' => "Reset 2-Factor Auth",
+ 'reset-2fa-title' => "2-Factor Authentication Reset",
+ 'title' => "User account",
+ 'search-pl' => "User ID, email or domain",
+ 'skureq' => "{sku} requires {list}.",
+ 'subscription' => "Subscription",
+ 'subscriptions' => "Subscriptions",
+ 'subscriptions-none' => "This user has no subscriptions.",
+ 'users' => "Users",
+ 'users-none' => "There are no users in this account.",
+ ],
+
+ 'wallet' => [
+ 'add-credit' => "Add credit",
+ 'auto-payment-cancel' => "Cancel auto-payment",
+ 'auto-payment-change' => "Change auto-payment",
+ 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.",
+ 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose."
+ . " You can cancel or change the auto-payment option at any time.",
+ 'auto-payment-setup' => "Set up auto-payment",
+ 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.",
+ 'auto-payment-info' => "Auto-payment is <b>set</b> to fill up your account by <b>{amount} CHF</b> every time your account balance gets under <b>{balance} CHF</b>.",
+ 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.",
+ 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.",
+ 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.",
+ 'auto-payment-update' => "Update auto-payment",
+ 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.",
+ 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}."
+ . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.",
+ 'fill-up' => "Fill up by",
+ 'history' => "History",
+ 'noperm' => "Only account owners can access a wallet.",
+ 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.",
+ 'payment-method' => "Method of payment: {method}",
+ 'payment-warning' => "You will be charged for {price}.",
+ 'pending-payments' => "Pending Payments",
+ 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.",
+ 'pending-payments-none' => "There are no pending payments for this account.",
+ 'receipts' => "Receipts",
+ 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.",
+ 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.",
+ 'title' => "Account balance",
+ 'top-up' => "Top up your wallet",
+ 'transactions' => "Transactions",
+ 'transactions-none' => "There are no transactions for this account.",
+ 'when-below' => "when account balance is below",
+ ],
];
diff --git a/src/resources/vue/Admin/Dashboard.vue b/src/resources/vue/Admin/Dashboard.vue
index 39bf9208..b0d8d65c 100644
--- a/src/resources/vue/Admin/Dashboard.vue
+++ b/src/resources/vue/Admin/Dashboard.vue
@@ -1,24 +1,24 @@
<template>
<div class="container" dusk="dashboard-component">
<user-search></user-search>
<div id="dashboard-nav" class="mt-3">
<router-link class="card link-stats" :to="{ name: 'stats' }">
- <svg-icon icon="chart-line"></svg-icon><span class="name">Stats</span>
+ <svg-icon icon="chart-line"></svg-icon><span class="name">{{ $t('dashboard.stats') }}</span>
</router-link>
</div>
</div>
</template>
<script>
import UserSearch from '../Widgets/UserSearch'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChartLine } from '@fortawesome/free-solid-svg-icons'
library.add(faChartLine)
export default {
components: {
UserSearch
}
}
</script>
diff --git a/src/resources/vue/Admin/Distlist.vue b/src/resources/vue/Admin/Distlist.vue
index f9fec599..8a8e5637 100644
--- a/src/resources/vue/Admin/Distlist.vue
+++ b/src/resources/vue/Admin/Distlist.vue
@@ -1,79 +1,85 @@
<template>
<div v-if="list.id" class="container">
<div class="card" id="distlist-info">
<div class="card-body">
<div class="card-title">{{ list.email }}</div>
<div class="card-text">
<form class="read-only short">
<div class="form-group row">
- <label for="distlistid" class="col-sm-4 col-form-label">ID <span class="text-muted">(Created at)</span></label>
+ <label for="distlistid" class="col-sm-4 col-form-label">
+ {{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span>
+ </label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="distlistid">
{{ list.id }} <span class="text-muted">({{ list.created_at }})</span>
</span>
</div>
</div>
<div class="form-group row">
- <label for="status" class="col-sm-4 col-form-label">Status</label>
+ <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.distlistStatusClass(list) + ' form-control-plaintext'" id="status">{{ $root.distlistStatusText(list) }}</span>
</div>
</div>
<div class="form-group row">
- <label for="members-input" class="col-sm-4 col-form-label">Recipients</label>
+ <label for="members-input" class="col-sm-4 col-form-label">{{ $t('distlist.recipients') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="members">
<span v-for="member in list.members" :key="member">{{ member }}<br></span>
</span>
</div>
</div>
</form>
<div class="mt-2">
- <button v-if="!list.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendList">Suspend</button>
- <button v-if="list.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendList">Unsuspend</button>
+ <button v-if="!list.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendList">
+ {{ $t('btn.suspend') }}
+ </button>
+ <button v-if="list.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendList">
+ {{ $t('btn.unsuspend') }}
+ </button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
list: { members: [] }
}
},
created() {
this.$root.startLoading()
axios.get('/api/v4/groups/' + this.$route.params.list)
.then(response => {
this.$root.stopLoading()
this.list = response.data
})
.catch(this.$root.errorHandler)
},
methods: {
suspendList() {
axios.post('/api/v4/groups/' + this.list.id + '/suspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.list = Object.assign({}, this.list, { isSuspended: true })
}
})
},
unsuspendList() {
axios.post('/api/v4/groups/' + this.list.id + '/unsuspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.list = Object.assign({}, this.list, { isSuspended: false })
}
})
}
}
}
</script>
diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue
index cbbb284c..c50d65a6 100644
--- a/src/resources/vue/Admin/Domain.vue
+++ b/src/resources/vue/Admin/Domain.vue
@@ -1,91 +1,97 @@
<template>
<div v-if="domain" class="container">
<div class="card" id="domain-info">
<div class="card-body">
<div class="card-title">{{ domain.namespace }}</div>
<div class="card-text">
<form class="read-only short">
<div class="form-group row">
- <label for="domainid" class="col-sm-4 col-form-label">ID <span class="text-muted">(Created at)</span></label>
+ <label for="domainid" class="col-sm-4 col-form-label">
+ {{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span>
+ </label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="domainid">
{{ domain.id }} <span class="text-muted">({{ domain.created_at }})</span>
</span>
</div>
</div>
<div class="form-group row">
- <label for="first_name" class="col-sm-4 col-form-label">Status</label>
+ <label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="status">
<span :class="$root.domainStatusClass(domain)">{{ $root.domainStatusText(domain) }}</span>
</span>
</div>
</div>
</form>
<div class="mt-2">
- <button v-if="!domain.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendDomain">Suspend</button>
- <button v-if="domain.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendDomain">Unsuspend</button>
+ <button v-if="!domain.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendDomain">
+ {{ $t('btn.suspend') }}
+ </button>
+ <button v-if="domain.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendDomain">
+ {{ $t('btn.unsuspend') }}
+ </button>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-config" href="#domain-config" role="tab" aria-controls="domain-config" aria-selected="true">
- Configuration
+ {{ $t('form.config') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="domain-config" role="tabpanel" aria-labelledby="tab-config">
<div class="card-body">
<div class="card-text">
- <p>Domain DNS verification sample:</p>
+ <p>{{ $t('domain.dns-verify') }}</p>
<p><pre id="dns-verify">{{ domain.dns.join("\n") }}</pre></p>
- <p>Domain DNS configuration sample:</p>
+ <p>{{ $t('domain.dns-config') }}</p>
<p><pre id="dns-config">{{ domain.config.join("\n") }}</pre></p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
domain: null
}
},
created() {
const domain_id = this.$route.params.domain;
axios.get('/api/v4/domains/' + domain_id)
.then(response => {
this.domain = response.data
})
.catch(this.$root.errorHandler)
},
methods: {
suspendDomain() {
axios.post('/api/v4/domains/' + this.domain.id + '/suspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.domain = Object.assign({}, this.domain, { isSuspended: true })
}
})
},
unsuspendDomain() {
axios.post('/api/v4/domains/' + this.domain.id + '/unsuspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.domain = Object.assign({}, this.domain, { isSuspended: false })
}
})
}
}
}
</script>
diff --git a/src/resources/vue/Admin/Stats.vue b/src/resources/vue/Admin/Stats.vue
index a3eabae1..420ea3b1 100644
--- a/src/resources/vue/Admin/Stats.vue
+++ b/src/resources/vue/Admin/Stats.vue
@@ -1,47 +1,46 @@
<template>
- <div id="stats-container" class="container">
- </div>
+ <div id="stats-container" class="container"></div>
</template>
<script>
import { Chart } from 'frappe-charts/dist/frappe-charts.esm.js'
export default {
data() {
return {
charts: {},
chartTypes: ['users', 'users-all', 'income', 'discounts']
}
},
mounted() {
this.chartTypes.forEach(chart => this.loadChart(chart))
},
methods: {
drawChart(name, data) {
if (!data.title) {
return
}
const ch = new Chart('#chart-' + name, data)
this.charts[name] = ch
},
loadChart(name) {
const chart = $('<div>').attr({ id: 'chart-' + name }).appendTo(this.$el)
this.$root.addLoader(chart)
axios.get('/api/v4/stats/chart/' + name)
.then(response => {
this.$root.removeLoader(chart)
this.drawChart(name, response.data)
})
.catch(error => {
console.error(error)
this.$root.removeLoader(chart)
- chart.append($('<span>').text('Failed to load data.'))
+ chart.append($('<span>').text(this.$t('msg.loading-failed')))
})
}
}
}
</script>
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
index cc821aa1..cff26837 100644
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -1,694 +1,705 @@
<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="form-group row plaintext">
- <label for="manager" class="col-sm-4 col-form-label">Managed by</label>
+ <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="form-group row plaintext">
- <label for="userid" class="col-sm-4 col-form-label">ID <span class="text-muted">(Created at)</span></label>
+ <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="form-group row plaintext">
- <label for="status" class="col-sm-4 col-form-label">Status</label>
+ <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.userStatusClass(user)">{{ $root.userStatusText(user) }}</span>
</span>
</div>
</div>
<div class="form-group row plaintext" v-if="user.first_name">
- <label for="first_name" class="col-sm-4 col-form-label">First name</label>
+ <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="form-group row plaintext" v-if="user.last_name">
- <label for="last_name" class="col-sm-4 col-form-label">Last name</label>
+ <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="form-group row plaintext" v-if="user.organization">
- <label for="organization" class="col-sm-4 col-form-label">Organization</label>
+ <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="form-group row plaintext" v-if="user.phone">
- <label for="phone" class="col-sm-4 col-form-label">Phone</label>
+ <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="form-group row plaintext">
- <label for="external_email" class="col-sm-4 col-form-label">External email</label>
+ <label for="external_email" class="col-sm-4 col-form-label">{{ $t('user.ext-email') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="external_email">
<a v-if="user.external_email" :href="'mailto:' + user.external_email">{{ user.external_email }}</a>
- <button type="button" class="btn btn-secondary btn-sm" @click="emailEdit">Edit</button>
+ <button type="button" class="btn btn-secondary btn-sm" @click="emailEdit">{{ $t('btn.edit') }}</button>
</span>
</div>
</div>
<div class="form-group row plaintext" v-if="user.billing_address">
- <label for="billing_address" class="col-sm-4 col-form-label">Address</label>
+ <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="form-group row plaintext">
- <label for="country" class="col-sm-4 col-form-label">Country</label>
+ <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">
- <button v-if="!user.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendUser">Suspend</button>
- <button v-if="user.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendUser">Unsuspend</button>
+ <button v-if="!user.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendUser">
+ {{ $t('btn.suspend') }}
+ </button>
+ <button v-if="user.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendUser">
+ {{ $t('btn.unsuspend') }}
+ </button>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-finances" href="#user-finances" role="tab" aria-controls="user-finances" aria-selected="true">
- Finances
+ {{ $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">
- Aliases ({{ user.aliases.length }})
+ {{ $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">
- Subscriptions ({{ skus.length }})
+ {{ $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">
- Domains ({{ domains.length }})
+ {{ $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">
- Users ({{ users.length }})
+ {{ $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">
- Distribution lists ({{ distlists.length }})
+ {{ $t('user.distlists') }} ({{ distlists.length }})
</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">Account balance <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(wallet.balance) }}</strong></span></h2>
+ <h2 class="card-title">
+ {{ $t('wallet.title') }}
+ <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(wallet.balance) }}</strong></span>
+ </h2>
<div class="card-text">
<form class="read-only short">
<div class="form-group row">
- <label class="col-sm-4 col-form-label">Discount</label>
+ <label class="col-sm-4 col-form-label">{{ $t('user.discount') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="discount">
<span>{{ wallet.discount ? (wallet.discount + '% - ' + wallet.discount_description) : 'none' }}</span>
- <button type="button" class="btn btn-secondary btn-sm" @click="discountEdit">Edit</button>
+ <button type="button" class="btn btn-secondary btn-sm" @click="discountEdit">{{ $t('btn.edit') }}</button>
</span>
</div>
</div>
<div class="form-group row" v-if="wallet.mandate && wallet.mandate.id">
- <label class="col-sm-4 col-form-label">Auto-payment</label>
+ <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' : '')">
- Fill up by <b>{{ wallet.mandate.amount }} CHF</b>
- when under <b>{{ wallet.mandate.balance }} CHF</b>
- using {{ wallet.mandate.method }}
+ <span id="autopayment" :class="'form-control-plaintext' + (wallet.mandateState ? ' text-danger' : '')"
+ v-html="$t('user.auto-payment-text', {
+ amount: wallet.mandate.amount,
+ balance: wallet.mandate.balance,
+ method: wallet.mandate.method
+ })"
+ >
<span v-if="wallet.mandateState">({{ wallet.mandateState }})</span>.
</span>
</div>
</div>
<div class="form-group row" v-if="wallet.providerLink">
- <label class="col-sm-4 col-form-label">{{ capitalize(wallet.provider) }} ID</label>
+ <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">
- <button id="button-award" class="btn btn-success" type="button" @click="awardDialog">Add bonus</button>
- <button id="button-penalty" class="btn btn-danger" type="button" @click="penalizeDialog">Add penalty</button>
+ <button id="button-award" class="btn btn-success" type="button" @click="awardDialog">{{ $t('user.add-bonus') }}</button>
+ <button id="button-penalty" class="btn btn-danger" type="button" @click="penalizeDialog">{{ $t('user.add-penalty') }}</button>
</div>
</div>
- <h2 class="card-title mt-4">Transactions</h2>
+ <h2 class="card-title mt-4">{{ $t('wallet.transactions') }}</h2>
<transaction-log v-if="wallet.id && !walletReload" class="card-text" :wallet-id="wallet.id" :is-admin="true"></transaction-log>
</div>
</div>
<div class="tab-pane" id="user-aliases" role="tabpanel" aria-labelledby="tab-aliases">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
- <th scope="col">Email address</th>
+ <th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(alias, index) in user.aliases" :id="'alias' + index" :key="index">
<td>{{ alias }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td>This user has no email aliases.</td>
+ <td>{{ $t('user.aliases-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-subscriptions" role="tabpanel" aria-labelledby="tab-subscriptions">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead class="thead-light">
<tr>
- <th scope="col">Subscription</th>
- <th scope="col">Price</th>
+ <th scope="col">{{ $t('user.subscription') }}</th>
+ <th scope="col">{{ $t('user.price') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(sku, sku_id) in skus" :id="'sku' + sku.id" :key="sku_id">
<td>{{ sku.name }}</td>
<td>{{ sku.price }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td colspan="2">This user has no subscriptions.</td>
+ <td colspan="2">{{ $t('user.subscriptions-none') }}</td>
</tr>
</tfoot>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
- &sup1; applied discount: {{ discount }}% - {{ discount_description }}
+ &sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
<div class="mt-2">
- <button type="button" class="btn btn-danger" id="reset2fa" v-if="has2FA" @click="reset2FADialog">Reset 2-Factor Auth</button>
+ <button type="button" class="btn btn-danger" id="reset2fa" v-if="has2FA" @click="reset2FADialog">
+ {{ $t('user.reset-2fa') }}
+ </button>
</div>
</div>
</div>
</div>
<div class="tab-pane" id="user-domains" role="tabpanel" aria-labelledby="tab-domains">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
- <th scope="col">Name</th>
+ <th scope="col">{{ $t('domain.namespace') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="domain in domains" :id="'domain' + domain.id" :key="domain.id" @click="$root.clickRecord">
<td>
<svg-icon icon="globe" :class="$root.domainStatusClass(domain)" :title="$root.domainStatusText(domain)"></svg-icon>
<router-link :to="{ path: '/domain/' + domain.id }">{{ domain.namespace }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td>There are no domains in this account.</td>
+ <td>{{ $t('user.domains-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-users" role="tabpanel" aria-labelledby="tab-users">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
- <th scope="col">Primary Email</th>
+ <th scope="col">{{ $t('form.primary-email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in users" :id="'user' + item.id" :key="item.id" @click="$root.clickRecord">
<td>
<svg-icon icon="user" :class="$root.userStatusClass(item)" :title="$root.userStatusText(item)"></svg-icon>
<router-link v-if="item.id != user.id" :to="{ path: '/user/' + item.id }">{{ item.email }}</router-link>
<span v-else>{{ item.email }}</span>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td>There are no users in this account.</td>
+ <td>{{ $t('user.users-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-distlists" role="tabpanel" aria-labelledby="tab-distlists">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
- <th scope="col">Email address</th>
+ <th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="list in distlists" :key="list.id" @click="$root.clickRecord">
<td>
<svg-icon icon="users" :class="$root.distlistStatusClass(list)" :title="$root.distlistStatusText(list)"></svg-icon>
<router-link :to="{ path: '/distlist/' + list.id }">{{ list.email }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td>There are no distribution lists in this account.</td>
+ <td>{{ $t('user.distlists-none') }}</td>
</tr>
</tfoot>
</table>
</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">Account discount</h5>
- <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <h5 class="modal-title">{{ $t('user.discount-title') }}</h5>
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p class="form-group">
<select v-model="wallet.discount_id" class="custom-select">
- <option value="">- none -</option>
+ <option value="">- {{ $t('form.none') }} -</option>
<option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option>
</select>
</p>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="submitDiscount()">
- <svg-icon icon="check"></svg-icon> Submit
+ <svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
</div>
</div>
</div>
</div>
<div id="email-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
- <h5 class="modal-title">External email</h5>
- <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <h5 class="modal-title">{{ $t('user.ext-email') }}</h5>
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p class="form-group">
<input v-model="external_email" name="external_email" class="form-control">
</p>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="submitEmail()">
- <svg-icon icon="check"></svg-icon> Submit
+ <svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
</div>
</div>
</div>
</div>
<div id="oneoff-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
- <h5 class="modal-title">{{ oneoff_negative ? 'Add a penalty to the wallet' : 'Add a bonus to the wallet' }}</h5>
- <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <h5 class="modal-title">{{ $t(oneoff_negative ? 'user.add-penalty-title' : 'user.add-bonus-title') }}</h5>
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form data-validation-prefix="oneoff_">
<div class="form-group">
- <label for="oneoff_amount" class="col-form-label">Amount</label>
+ <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-append">
<span class="input-group-text">{{ oneoff_currency }}</span>
</span>
</div>
</div>
<div class="form-group">
- <label for="oneoff_description" class="col-form-label">Description</label>
+ <label for="oneoff_description" class="col-form-label">{{ $t('form.description') }}</label>
<input class="form-control" id="oneoff_description" v-model="oneoff_description" required>
</div>
</form>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="submitOneOff()">
- <svg-icon icon="check"></svg-icon> Submit
+ <svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
</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">2-Factor Authentication Reset</h5>
- <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <h5 class="modal-title">{{ $t('user.reset-2fa-title') }}</h5>
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
- <p>This will remove 2-Factor Authentication entitlement as well
- as the user-configured factors.</p>
- <p>Please, make sure to confirm the user identity properly.</p>
+ <p>{{ $t('user.2fa-hint1') }}</p>
+ <p>{{ $t('user.2fa-hint2') }}</p>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
- <button type="button" class="btn btn-danger modal-action" @click="reset2FA()">Reset</button>
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
+ <button type="button" class="btn btn-danger modal-action" @click="reset2FA()">{{ $t('btn.reset') }}</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import TransactionLog from '../Widgets/TransactionLog'
export default {
components: {
TransactionLog
},
beforeRouteUpdate (to, from, next) {
// An event called when the route that renders this component has changed,
// but this component is reused in the new route.
// Required to handle links from /user/XXX to /user/YYY
next()
this.$parent.routerReload()
},
data() {
return {
oneoff_amount: '',
oneoff_currency: 'CHF',
oneoff_description: '',
oneoff_negative: false,
discount: 0,
discount_description: '',
discounts: [],
external_email: '',
has2FA: false,
wallet: {},
walletReload: false,
distlists: [],
domains: [],
skus: [],
sku2FA: null,
users: [],
user: {
aliases: [],
wallet: {},
skus: {},
}
}
},
created() {
const user_id = this.$route.params.user
this.$root.startLoading()
axios.get('/api/v4/users/' + user_id)
.then(response => {
this.$root.stopLoading()
this.user = response.data
const financesTab = '#user-finances'
const keys = ['first_name', 'last_name', 'external_email', 'billing_address', 'phone', 'organization']
let country = this.user.settings.country
if (country && country in window.config.countries) {
country = window.config.countries[country][1]
}
this.user.country = country
keys.forEach(key => { this.user[key] = this.user.settings[key] })
this.discount = this.user.wallet.discount
this.discount_description = this.user.wallet.discount_description
// TODO: currencies, multi-wallets, accounts
// Get more info about the wallet (e.g. payment provider related)
this.$root.addLoader(financesTab)
axios.get('/api/v4/wallets/' + this.user.wallets[0].id)
.then(response => {
this.$root.removeLoader(financesTab)
this.wallet = response.data
this.setMandateState()
})
.catch(error => {
this.$root.removeLoader(financesTab)
})
// Create subscriptions list
axios.get('/api/v4/users/' + user_id + '/skus')
.then(response => {
// "merge" SKUs with user entitlement-SKUs
response.data.forEach(sku => {
const userSku = this.user.skus[sku.id]
if (userSku) {
let cost = userSku.costs.reduce((sum, current) => sum + current)
let item = {
id: sku.id,
name: sku.name,
cost: cost,
price: this.$root.priceLabel(cost, this.discount)
}
if (sku.range) {
item.name += ' ' + userSku.count + ' ' + sku.range.unit
}
this.skus.push(item)
if (sku.handler == 'auth2f') {
this.has2FA = true
this.sku2FA = sku.id
}
}
})
})
// 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
})
})
.catch(this.$root.errorHandler)
},
mounted() {
$(this.$el).find('ul.nav-tabs a').on('click', e => {
e.preventDefault()
$(e.target).tab('show')
})
},
methods: {
capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
awardDialog() {
this.oneOffDialog(false)
},
discountEdit() {
$('#discount-dialog')
.on('shown.bs.modal', e => {
$(e.target).find('select').focus()
// Note: Vue v-model is strict, convert null to a string
this.wallet.discount_id = this.wallet_discount_id || ''
})
.modal()
if (!this.discounts.length) {
// Fetch discounts
axios.get('/api/v4/discounts')
.then(response => {
this.discounts = response.data.list
})
}
},
emailEdit() {
this.external_email = this.user.external_email
this.$root.clearFormValidation($('#email-dialog'))
$('#email-dialog')
.on('shown.bs.modal', e => {
$(e.target).find('input').focus()
})
.modal()
},
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
this.dialog = $('#oneoff-dialog').on('shown.bs.modal', event => {
this.$root.clearFormValidation(event.target)
$(event.target).find('#oneoff_amount').focus()
}).modal()
},
penalizeDialog() {
this.oneOffDialog(true)
},
reload() {
// this is to reload transaction log
this.walletReload = true
this.$nextTick(() => { this.walletReload = false })
},
reset2FA() {
$('#reset-2fa-dialog').modal('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() {
$('#reset-2fa-dialog').modal()
},
submitDiscount() {
$('#discount-dialog').modal('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') {
$('#email-dialog').modal('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
}
// TODO: We maybe should use system currency not wallet currency,
// or have a selector so the operator does not have to calculate
// exchange rates
this.$root.clearFormValidation(this.dialog)
axios.post('/api/v4/wallets/' + wallet_id + '/one-off', post)
.then(response => {
if (response.data.status == 'success') {
this.dialog.modal('hide')
this.$toast.success(response.data.message)
this.wallet = Object.assign({}, this.wallet, {balance: response.data.balance})
this.oneoff_amount = ''
this.oneoff_description = ''
this.reload()
}
})
},
suspendUser() {
axios.post('/api/v4/users/' + this.user.id + '/suspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.user = Object.assign({}, this.user, { isSuspended: true })
}
})
},
unsuspendUser() {
axios.post('/api/v4/users/' + this.user.id + '/unsuspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.user = Object.assign({}, this.user, { isSuspended: false })
}
})
}
}
}
</script>
diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue
index 46f5fca0..e2d5c19c 100644
--- a/src/resources/vue/App.vue
+++ b/src/resources/vue/App.vue
@@ -1,110 +1,110 @@
<template>
<router-view v-if="!isLoading && !routerReloading && key" :key="key" @hook:mounted="childMounted"></router-view>
</template>
<script>
export default {
data() {
return {
isLoading: true,
routerReloading: false
}
},
computed: {
key() {
// Display 403 error page if the current user has no permission to a specified page
// Note that it's the only place I found that allows us to do this.
if (this.$route.meta.perm && !this.checkPermission(this.$route.meta.perm)) {
// Returning false here will block the page component from execution,
// as we're using the key in v-if condition on the router-view above
return false
}
// The 'key' property is used to reload the Page component
// whenever a route changes. Normally vue does not do that.
return this.$route.name == '404' ? this.$route.path : 'static'
}
},
mounted() {
const token = localStorage.getItem('token')
if (token) {
this.$root.startLoading()
axios.defaults.headers.common.Authorization = 'Bearer ' + token
axios.get('/api/auth/info?refresh_token=1')
.then(response => {
this.$root.loginUser(response.data, false)
this.$root.stopLoading()
this.isLoading = false
})
.catch(error => {
// Release lock on the router-view, otherwise links (e.g. Logout) will not work
this.isLoading = false
this.$root.logoutUser(false)
this.$root.errorHandler(error)
})
} else {
this.isLoading = false
}
},
methods: {
checkPermission(type) {
if (this.$root.hasPermission(type)) {
return true
}
- const hint = type == 'wallets' ? "Only account owners can access a wallet." : ''
+ const hint = type == 'wallets' ? this.$t('wallet.noperm') : ''
this.$root.errorPage(403, null, hint)
return false
},
childMounted() {
this.$root.updateBodyClass()
this.getFAQ()
},
getFAQ() {
let page = this.$route.path
if (page == '/' || page == '/login') {
return
}
axios.get('/content/faq' + page, { ignoreErrors: true })
.then(response => {
const result = response.data.faq
$('#faq').remove()
if (result && result.length) {
- let faq = $('<div id="faq" class="faq mt-3"><h5>FAQ</h5><ul class="pl-4"></ul></div>')
+ let faq = $('<div id="faq" class="faq mt-3"><h5>' + this.$t('app.faq') + '</h5><ul class="pl-4"></ul></div>')
let list = []
result.forEach(item => {
list.push($('<li>').append($('<a>').attr('href', item.href).text(item.title)))
// Handle internal links with the vue-router
if (item.href.charAt(0) == '/') {
list[list.length-1].find('a').on('click', event => {
event.preventDefault()
this.$router.push(item.href)
})
}
})
faq.find('ul').append(list)
$(this.$el).append(faq)
}
})
},
routerReload() {
// Together with beforeRouteUpdate even on a route component
// allows us to force reload the component. So it is possible
// to jump from/to page that uses currently loaded component.
this.routerReloading = true
this.$nextTick().then(() => {
this.routerReloading = false
})
}
}
}
</script>
diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue
index bca3666c..320e607e 100644
--- a/src/resources/vue/Dashboard.vue
+++ b/src/resources/vue/Dashboard.vue
@@ -1,66 +1,66 @@
<template>
<div class="container" dusk="dashboard-component">
<status-component :status="status" @status-update="statusUpdate"></status-component>
<div id="dashboard-nav">
<router-link class="card link-profile" :to="{ name: 'profile' }">
- <svg-icon icon="user-cog"></svg-icon><span class="name">Your profile</span>
+ <svg-icon icon="user-cog"></svg-icon><span class="name">{{ $t('dashboard.profile') }}</span>
</router-link>
<router-link v-if="status.enableDomains" class="card link-domains" :to="{ name: 'domains' }">
- <svg-icon icon="globe"></svg-icon><span class="name">Domains</span>
+ <svg-icon icon="globe"></svg-icon><span class="name">{{ $t('dashboard.domains') }}</span>
</router-link>
<router-link v-if="status.enableUsers" class="card link-users" :to="{ name: 'users' }">
- <svg-icon icon="user-friends"></svg-icon><span class="name">User accounts</span>
+ <svg-icon icon="user-friends"></svg-icon><span class="name">{{ $t('dashboard.users') }}</span>
</router-link>
<router-link v-if="status.enableDistlists" class="card link-distlists" :to="{ name: 'distlists' }">
- <svg-icon icon="users"></svg-icon><span class="name">Distribution lists</span>
+ <svg-icon icon="users"></svg-icon><span class="name">{{ $t('dashboard.distlists') }}</span>
</router-link>
<router-link v-if="status.enableWallets" class="card link-wallet" :to="{ name: 'wallet' }">
- <svg-icon icon="wallet"></svg-icon><span class="name">Wallet</span>
+ <svg-icon icon="wallet"></svg-icon><span class="name">{{ $t('dashboard.wallet') }}</span>
<span v-if="balance < 0" class="badge badge-danger">{{ $root.price(balance) }}</span>
</router-link>
<router-link v-if="$root.hasSKU('meet')" class="card link-chat" :to="{ name: 'rooms' }">
- <svg-icon icon="comments"></svg-icon><span class="name">Video chat</span>
- <span class="badge badge-primary">beta</span>
+ <svg-icon icon="comments"></svg-icon><span class="name">{{ $t('dashboard.chat') }}</span>
+ <span class="badge badge-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
<a v-if="webmailURL" class="card link-webmail" :href="webmailURL">
- <svg-icon icon="envelope"></svg-icon><span class="name">Webmail</span>
+ <svg-icon icon="envelope"></svg-icon><span class="name">{{ $t('dashboard.webmail') }}</span>
</a>
</div>
</div>
</template>
<script>
import StatusComponent from './Widgets/Status'
export default {
components: {
StatusComponent
},
data() {
return {
status: {},
balance: 0,
webmailURL: window.config['app.webmail_url']
}
},
mounted() {
const authInfo = this.$store.state.authInfo
this.status = authInfo.statusInfo
this.getBalance(authInfo)
},
methods: {
getBalance(authInfo) {
this.balance = 0;
// TODO: currencies, multi-wallets, accounts
authInfo.wallets.forEach(wallet => {
this.balance += wallet.balance
})
},
statusUpdate(user) {
this.status = Object.assign({}, this.status, user)
this.$store.state.authInfo.statusInfo = this.status
}
}
}
</script>
diff --git a/src/resources/vue/Distlist/Info.vue b/src/resources/vue/Distlist/Info.vue
index 636981af..f7084c04 100644
--- a/src/resources/vue/Distlist/Info.vue
+++ b/src/resources/vue/Distlist/Info.vue
@@ -1,105 +1,105 @@
<template>
<div class="container">
<status-component v-if="list_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="distlist-info">
<div class="card-body">
<div class="card-title" v-if="list_id !== 'new'">
{{ $tc('distlist.list-title', 1) }}
<button class="btn btn-outline-danger button-delete float-right" @click="deleteList()" tag="button">
<svg-icon icon="trash-alt"></svg-icon> {{ $t('distlist.delete') }}
</button>
</div>
<div class="card-title" v-if="list_id === 'new'">{{ $t('distlist.new') }}</div>
<div class="card-text">
<form @submit.prevent="submit">
<div v-if="list_id !== 'new'" class="form-group row plaintext">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.distlistStatusClass(list) + ' form-control-plaintext'" id="status">{{ $root.distlistStatusText(list) }}</span>
</div>
</div>
<div class="form-group row">
<label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="email" :disabled="list_id !== 'new'" required v-model="list.email">
</div>
</div>
<div class="form-group row">
<label for="members-input" class="col-sm-4 col-form-label">{{ $t('distlist.recipients') }}</label>
<div class="col-sm-8">
<list-input id="members" :list="list.members"></list-input>
</div>
</div>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('button.submit') }}</button>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
ListInput,
StatusComponent
},
data() {
return {
list_id: null,
list: { members: [] },
status: {}
}
},
created() {
this.list_id = this.$route.params.list
if (this.list_id != 'new') {
this.$root.startLoading()
axios.get('/api/v4/groups/' + this.list_id)
.then(response => {
this.$root.stopLoading()
this.list = response.data
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
}
},
methods: {
deleteList() {
axios.delete('/api/v4/groups/' + this.list_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'distlists' })
}
})
},
statusUpdate(list) {
this.list = Object.assign({}, this.list, list)
},
submit() {
this.$root.clearFormValidation($('#list-info form'))
let method = 'post'
let location = '/api/v4/groups'
if (this.list_id !== 'new') {
method = 'put'
location += '/' + this.list_id
}
axios[method](location, this.list)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'distlists' })
})
}
}
}
</script>
diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue
index 377d731f..12afc0df 100644
--- a/src/resources/vue/Domain/Info.vue
+++ b/src/resources/vue/Domain/Info.vue
@@ -1,92 +1,89 @@
<template>
<div class="container">
<status-component :status="status" @status-update="statusUpdate"></status-component>
<div v-if="domain && !domain.isConfirmed" class="card" id="domain-verify">
<div class="card-body">
- <div class="card-title">Domain verification</div>
+ <div class="card-title">{{ $t('domain.verify') }}</div>
<div class="card-text">
- <p>In order to confirm that you're the actual holder of the domain,
- we need to run a verification process before finally activating it for email delivery.</p>
- <p>The domain <b>must have one of the following entries</b> in DNS:
+ <p>{{ $t('domain.verify-intro') }}</p>
+ <p>
+ <span v-html="$t('domain.verify-dns')"></span>
<ul>
- <li>TXT entry with value: <code>{{ domain.hash_text }}</code></li>
- <li>or CNAME entry: <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
+ <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>
- When this is done press the button below to start the verification.</p>
- <p>Here's a sample zone file for your domain: <pre>{{ domain.dns.join("\n") }}</pre></p>
- <button class="btn btn-primary" type="button" @click="confirm"><svg-icon icon="sync-alt"></svg-icon> Verify</button>
+ <span>{{ $t('domain.verify-outro') }}</span>
+ </p>
+ <p>{{ $t('domain.verify-sample') }} <pre>{{ domain.dns.join("\n") }}</pre></p>
+ <button class="btn btn-primary" type="button" @click="confirm"><svg-icon icon="sync-alt"></svg-icon> {{ $t('btn.verify') }}</button>
</div>
</div>
</div>
<div v-if="domain && domain.isConfirmed" class="card" id="domain-config">
<div class="card-body">
- <div class="card-title">Domain configuration</div>
+ <div class="card-title">{{ $t('domain.config') }}</div>
<div class="card-text">
- <p>In order to let {{ $root.appName }} receive email traffic for your domain you need to adjust
- the DNS settings, more precisely the MX entries, accordingly.</p>
- <p>Edit your domain's zone file and replace existing MX
- entries with the following values: <pre>{{ domain.config.join("\n") }}</pre></p>
- <p>If you don't know how to set DNS entries for your domain,
- please contact the registration service where you registered
- the domain or your web hosting provider.</p>
+ <p>{{ $t('domain.config-intro', { app: $root.appName }) }}</p>
+ <p>{{ $t('domain.config-sample') }} <pre>{{ domain.config.join("\n") }}</pre></p>
+ <p>{{ $t('domain.config-hint') }}</p>
</div>
</div>
</div>
</div>
</template>
<script>
import StatusComponent from '../Widgets/Status'
export default {
components: {
StatusComponent
},
data() {
return {
domain_id: null,
domain: null,
status: {}
}
},
created() {
if (this.domain_id = this.$route.params.domain) {
this.$root.startLoading()
axios.get('/api/v4/domains/' + this.domain_id)
.then(response => {
this.$root.stopLoading()
this.domain = response.data
if (!this.domain.isConfirmed) {
$('#domain-verify button').focus()
}
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
} else {
this.$root.errorPage(404)
}
},
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)
}
})
},
statusUpdate(domain) {
this.domain = Object.assign({}, this.domain, domain)
}
}
}
</script>
diff --git a/src/resources/vue/Domain/List.vue b/src/resources/vue/Domain/List.vue
index ca8cfed1..814aea30 100644
--- a/src/resources/vue/Domain/List.vue
+++ b/src/resources/vue/Domain/List.vue
@@ -1,53 +1,53 @@
<template>
<div class="container">
<div class="card" id="domain-list">
<div class="card-body">
- <div class="card-title">Domains</div>
+ <div class="card-title">{{ $t('user.domains') }}</div>
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
- <th scope="col">Name</th>
+ <th scope="col">{{ $t('domain.namespace') }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="domain in domains" :key="domain.id" @click="$root.clickRecord">
<td>
<svg-icon icon="globe" :class="$root.domainStatusClass(domain)" :title="$root.domainStatusText(domain)"></svg-icon>
<router-link :to="{ path: 'domain/' + domain.id }">{{ domain.namespace }}</router-link>
</td>
<td class="buttons"></td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td colspan="2">There are no domains in this account.</td>
+ <td colspan="2">{{ $t('user.domains-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
domains: []
}
},
created() {
this.$root.startLoading()
axios.get('/api/v4/domains')
.then(response => {
this.$root.stopLoading()
this.domains = response.data
})
.catch(this.$root.errorHandler)
}
}
</script>
diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue
index d6bc9de6..2a59ba87 100644
--- a/src/resources/vue/Meet/Room.vue
+++ b/src/resources/vue/Meet/Room.vue
@@ -1,759 +1,759 @@
<template>
<div id="meet-component">
<div id="meet-session-toolbar" class="hidden">
<span id="meet-counter" :title="$t('meet.partcnt')"><svg-icon icon="users"></svg-icon> <span></span></span>
<span id="meet-session-logo" v-html="$root.logo()"></span>
<div id="meet-session-menu">
<button :class="'btn link-audio' + (audioActive ? '' : ' on')" @click="switchSound" :disabled="!isPublisher()" :title="$t('meet.menu-audio-' + (audioActive ? 'mute' : 'unmute'))">
<svg-icon :icon="audioActive ? 'microphone' : 'microphone-slash'"></svg-icon>
</button>
<button :class="'btn link-video' + (videoActive ? '' : ' on')" @click="switchVideo" :disabled="!isPublisher()" :title="$t('meet.menu-video-' + (videoActive ? 'mute' : 'unmute'))">
<svg-icon :icon="videoActive ? 'video' : 'video-slash'"></svg-icon>
</button>
<button :class="'btn link-screen' + (screenShareActive ? ' on' : '')" @click="switchScreen" :disabled="!canShareScreen || !isPublisher()" :title="$t('meet.menu-screen')">
<svg-icon icon="desktop"></svg-icon>
</button>
<button :class="'btn link-hand' + (handRaised ? ' on' : '')" v-if="!isPublisher()" @click="switchHand" :title="$t('meet.menu-hand-' + (handRaised ? 'lower' : 'raise'))">
<svg-icon icon="hand-paper"></svg-icon>
</button>
<span id="channel-select" :style="'display:' + (channels.length ? '' : 'none')" class="dropdown">
<button :class="'btn link-channel' + (session.channel ? ' on' : '')" data-toggle="dropdown"
:title="$t('meet.menu-channel')" aria-haspopup="true" aria-expanded="false"
>
<svg-icon icon="headphones"></svg-icon>
<span class="badge badge-danger" v-if="session.channel">{{ session.channel.toUpperCase() }}</span>
</button>
<div class="dropdown-menu">
<a :class="'dropdown-item' + (!session.channel ? ' active' : '')" href="#" data-code="" @click="switchChannel">- {{ $t('form.none') }} -</a>
<a v-for="code in channels" :key="code" href="#" @click="switchChannel" :data-code="code"
:class="'dropdown-item' + (session.channel == code ? ' active' : '')"
>{{ $t('lang.' + code) }}</a>
</div>
</span>
<button :class="'btn link-chat' + (chatActive ? ' on' : '')" @click="switchChat" :title="$t('meet.menu-chat')">
<svg-icon icon="comment"></svg-icon>
</button>
<button class="btn link-fullscreen closed hidden" @click="switchFullscreen" :title="$t('meet.menu-fullscreen')">
<svg-icon icon="expand"></svg-icon>
</button>
<button class="btn link-fullscreen open hidden" @click="switchFullscreen" :title="$t('meet.menu-fullscreen-exit')">
<svg-icon icon="compress"></svg-icon>
</button>
<button class="btn link-options" v-if="isRoomOwner()" @click="roomOptions" :title="$t('meet.options')">
<svg-icon icon="cog"></svg-icon>
</button>
<button class="btn link-logout" @click="logout" :title="$t('meet.menu-leave')">
<svg-icon icon="power-off"></svg-icon>
</button>
</div>
</div>
<div id="meet-setup" class="card container mt-2 mt-md-5 mb-5">
<div class="card-body">
<div class="card-title">{{ $t('meet.setup-title') }}</div>
<div class="card-text">
<form class="media-setup-form row" @submit.prevent="joinSession">
<div class="media-setup-preview col-sm-6 mb-3 mb-sm-0">
<video class="rounded"></video>
<div class="volume"><div class="bar"></div></div>
</div>
<div class="col-sm-6 align-self-center">
<div class="input-group">
<label for="setup-microphone" class="input-group-prepend mb-0">
<span class="input-group-text" :title="$t('meet.mic')"><svg-icon icon="microphone"></svg-icon></span>
</label>
<select class="custom-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 mt-2">
<label for="setup-camera" class="input-group-prepend mb-0">
<span class="input-group-text" :title="$t('meet.cam')"><svg-icon icon="video"></svg-icon></span>
</label>
<select class="custom-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 mt-2">
<label for="setup-nickname" class="input-group-prepend mb-0">
<span class="input-group-text" :title="$t('meet.nick')"><svg-icon icon="user"></svg-icon></span>
</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-prepend mb-0">
<span class="input-group-text" :title="$t('form.password')"><svg-icon icon="key"></svg-icon></span>
</label>
<input type="password" class="form-control" id="setup-password" v-model="password" :placeholder="$t('form.password')">
</div>
<div class="mt-3">
<button type="submit" id="join-button"
:class="'btn w-100 btn-' + (isRoomReady() ? 'success' : 'primary')"
>
<span v-if="isRoomReady()">{{ $t('meet.joinnow') }}</span>
<span v-else-if="roomState == 323">{{ $t('meet.imaowner') }}</span>
<span v-else>{{ $t('meet.join') }}</span>
</button>
</div>
</div>
<div class="mt-4 col-sm-12">
<status-message :status="roomState" :status-labels="roomStateLabels"></status-message>
</div>
</form>
</div>
</div>
</div>
<div id="meet-session-layout" class="d-flex hidden">
<div id="meet-queue">
<div class="head" :title="$t('meet.qa')"><svg-icon icon="microphone-alt"></svg-icon></div>
</div>
<div id="meet-session"></div>
<div id="meet-chat">
<div class="chat"></div>
<div class="chat-input m-2">
<textarea class="form-control" rows="1"></textarea>
</div>
</div>
</div>
<logon-form id="meet-auth" class="hidden" :dashboard="false" @success="authSuccess"></logon-form>
<div id="leave-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('meet.leave-title') }}</h5>
- <button type="button" class="close" data-dismiss="modal" :aria-label="$t('button.close')">
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>{{ $t('meet.leave-body') }}</p>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-danger modal-action" data-dismiss="modal">{{ $t('button.close') }}</button>
+ <button type="button" class="btn btn-danger modal-action" data-dismiss="modal">{{ $t('btn.close') }}</button>
</div>
</div>
</div>
</div>
<div id="media-setup-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('meet.media-title') }}</h5>
- <button type="button" class="close" data-dismiss="modal" :aria-label="$t('button.close')">
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</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-prepend mb-0">
<span class="input-group-text" :title="$t('meet.mic')"><svg-icon icon="microphone"></svg-icon></span>
</label>
<select class="custom-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-prepend mb-0">
<span class="input-group-text" :title="$t('meet.cam')"><svg-icon icon="video"></svg-icon></span>
</label>
<select class="custom-select" id="setup-cam" v-model="camera" @change="setupCameraChange">
<option value="">{{ $t('form.none') }}</option>
<option v-for="cam in setup.cameras" :value="cam.deviceId" :key="cam.deviceId">{{ cam.label }}</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-action" data-dismiss="modal">{{ $t('button.close') }}</button>
+ <button type="button" class="btn btn-secondary modal-action" data-dismiss="modal">{{ $t('btn.close') }}</button>
</div>
</div>
</div>
</div>
<room-options v-if="session.config" :config="session.config" :room="room" @config-update="configUpdate"></room-options>
</div>
</template>
<script>
import { Meet, Roles } from '../../js/meet/app.js'
import StatusMessage from '../Widgets/StatusMessage'
import LogonForm from '../Login'
import RoomOptions from './RoomOptions'
// Register additional icons
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faComment,
faCog,
faCompress,
faCrown,
faDesktop,
faExpand,
faHandPaper,
faHeadphones,
faMicrophone,
faMicrophoneSlash,
faMicrophoneAlt,
faPowerOff,
faUser,
faUsers,
faVideo,
faVideoSlash,
faVolumeMute
} from '@fortawesome/free-solid-svg-icons'
// Register only these icons we need
library.add(
faComment,
faCog,
faCompress,
faCrown,
faDesktop,
faExpand,
faHandPaper,
faHeadphones,
faMicrophone,
faMicrophoneSlash,
faMicrophoneAlt,
faPowerOff,
faUser,
faUsers,
faVideo,
faVideoSlash,
faVolumeMute
)
let roomRequest
const authHeader = 'X-Meet-Auth-Token'
export default {
components: {
LogonForm,
RoomOptions,
StatusMessage
},
data() {
return {
setup: {
cameras: [],
microphones: [],
},
canShareScreen: false,
camera: '',
channels: [],
languages: {
en: 'lang.en',
de: 'lang.de',
fr: 'lang.fr',
it: 'lang.it'
},
meet: null,
microphone: '',
nickname: '',
password: '',
room: null,
roomState: 'init',
roomStateLabels: {
'init': 'meet.status-init',
323: 'meet.status-323',
324: 'meet.status-324',
325: 'meet.status-325',
326: 'meet.status-326',
327: 'meet.status-327',
404: 'meet.status-404',
429: 'meet.status-429',
500: 'meet.status-500'
},
session: {},
audioActive: false,
videoActive: false,
chatActive: false,
handRaised: false,
screenShareActive: false
}
},
mounted() {
this.room = this.$route.params.room
// Initialize OpenVidu and do some basic checks
this.meet = new Meet($('#meet-session')[0]);
this.canShareScreen = this.meet.isScreenSharingSupported()
// Check the room and init the session
this.initSession()
// Setup the room UI
this.setupSession()
},
beforeDestroy() {
clearTimeout(roomRequest)
$('#app').removeClass('meet')
if (this.meet) {
this.meet.leaveRoom()
}
delete axios.defaults.headers.common[authHeader]
$(document.body).off('keydown.meet')
},
methods: {
authSuccess() {
// The user authentication succeeded, we still don't know it's really the room owner
this.initSession()
$('#meet-setup').removeClass('hidden')
$('#meet-auth').addClass('hidden')
},
configUpdate(config) {
this.session.config = Object.assign({}, this.session.config, config)
},
dismissParticipant(id) {
axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections/' + id + '/dismiss')
},
initSession(init) {
const button = $('#join-button').prop('disabled', true)
this.post = {
password: this.password,
nickname: this.nickname,
screenShare: this.canShareScreen ? 1 : 0,
init: init ? 1 : 0,
picture: init ? this.makePicture() : '',
requestId: this.requestId(),
canPublish: !!this.camera || !!this.microphone
}
$('#setup-password,#setup-nickname').removeClass('is-invalid')
axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true })
.then(response => {
button.prop('disabled', false)
// We already have token, the response is redundant
if (this.roomState == 'ready' && this.session.token) {
return
}
this.roomState = 'ready'
this.session = response.data
if (init) {
this.joinSession()
}
if (this.session.authToken) {
axios.defaults.headers.common[authHeader] = this.session.authToken
}
})
.catch(error => {
if (!error.response) {
console.error(error)
return
}
const data = error.response.data || {}
if (data.code) {
this.roomState = data.code
} else {
this.roomState = error.response.status
}
button.prop('disabled', this.roomState == 'init' || this.roomState == 327 || this.roomState >= 400)
if (data.config) {
this.session.config = data.config
}
switch (this.roomState) {
case 323:
// Waiting for the owner to open the room...
// Update room state every 10 seconds
roomRequest = setTimeout(() => { this.initSession() }, 10000)
break;
case 324:
// Room is ready for the owner, but the 'init' was not requested yet
clearTimeout(roomRequest)
break;
case 325:
// Missing/invalid password
if (init) {
$('#setup-password').addClass('is-invalid').focus()
}
break;
case 326:
// Locked room prerequisites error
if (init && !$('#setup-nickname').val()) {
$('#setup-nickname').addClass('is-invalid').focus()
}
break;
case 327:
// Waiting for the owner's approval to join
// Update room state every 10 seconds
roomRequest = setTimeout(() => { this.initSession(true) }, 10000)
break;
case 429:
// Rate limited, wait and try again
const waitTime = error.response.headers['retry-after'] || 10
roomRequest = setTimeout(() => { this.initSession(init) }, waitTime * 1000)
break;
default:
if (this.roomState >= 400 && this.roomState != 404) {
this.roomState = 500
}
}
})
if (document.fullscreenEnabled) {
$('#meet-session-menu').find('.link-fullscreen.closed').removeClass('hidden')
}
},
isModerator() {
return this.isRoomOwner() || (!!this.session.role && (this.session.role & Roles.MODERATOR) > 0)
},
isPublisher() {
return !!this.session.role && (this.session.role & Roles.PUBLISHER) > 0
},
isRoomOwner() {
return !!this.session.role && (this.session.role & Roles.OWNER) > 0
},
isRoomReady() {
return ['ready', 322, 324, 325, 326, 327].includes(this.roomState)
},
// An event received by the room owner when a participant is asking for a permission to join the room
joinRequest(data) {
// The toast for this user request already exists, ignore
// It's not really needed as we do this on server-side already
if ($('#i' + data.requestId).length) {
return
}
// FIXME: Should the message close button act as the Deny button? Do we need the Deny button?
let body = $(
`<div>`
+ `<div class="picture"><img src="${data.picture}"></div>`
+ `<div class="content">`
+ `<p class="mb-2"></p>`
+ `<div class="text-right">`
- + `<button type="button" class="btn btn-sm btn-success accept">${this.$t('button.accept')}</button>`
- + `<button type="button" class="btn btn-sm btn-danger deny ml-2">${this.$t('button.deny')}</button>`
+ + `<button type="button" class="btn btn-sm btn-success accept">${this.$t('btn.accept')}</button>`
+ + `<button type="button" class="btn btn-sm btn-danger deny ml-2">${this.$t('btn.deny')}</button>`
)
this.$toast.message({
className: 'join-request',
icon: 'user',
timeout: 0,
title: this.$t('meet.join-request'),
// titleClassName: '',
body: body.html(),
onShow: element => {
const id = data.requestId
$(element).find('p').text(this.$t('meet.join-requested', { user: data.nickname || '' }))
// add id attribute, so we can identify it
$(element).attr('id', 'i' + id)
// add action to the buttons
.find('button.accept,button.deny').on('click', e => {
const action = $(e.target).is('.accept') ? 'accept' : 'deny'
axios.post('/api/v4/openvidu/rooms/' + this.room + '/request/' + id + '/' + action)
.then(response => {
$('#i' + id).remove()
})
})
}
})
},
// Entering the room
joinSession() {
// The form can be submitted not only via the submit button,
// make sure the submit is allowed
if ($('#meet-setup [type=submit]').prop('disabled')) {
return;
}
if (this.roomState == 323) {
$('#meet-setup').addClass('hidden')
$('#meet-auth').removeClass('hidden')
return
}
if (this.roomState != 'ready' && !this.session.token) {
this.initSession(true)
return
}
clearTimeout(roomRequest)
this.session.nickname = this.nickname
this.session.languages = this.languages
this.session.menuElement = $('#meet-session-menu')[0]
this.session.chatElement = $('#meet-chat')[0]
this.session.queueElement = $('#meet-queue')[0]
this.session.counterElement = $('#meet-counter span')[0]
this.session.translate = (label, args) => this.$t(label, args)
this.session.onSuccess = () => {
$('#app').addClass('meet')
$('#meet-setup').addClass('hidden')
$('#meet-session-toolbar,#meet-session-layout').removeClass('hidden')
}
this.session.onError = () => {
this.roomState = 500
}
this.session.onDestroy = event => {
// TODO: Display different message for each reason: forceDisconnectByUser,
// forceDisconnectByServer, sessionClosedByServer?
if (event.reason != 'disconnect' && event.reason != 'networkDisconnect' && !this.isRoomOwner()) {
$('#leave-dialog').on('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' })
}).modal()
}
}
this.session.onDismiss = connId => { this.dismissParticipant(connId) }
this.session.onSessionDataUpdate = data => { this.updateSession(data) }
this.session.onConnectionChange = (connId, data) => { this.updateParticipant(connId, data) }
this.session.onJoinRequest = data => { this.joinRequest(data) }
this.session.onMediaSetup = () => { this.setupMedia() }
this.meet.joinRoom(this.session)
this.keyboardShortcuts()
},
keyboardShortcuts() {
$(document.body).on('keydown.meet', e => {
if ($(e.target).is('select,input,textarea')) {
return
}
// Self-Mute with 'm' key
if (e.key == 'm' || e.key == 'M') {
if ($('#meet-session-menu').find('.link-audio:not(:disabled)').length) {
this.switchSound()
}
}
})
},
logout() {
const logout = () => {
this.meet.leaveRoom()
this.meet = null
this.$router.push({ name: 'dashboard' })
}
if (this.isRoomOwner()) {
axios.post('/api/v4/openvidu/rooms/' + this.room + '/close').then(logout)
} else {
logout()
}
},
makePicture() {
const video = $("#meet-setup video")[0];
// Skip if video is not "playing"
if (!video.videoWidth || !this.camera) {
return ''
}
// we're going to crop a square from the video and resize it
const maxSize = 64
// Calculate sizing
let sh = Math.floor(video.videoHeight / 1.5)
let sw = sh
let sx = (video.videoWidth - sw) / 2
let sy = (video.videoHeight - sh) / 2
let dh = Math.min(sh, maxSize)
let dw = sh < maxSize ? sw : Math.floor(sw * dh/sh)
const canvas = $("<canvas>")[0];
canvas.width = dw;
canvas.height = dh;
// draw the image on the canvas (square cropped and resized)
canvas.getContext('2d').drawImage(video, sx, sy, sw, sh, 0, 0, dw, dh);
// convert it to a usable data URL (png format)
return canvas.toDataURL();
},
requestId() {
const key = 'kolab-meet-uid'
if (!this.reqId) {
this.reqId = localStorage.getItem(key)
}
if (!this.reqId) {
// We store the identifier in the browser to make sure that it is the same after
// page refresh for the avg user. This will not prevent hackers from sending
// the new identifier on every request.
// If we're afraid of a room owner being spammed with join requests we might invent
// a way to silently ignore all join requests after the owner pressed some button
// stating "all attendees already joined, lock the room for good!".
// This will create max. 24-char numeric string
this.reqId = (String(Date.now()) + String(Math.random()).substring(2)).substring(0, 24)
localStorage.setItem(key, this.reqId)
}
return this.reqId
},
roomOptions() {
$('#room-options-dialog').modal()
},
setupMedia() {
let dialog = $('#media-setup-dialog')
if (!dialog.find('video').length) {
$('#meet-setup').find('video,div.volume').appendTo(dialog.find('.media-setup-preview'))
}
dialog.on('show.bs.modal', () => { this.meet.setupStart() })
.on('hide.bs.modal', () => { this.meet.setupStop() })
.modal()
},
setupSession() {
this.meet.setupStart({
videoElement: $('#meet-setup video')[0],
volumeElement: $('#meet-setup .volume')[0],
onSuccess: setup => {
this.setup = setup
this.microphone = setup.audioSource
this.camera = setup.videoSource
this.audioActive = setup.audioActive
this.videoActive = setup.videoActive
},
onError: error => {
this.audioActive = false
this.videoActive = false
}
})
},
setupCameraChange() {
this.meet.setupSetVideoDevice(this.camera).then(enabled => {
this.videoActive = enabled
})
},
setupMicrophoneChange() {
this.meet.setupSetAudioDevice(this.microphone).then(enabled => {
this.audioActive = enabled
})
},
switchChannel(e) {
let channel = $(e.target).data('code')
this.$set(this.session, 'channel', channel)
this.meet.switchChannel(channel)
},
switchChat() {
let chat = $('#meet-chat')
let enabled = chat.is('.open')
chat.toggleClass('open')
if (!enabled) {
chat.find('textarea').focus()
}
this.chatActive = !enabled
// Trigger resize, so participant matrix can update its layout
window.dispatchEvent(new Event('resize'));
},
switchFullscreen() {
const element = this.$el
$(element).off('fullscreenchange').on('fullscreenchange', (e) => {
let enabled = document.fullscreenElement == element
let buttons = $('#meet-session-menu').find('.link-fullscreen')
buttons.first()[enabled ? 'addClass' : 'removeClass']('hidden')
buttons.last()[!enabled ? 'addClass' : 'removeClass']('hidden')
})
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
element.requestFullscreen()
}
},
switchHand() {
this.updateSelf({ hand: !this.handRaised })
},
switchSound() {
this.audioActive = this.meet.switchAudio()
},
switchVideo() {
this.videoActive = this.meet.switchVideo()
},
switchScreen() {
const switchScreenAction = () => {
this.meet.switchScreen((enabled, error) => {
this.screenShareActive = enabled
if (!enabled && !error) {
// Closing a screen sharing connection invalidates the token
delete this.session.shareToken
}
})
}
if (this.session.shareToken || this.screenShareActive) {
switchScreenAction()
} else {
axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections')
.then(response => {
this.session.shareToken = response.data.token
this.meet.updateSession(this.session)
switchScreenAction()
})
}
},
updateParticipant(connId, params) {
if (this.isModerator()) {
axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + connId, params)
}
},
updateSelf(params, onSuccess) {
axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + this.session.connectionId, params)
.then(response => {
if (onSuccess) {
onSuccess(response)
}
})
},
updateSession(data) {
this.session = data
this.channels = data.channels || []
const isPublisher = this.isPublisher()
this.videoActive = isPublisher ? data.videoActive : false
this.audioActive = isPublisher ? data.audioActive : false
this.handRaised = data.hand
}
}
}
</script>
diff --git a/src/resources/vue/Meet/RoomOptions.vue b/src/resources/vue/Meet/RoomOptions.vue
index a3ba2631..b9786afd 100644
--- a/src/resources/vue/Meet/RoomOptions.vue
+++ b/src/resources/vue/Meet/RoomOptions.vue
@@ -1,117 +1,117 @@
<template>
<div v-if="config">
<div id="room-options-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('meet.options') }}</h5>
- <button type="button" class="close" data-dismiss="modal" :aria-label="$t('button.close')">
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form id="room-options-password">
<div id="password-input" class="input-group input-group-activable">
<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-left activable">
<div class="input-group-append">
- <button type="button" @click="passwordSave" id="password-save-btn" class="btn btn-outline-primary activable rounded-right">{{ $t('button.save') }}</button>
+ <button type="button" @click="passwordSave" id="password-save-btn" class="btn btn-outline-primary activable rounded-right">{{ $t('btn.save') }}</button>
<button type="button" v-if="config.password" id="password-clear-btn" @click="passwordClear" class="btn btn-outline-danger rounded">{{ $t('meet.password-clear') }}</button>
<button type="button" v-else @click="passwordSet" id="password-set-btn" class="btn btn-outline-primary rounded">{{ $t('meet.password-set') }}</button>
</div>
</div>
<small class="form-text text-muted">
{{ $t('meet.password-text') }}
</small>
</form>
<hr>
<form id="room-options-lock">
<div id="room-lock">
<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="form-text text-muted">
{{ $t('meet.lock-text') }}
</small>
</form>
<hr>
<form id="room-options-nomedia">
<div id="room-nomedia">
<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="form-text text-muted">
{{ $t('meet.nomedia-text') }}
</small>
</form>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-action" data-dismiss="modal">{{ $t('button.close') }}</button>
+ <button type="button" class="btn btn-secondary modal-action" data-dismiss="modal">{{ $t('btn.close') }}</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
config: { type: Object, default: () => null },
room: { type: String, default: () => null }
},
mounted() {
$('#room-options-dialog').on('show.bs.modal', e => {
$(e.target).find('.input-group-activable.active').removeClass('active')
})
},
methods: {
configSave(name, value, callback) {
const post = {}
post[name] = value
axios.post('/api/v4/openvidu/rooms/' + this.room + '/config', post)
.then(response => {
this.config[name] = value
if (callback) {
callback(response.data)
}
this.$emit('config-update', this.config)
this.$toast.success(response.data.message)
})
},
lockSave(e) {
this.configSave('locked', $(e.target).prop('checked') ? 1 : 0)
},
nomediaSave(e) {
this.configSave('nomedia', $(e.target).prop('checked') ? 1 : 0)
},
passwordClear() {
this.configSave('password', '')
},
passwordSave() {
this.configSave('password', $('#password-input input').val(), () => {
$('#password-input').removeClass('active')
})
},
passwordSet() {
$('#password-input').addClass('active').find('input')
.off('keydown.pass')
.on('keydown.pass', e => {
if (e.which == 13) {
// On ENTER save the password
this.passwordSave()
e.preventDefault()
} else if (e.which == 27) {
// On ESC escape from the input, but not the dialog
$('#password-input').removeClass('active')
e.stopPropagation()
}
})
.focus()
}
}
}
</script>
diff --git a/src/resources/vue/PasswordReset.vue b/src/resources/vue/PasswordReset.vue
index 30437d64..a4e00253 100644
--- a/src/resources/vue/PasswordReset.vue
+++ b/src/resources/vue/PasswordReset.vue
@@ -1,156 +1,156 @@
<template>
<div class="container">
<div class="card" id="step1">
<div class="card-body">
<h4 class="card-title">{{ $t('password.reset') }} - {{ $t('nav.step', { i: 1, n: 3 }) }}</h4>
<p class="card-text">
{{ $t('password.reset-step1') }}
<span v-if="fromEmail">{{ $t('password.reset-step1-hint', { email: fromEmail }) }}</span>
</p>
<form @submit.prevent="submitStep1" data-validation-prefix="reset_">
<div class="form-group">
<label for="reset_email" class="sr-only">{{ $t('form.email') }}</label>
<input type="text" class="form-control" id="reset_email" :placeholder="$t('form.email')" required v-model="email">
</div>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('button.continue') }}</button>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.continue') }}</button>
</form>
</div>
</div>
<div class="card d-none" id="step2">
<div class="card-body">
<h4 class="card-title">{{ $t('password.reset') }} - {{ $t('nav.step', { i: 2, n: 3 }) }}</h4>
<p class="card-text">
{{ $t('password.reset-step2') }}
</p>
<form @submit.prevent="submitStep2" data-validation-prefix="reset_">
<div class="form-group">
<label for="reset_short_code" class="sr-only">{{ $t('form.code') }}</label>
<input type="text" class="form-control" id="reset_short_code" :placeholder="$t('form.code')" required v-model="short_code">
</div>
- <button class="btn btn-secondary" type="button" @click="stepBack">{{ $t('button.back') }}</button>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('button.continue') }}</button>
+ <button class="btn btn-secondary" type="button" @click="stepBack">{{ $t('btn.back') }}</button>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.continue') }}</button>
<input type="hidden" id="reset_code" v-model="code" />
</form>
</div>
</div>
<div class="card d-none" id="step3">
<div class="card-body">
<h4 class="card-title">{{ $t('password.reset') }} - {{ $t('nav.step', { i: 3, n: 3 }) }}</h4>
<p class="card-text">
</p>
<form @submit.prevent="submitStep3" data-validation-prefix="reset_">
<div class="form-group">
<label for="reset_password" class="sr-only">{{ $t('form.password') }}</label>
<input type="password" class="form-control" id="reset_password" :placeholder="$t('form.password')" required v-model="password">
</div>
<div class="form-group">
<label for="reset_confirm" class="sr-only">{{ $t('form.password-confirm') }}</label>
<input type="password" class="form-control" id="reset_confirm" :placeholder="$t('form.password-confirm')" required v-model="password_confirmation">
</div>
- <button class="btn btn-secondary" type="button" @click="stepBack">{{ $t('button.back') }}</button>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('button.submit') }}</button>
+ <button class="btn btn-secondary" type="button" @click="stepBack">{{ $t('btn.back') }}</button>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
email: '',
code: '',
short_code: '',
password: '',
password_confirmation: '',
fromEmail: window.config['mail.from.address']
}
},
created() {
// Verification code provided, auto-submit Step 2
if (this.$route.params.code) {
if (/^([A-Z0-9]+)-([a-zA-Z0-9]+)$/.test(this.$route.params.code)) {
this.short_code = RegExp.$1
this.code = RegExp.$2
this.submitStep2(true)
}
else {
this.$root.errorPage(404)
}
}
},
mounted() {
// Focus the first input (autofocus does not work when using the menu/router)
this.displayForm(1, true)
},
methods: {
// Submits data to the API, validates and gets verification code
submitStep1() {
this.$root.clearFormValidation($('#step1 form'))
axios.post('/api/auth/password-reset/init', {
email: this.email
}).then(response => {
this.displayForm(2, true)
this.code = response.data.code
})
},
// Submits the code to the API for verification
submitStep2(bylink) {
if (bylink === true) {
this.displayForm(2, false)
}
this.$root.clearFormValidation($('#step2 form'))
axios.post('/api/auth/password-reset/verify', {
code: this.code,
short_code: this.short_code
}).then(response => {
this.displayForm(3, true)
}).catch(error => {
if (bylink === true) {
// FIXME: display step 1, user can do nothing about it anyway
// Maybe we should display 404 error page?
this.displayForm(1, true)
}
})
},
// Submits the data to the API to reset the password
submitStep3() {
this.$root.clearFormValidation($('#step3 form'))
axios.post('/api/auth/password-reset', {
code: this.code,
short_code: this.short_code,
password: this.password,
password_confirmation: this.password_confirmation
}).then(response => {
// auto-login and goto dashboard
this.$root.loginUser(response.data)
})
},
// Moves the user a step back in registration form
stepBack(e) {
var card = $(e.target).closest('.card')
card.prev().removeClass('d-none').find('input').first().focus()
card.addClass('d-none').find('form')[0].reset()
},
displayForm(step, focus) {
[1, 2, 3].filter(value => value != step).forEach(value => {
$('#step' + value).addClass('d-none')
})
$('#step' + step).removeClass('d-none')
if (focus) {
$('#step' + step).find('input').first().focus()
}
}
}
}
</script>
diff --git a/src/resources/vue/Reseller/Dashboard.vue b/src/resources/vue/Reseller/Dashboard.vue
index 95791e1c..76019d8e 100644
--- a/src/resources/vue/Reseller/Dashboard.vue
+++ b/src/resources/vue/Reseller/Dashboard.vue
@@ -1,51 +1,51 @@
<template>
<div class="container" dusk="dashboard-component">
<user-search></user-search>
<div id="dashboard-nav" class="mt-3">
<router-link v-if="status.enableWallets" class="card link-wallet" :to="{ name: 'wallet' }">
- <svg-icon icon="wallet"></svg-icon><span class="name">Wallet</span>
+ <svg-icon icon="wallet"></svg-icon><span class="name">{{ $t('dashboard.wallet') }}</span>
<span :class="'badge badge-' + (balance < 0 ? 'danger' : 'success')">{{ $root.price(balance) }}</span>
</router-link>
<router-link class="card link-invitations" :to="{ name: 'invitations' }">
- <svg-icon icon="envelope-open-text"></svg-icon><span class="name">Invitations</span>
+ <svg-icon icon="envelope-open-text"></svg-icon><span class="name">{{ $t('dashboard.invitations') }}</span>
</router-link>
<router-link class="card link-stats" :to="{ name: 'stats' }">
- <svg-icon icon="chart-line"></svg-icon><span class="name">Stats</span>
+ <svg-icon icon="chart-line"></svg-icon><span class="name">{{ $t('dashboard.stats') }}</span>
</router-link>
</div>
</div>
</template>
<script>
import UserSearch from '../Widgets/UserSearch'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChartLine, faEnvelopeOpenText, faWallet } from '@fortawesome/free-solid-svg-icons'
library.add(faChartLine, faEnvelopeOpenText, faWallet)
export default {
components: {
UserSearch
},
data() {
return {
status: {},
balance: 0
}
},
mounted() {
const authInfo = this.$store.state.authInfo
this.status = authInfo.statusInfo
this.getBalance(authInfo)
},
methods: {
getBalance(authInfo) {
this.balance = 0;
// TODO: currencies, multi-wallets, accounts
authInfo.wallets.forEach(wallet => {
this.balance += wallet.balance
})
}
}
}
</script>
diff --git a/src/resources/vue/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue
index c2c89312..86f624cb 100644
--- a/src/resources/vue/Reseller/Invitations.vue
+++ b/src/resources/vue/Reseller/Invitations.vue
@@ -1,283 +1,280 @@
<template>
<div class="container">
<div class="card" id="invitations">
<div class="card-body">
<div class="card-title">
- Signup Invitations
+ {{ $t('invitation.title') }}
</div>
<div class="card-text">
<div class="mb-2 d-flex">
<form @submit.prevent="searchInvitations" id="search-form" class="input-group" style="flex:1">
- <input class="form-control" type="text" placeholder="Email address or domain" v-model="search">
+ <input class="form-control" type="text" :placeholder="$t('invitation.search')" v-model="search">
<div class="input-group-append">
- <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> Search</button>
+ <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> {{ $t('btn.search') }}</button>
</div>
</form>
<div>
<button class="btn btn-success create-invite ml-1" @click="inviteUserDialog">
- <svg-icon icon="envelope-open-text"></svg-icon> Create invite(s)
+ <svg-icon icon="envelope-open-text"></svg-icon> {{ $t('invitation.create') }}
</button>
</div>
</div>
<table id="invitations-list" class="table table-sm table-hover">
<thead class="thead-light">
<tr>
- <th scope="col">External Email</th>
- <th scope="col">Created</th>
+ <th scope="col">{{ $t('user.ext-email') }}</th>
+ <th scope="col">{{ $t('form.created') }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invitations" :id="'i' + inv.id" :key="inv.id">
<td class="email">
- <svg-icon icon="envelope-open-text" :class="statusClass(inv)" :title="statusText(inv)"></svg-icon>
+ <svg-icon icon="envelope-open-text" :class="statusClass(inv)" :title="$t('invitation.status-' + statusLabel(inv))"></svg-icon>
<span>{{ inv.email }}</span>
</td>
<td class="datetime">
{{ inv.created }}
</td>
<td class="buttons">
<button class="btn text-danger button-delete p-0 ml-1" @click="deleteInvite(inv.id)">
<svg-icon icon="trash-alt"></svg-icon>
- <span class="btn-label">Delete</span>
+ <span class="btn-label">{{ $t('btn.delete') }}</span>
</button>
<button class="btn button-resend p-0 ml-1" :disabled="inv.isNew || inv.isCompleted" @click="resendInvite(inv.id)">
<svg-icon icon="redo"></svg-icon>
- <span class="btn-label">Resend</span>
+ <span class="btn-label">{{ $t('btn.resend') }}</span>
</button>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td colspan="3">There are no invitations in the database.</td>
+ <td colspan="3">{{ $t('invitation.empty-list') }}</td>
</tr>
</tfoot>
</table>
<div class="text-center p-3" id="more-loader" v-if="hasMore">
- <button class="btn btn-secondary" @click="loadInvitations(true)">Load more</button>
+ <button class="btn btn-secondary" @click="loadInvitations(true)">{{ $t('nav.more') }}</button>
</div>
</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">Invite for a signup</h5>
- <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <h5 class="modal-title">{{ $t('invitation.create-title') }}</h5>
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form>
- <p>Enter an email address of the person you want to invite.</p>
+ <p>{{ $t('invitation.create-email') }}</p>
<div>
<input id="email" type="text" class="form-control" name="email">
</div>
- <div class="form-separator"><hr><span>or</span></div>
- <p>
- To send multiple invitations at once, provide a CSV (comma separated) file,
- or alternatively a plain-text file, containing one email address per line.
- </p>
+ <div class="form-separator"><hr><span>{{ $t('form.or') }}</span></div>
+ <p>{{ $t('invitation.create-csv') }}</p>
<div class="custom-file">
<input id="file" type="file" class="custom-file-input" name="csv" @change="fileChange">
- <label class="custom-file-label" for="file">Choose file...</label>
+ <label class="custom-file-label" for="file">{{ $t('btn.file') }}</label>
</div>
</form>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="inviteUser()">
- <svg-icon icon="paper-plane"></svg-icon> Send invite(s)
+ <svg-icon icon="paper-plane"></svg-icon> {{ $t('invitation.send') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEnvelopeOpenText, faPaperPlane, faRedo } from '@fortawesome/free-solid-svg-icons'
library.add(faEnvelopeOpenText, faPaperPlane, faRedo)
export default {
data() {
return {
invitations: [],
hasMore: false,
page: 1,
search: ''
}
},
mounted() {
this.$root.startLoading()
this.loadInvitations(null, () => this.$root.stopLoading())
},
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 = 'Choose file...'
+ 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') {
dialog.modal('hide')
this.$toast.success(response.data.message)
if (response.data.count) {
this.loadInvitations({ reset: true })
}
}
})
},
inviteUserDialog() {
let dialog = $('#invite-create')
let form = dialog.find('form')
form.get(0).reset()
this.fileChange({ target: form.find('#file')[0] }) // resets file input label
this.$root.clearFormValidation(form)
dialog.on('shown.bs.modal', () => {
dialog.find('input').get(0).focus()
}).modal()
},
loadInvitations(params, callback) {
let loader
let get = {}
if (params) {
if (params.reset) {
this.invitations = []
this.page = 0
}
get.page = params.page || (this.page + 1)
if (typeof params === 'object' && 'search' in params) {
get.search = params.search
this.currentSearch = params.search
} else {
get.search = this.currentSearch
}
loader = $(get.page > 1 ? '#more-loader' : '#invitations-list tfoot td')
} else {
this.currentSearch = null
}
this.$root.addLoader(loader)
axios.get('/api/v4/invitations', { params: get })
.then(response => {
this.$root.removeLoader(loader)
// Note: In Vue we can't just use .concat()
for (let i in response.data.list) {
this.$set(this.invitations, this.invitations.length, response.data.list[i])
}
this.hasMore = response.data.hasMore
this.page = response.data.page || 1
if (callback) {
callback()
}
})
.catch(error => {
this.$root.removeLoader(loader)
if (callback) {
callback()
}
})
},
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)
this.invitations.splice(index, 1)
this.$set(this.invitations, index, response.data.invitation)
}
})
},
searchInvitations() {
this.loadInvitations({ reset: true, search: this.search })
},
statusClass(invitation) {
if (invitation.isCompleted) {
return 'text-success'
}
if (invitation.isFailed) {
return 'text-danger'
}
if (invitation.isSent) {
return 'text-primary'
}
return ''
},
- statusText(invitation) {
+ statusLabel(invitation) {
if (invitation.isCompleted) {
- return 'User signed up'
+ return 'completed'
}
if (invitation.isFailed) {
- return 'Sending failed'
+ return 'failed'
}
if (invitation.isSent) {
- return 'Sent'
+ return 'sent'
}
- return 'Not sent yet'
+ return 'new'
}
}
}
</script>
diff --git a/src/resources/vue/Reseller/Stats.vue b/src/resources/vue/Reseller/Stats.vue
index 1f20ad3d..742d8a64 100644
--- a/src/resources/vue/Reseller/Stats.vue
+++ b/src/resources/vue/Reseller/Stats.vue
@@ -1,18 +1,17 @@
<template>
- <div id="stats-container" class="container">
- </div>
+ <div id="stats-container" class="container"></div>
</template>
<script>
import Stats from '../Admin/Stats'
export default {
mixins: [Stats],
data() {
return {
// charts: {},
chartTypes: ['users', 'users-all', 'discounts']
}
}
}
</script>
diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue
index 059efe44..973af221 100644
--- a/src/resources/vue/Signup.vue
+++ b/src/resources/vue/Signup.vue
@@ -1,305 +1,304 @@
<template>
<div class="container">
<div id="step0" v-if="!invitation">
<div class="plan-selector card-deck">
<div v-for="item in plans" :key="item.id" :class="'card bg-light plan-' + item.title">
<div class="card-header plan-header">
<div class="plan-ico text-center">
<svg-icon :icon="plan_icons[item.title]"></svg-icon>
</div>
</div>
<div class="card-body text-center ">
<button class="btn btn-primary" :data-title="item.title" @click="selectPlan(item.title)" v-html="item.button"></button>
<div class="plan-description text-left mt-3" v-html="item.description"></div>
</div>
</div>
</div>
</div>
<div class="card d-none" id="step1" v-if="!invitation">
<div class="card-body">
- <h4 class="card-title">Sign Up - Step 1/3</h4>
+ <h4 class="card-title">{{ $t('signup.title') }} - {{ $t('nav.step', { i: 1, n: 3 }) }}</h4>
<p class="card-text">
- Sign up to start your free month.
+ {{ $t('signup.step1') }}
</p>
<form @submit.prevent="submitStep1" data-validation-prefix="signup_">
<div class="form-group">
<div class="input-group">
- <input type="text" class="form-control" id="signup_first_name" placeholder="First Name" autofocus v-model="first_name">
- <input type="text" class="form-control rounded-right" id="signup_last_name" placeholder="Surname" v-model="last_name">
+ <input type="text" class="form-control" id="signup_first_name" :placeholder="$t('form.firstname')" autofocus v-model="first_name">
+ <input type="text" class="form-control rounded-right" id="signup_last_name" :placeholder="$t('form.surname')" v-model="last_name">
</div>
</div>
<div class="form-group">
- <label for="signup_email" class="sr-only">Existing Email Address</label>
- <input type="text" class="form-control" id="signup_email" placeholder="Existing Email Address" required v-model="email">
+ <label for="signup_email" class="sr-only">{{ $t('signup.email') }}</label>
+ <input type="text" class="form-control" id="signup_email" :placeholder="$t('signup.email')" required v-model="email">
</div>
- <button class="btn btn-secondary" type="button" @click="stepBack">Back</button>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Continue</button>
+ <button class="btn btn-secondary" type="button" @click="stepBack">{{ $t('btn.back') }}</button>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.continue') }}</button>
</form>
</div>
</div>
<div class="card d-none" id="step2" v-if="!invitation">
<div class="card-body">
- <h4 class="card-title">Sign Up - Step 2/3</h4>
+ <h4 class="card-title">{{ $t('signup.title') }} - {{ $t('nav.step', { i: 2, n: 3 }) }}</h4>
<p class="card-text">
- We sent out a confirmation code to your email address.
- Enter the code we sent you, or click the link in the message.
+ {{ $t('signup.step2') }}
</p>
<form @submit.prevent="submitStep2" data-validation-prefix="signup_">
<div class="form-group">
- <label for="signup_short_code" class="sr-only">Confirmation Code</label>
- <input type="text" class="form-control" id="signup_short_code" placeholder="Confirmation Code" required v-model="short_code">
+ <label for="signup_short_code" class="sr-only">{{ $t('form.code') }}</label>
+ <input type="text" class="form-control" id="signup_short_code" :placeholder="$t('form.code')" required v-model="short_code">
</div>
- <button class="btn btn-secondary" type="button" @click="stepBack">Back</button>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Continue</button>
+ <button class="btn btn-secondary" type="button" @click="stepBack">{{ $t('btn.back') }}</button>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.continue') }}</button>
<input type="hidden" id="signup_code" v-model="code" />
</form>
</div>
</div>
<div class="card d-none" id="step3">
<div class="card-body">
- <h4 v-if="!invitation" class="card-title">Sign Up - Step 3/3</h4>
+ <h4 v-if="!invitation" class="card-title">{{ $t('signup.title') }} - {{ $t('nav.step', { i: 3, n: 3 }) }}</h4>
<p class="card-text">
- Create your Kolab identity (you can choose additional addresses later).
+ {{ $t('signup.step3') }}
</p>
<form @submit.prevent="submitStep3" data-validation-prefix="signup_">
<div class="form-group" v-if="invitation">
<div class="input-group">
- <input type="text" class="form-control" id="signup_first_name" placeholder="First Name" autofocus v-model="first_name">
- <input type="text" class="form-control rounded-right" id="signup_last_name" placeholder="Surname" v-model="last_name">
+ <input type="text" class="form-control" id="signup_first_name" :placeholder="$t('form.firstname')" autofocus v-model="first_name">
+ <input type="text" class="form-control rounded-right" id="signup_last_name" :placeholder="$t('form.surname')" v-model="last_name">
</div>
</div>
<div class="form-group">
<label for="signup_login" class="sr-only"></label>
<div class="input-group">
- <input type="text" class="form-control" id="signup_login" required v-model="login" placeholder="Login">
+ <input type="text" class="form-control" id="signup_login" required v-model="login" :placeholder="$t('signup.login')">
<span class="input-group-append input-group-prepend">
<span class="input-group-text">@</span>
</span>
- <input v-if="is_domain" type="text" class="form-control rounded-right" id="signup_domain" required v-model="domain" placeholder="Domain">
+ <input v-if="is_domain" type="text" class="form-control rounded-right" id="signup_domain" required v-model="domain" :placeholder="$t('form.domain')">
<select v-else class="custom-select rounded-right" id="signup_domain" required v-model="domain">
<option v-for="domain in domains" :key="domain" :value="domain">{{ domain }}</option>
</select>
</div>
</div>
<div class="form-group">
- <label for="signup_password" class="sr-only">Password</label>
- <input type="password" class="form-control" id="signup_password" placeholder="Password" required v-model="password">
+ <label for="signup_password" class="sr-only">{{ $t('form.password') }}</label>
+ <input type="password" class="form-control" id="signup_password" :placeholder="$t('form.password')" required v-model="password">
</div>
<div class="form-group">
- <label for="signup_confirm" class="sr-only">Confirm Password</label>
- <input type="password" class="form-control" id="signup_confirm" placeholder="Confirm Password" required v-model="password_confirmation">
+ <label for="signup_confirm" class="sr-only">{{ $t('form.password-confirm') }}</label>
+ <input type="password" class="form-control" id="signup_confirm" :placeholder="$t('form.password-confirm')" required v-model="password_confirmation">
</div>
<div class="form-group pt-2 pb-2">
- <label for="signup_voucher" class="sr-only">Voucher code</label>
- <input type="text" class="form-control" id="signup_voucher" placeholder="Voucher code" v-model="voucher">
+ <label for="signup_voucher" class="sr-only">{{ $t('signup.voucher') }}</label>
+ <input type="text" class="form-control" id="signup_voucher" :placeholder="$t('signup.voucher')" v-model="voucher">
</div>
- <button v-if="!invitation" class="btn btn-secondary" type="button" @click="stepBack">Back</button>
+ <button v-if="!invitation" class="btn btn-secondary" type="button" @click="stepBack">{{ $t('btn.back') }}</button>
<button class="btn btn-primary" type="submit">
- <svg-icon icon="check"></svg-icon> <span v-if="invitation">Sign Up</span><span v-else>Submit</span>
+ <svg-icon icon="check"></svg-icon> <span v-if="invitation">{{ $t('btn.signup') }}</span><span v-else>{{ $t('btn.submit') }}</span>
</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
email: '',
first_name: '',
last_name: '',
code: '',
short_code: '',
login: '',
password: '',
password_confirmation: '',
domain: '',
domains: [],
invitation: null,
is_domain: false,
plan: null,
plan_icons: {
individual: 'user',
group: 'users'
},
plans: [],
voucher: ''
}
},
mounted() {
let param = this.$route.params.param;
if (this.$route.name == 'signup-invite') {
this.$root.startLoading()
axios.get('/api/auth/signup/invitations/' + param)
.then(response => {
this.invitation = response.data
this.login = response.data.login
this.voucher = response.data.voucher
this.first_name = response.data.first_name
this.last_name = response.data.last_name
this.plan = response.data.plan
this.is_domain = response.data.is_domain
this.setDomain(response.data)
this.$root.stopLoading()
this.displayForm(3, true)
})
.catch(error => {
this.$root.errorHandler(error)
})
} else if (param) {
if (this.$route.path.indexOf('/signup/voucher/') === 0) {
// Voucher (discount) code
this.voucher = param
this.displayForm(0)
} else if (/^([A-Z0-9]+)-([a-zA-Z0-9]+)$/.test(param)) {
// Verification code provided, auto-submit Step 2
this.short_code = RegExp.$1
this.code = RegExp.$2
this.submitStep2(true)
} else if (/^([a-zA-Z_]+)$/.test(param)) {
// Plan title provided, save it and display Step 1
this.plan = param
this.displayForm(1, true)
} else {
this.$root.errorPage(404)
}
} else {
this.displayForm(0)
}
},
methods: {
selectPlan(plan) {
this.$router.push({path: '/signup/' + plan})
this.plan = plan
this.displayForm(1, true)
},
// Composes plan selection page
step0() {
if (!this.plans.length) {
this.$root.startLoading()
axios.get('/api/auth/signup/plans').then(response => {
this.$root.stopLoading()
this.plans = response.data.plans
})
.catch(error => {
this.$root.errorHandler(error)
})
}
},
// Submits data to the API, validates and gets verification code
submitStep1() {
this.$root.clearFormValidation($('#step1 form'))
axios.post('/api/auth/signup/init', {
email: this.email,
last_name: this.last_name,
first_name: this.first_name,
plan: this.plan,
voucher: this.voucher
}).then(response => {
this.displayForm(2, true)
this.code = response.data.code
})
},
// Submits the code to the API for verification
submitStep2(bylink) {
if (bylink === true) {
this.displayForm(2, false)
}
this.$root.clearFormValidation($('#step2 form'))
axios.post('/api/auth/signup/verify', {
code: this.code,
short_code: this.short_code
}).then(response => {
this.displayForm(3, true)
// Reset user name/email/plan, we don't have them if user used a verification link
this.first_name = response.data.first_name
this.last_name = response.data.last_name
this.email = response.data.email
this.is_domain = response.data.is_domain
this.voucher = response.data.voucher
// Fill the domain selector with available domains
if (!this.is_domain) {
this.setDomain(response.data)
}
}).catch(error => {
if (bylink === true) {
// FIXME: display step 1, user can do nothing about it anyway
// Maybe we should display 404 error page?
this.displayForm(1, true)
}
})
},
// Submits the data to the API to create the user account
submitStep3() {
this.$root.clearFormValidation($('#step3 form'))
let post = {
login: this.login,
domain: this.domain,
password: this.password,
password_confirmation: this.password_confirmation,
voucher: this.voucher
}
if (this.invitation) {
post.invitation = this.invitation.id
post.plan = this.plan
post.first_name = this.first_name
post.last_name = this.last_name
} else {
post.code = this.code
post.short_code = this.short_code
}
axios.post('/api/auth/signup', post).then(response => {
// auto-login and goto dashboard
this.$root.loginUser(response.data)
})
},
// Moves the user a step back in registration form
stepBack(e) {
var card = $(e.target).closest('.card')
card.prev().removeClass('d-none').find('input').first().focus()
card.addClass('d-none').find('form')[0].reset()
if (card.attr('id') == 'step1') {
this.step0()
this.$router.replace({path: '/signup'})
}
},
displayForm(step, focus) {
[0, 1, 2, 3].filter(value => value != step).forEach(value => {
$('#step' + value).addClass('d-none')
})
if (!step) {
return this.step0()
}
$('#step' + step).removeClass('d-none')
if (focus) {
$('#step' + step).find('input').first().focus()
}
},
setDomain(response) {
if (response.domains) {
this.domains = response.domains
}
this.domain = response.domain || window.config['app.domain']
}
}
}
</script>
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
index d6052bf6..c25fd63f 100644
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -1,445 +1,443 @@
<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'">User account
+ <div class="card-title" v-if="user_id !== 'new'">{{ $t('user.title') }}
<button
class="btn btn-outline-danger button-delete float-right"
@click="showDeleteConfirmation()" type="button"
>
- <svg-icon icon="trash-alt"></svg-icon> Delete user
+ <svg-icon icon="trash-alt"></svg-icon> {{ $t('user.delete') }}
</button>
</div>
- <div class="card-title" v-if="user_id === 'new'">New user account</div>
+ <div class="card-title" v-if="user_id === 'new'">{{ $t('user.new') }}</div>
<div class="card-text">
<form @submit.prevent="submit">
<div v-if="user_id !== 'new'" class="form-group row plaintext">
- <label for="status" class="col-sm-4 col-form-label">Status</label>
+ <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.userStatusClass(user) + ' form-control-plaintext'" id="status">{{ $root.userStatusText(user) }}</span>
</div>
</div>
<div class="form-group row">
- <label for="first_name" class="col-sm-4 col-form-label">First name</label>
+ <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="form-group row">
- <label for="last_name" class="col-sm-4 col-form-label">Last name</label>
+ <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="form-group row">
- <label for="organization" class="col-sm-4 col-form-label">Organization</label>
+ <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="form-group row">
- <label for="email" class="col-sm-4 col-form-label">Email</label>
+ <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="form-group row">
- <label for="aliases-input" class="col-sm-4 col-form-label">Email aliases</label>
+ <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="form-group row">
- <label for="password" class="col-sm-4 col-form-label">Password</label>
+ <label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
<div class="col-sm-8">
<input type="password" class="form-control" id="password" v-model="user.password" :required="user_id === 'new'">
</div>
</div>
<div class="form-group row">
- <label for="password_confirmaton" class="col-sm-4 col-form-label">Confirm password</label>
+ <label for="password_confirmaton" class="col-sm-4 col-form-label">{{ $t('form.password-confirm') }}</label>
<div class="col-sm-8">
<input type="password" class="form-control" id="password_confirmation" v-model="user.password_confirmation" :required="user_id === 'new'">
</div>
</div>
<div v-if="user_id === 'new'" id="user-packages" class="form-group row">
<label class="col-sm-4 col-form-label">Package</label>
<div class="col-sm-8">
<table class="table table-sm form-list">
<thead class="thead-light sr-only">
<tr>
<th scope="col"></th>
- <th scope="col">Package</th>
- <th scope="col">Price</th>
+ <th scope="col">{{ $t('user.package') }}</th>
+ <th scope="col">{{ $t('user.price') }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="pkg in packages" :id="'p' + pkg.id" :key="pkg.id">
<td class="selection">
<input type="checkbox" @click="selectPackage"
:value="pkg.id"
:checked="pkg.id == package_id"
:id="'pkg-input-' + pkg.id"
>
</td>
<td class="name">
<label :for="'pkg-input-' + pkg.id">{{ pkg.name }}</label>
</td>
<td class="price text-nowrap">
{{ $root.priceLabel(pkg.cost, discount) }}
</td>
<td class="buttons">
<button v-if="pkg.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="pkg.description">
<svg-icon icon="info-circle"></svg-icon>
- <span class="sr-only">More information</span>
+ <span class="sr-only">{{ $t('btn.moreinfo') }}</span>
</button>
</td>
</tr>
</tbody>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
- &sup1; applied discount: {{ discount }}% - {{ discount_description }}
+ &sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
</div>
</div>
<div v-if="user_id !== 'new'" id="user-skus" class="form-group row">
- <label class="col-sm-4 col-form-label">Subscriptions</label>
+ <label class="col-sm-4 col-form-label">{{ $t('user.subscriptions') }}</label>
<div class="col-sm-8">
<table class="table table-sm form-list">
<thead class="thead-light sr-only">
<tr>
<th scope="col"></th>
- <th scope="col">Subscription</th>
- <th scope="col">Price</th>
+ <th scope="col">{{ $t('user.subscription') }}</th>
+ <th scope="col">{{ $t('user.price') }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="sku in skus" :id="'s' + sku.id" :key="sku.id">
<td class="selection">
<input type="checkbox" @input="onInputSku"
:value="sku.id"
:disabled="sku.readonly"
:checked="sku.enabled"
:id="'sku-input-' + sku.title"
>
</td>
<td class="name">
<label :for="'sku-input-' + sku.title">{{ sku.name }}</label>
<div v-if="sku.range" class="range-input">
<label class="text-nowrap">{{ sku.range.min }} {{ sku.range.unit }}</label>
<input
type="range" class="custom-range" @input="rangeUpdate"
:value="sku.value || sku.range.min"
:min="sku.range.min"
:max="sku.range.max"
>
</div>
</td>
<td class="price text-nowrap">
{{ $root.priceLabel(sku.cost, discount) }}
</td>
<td class="buttons">
<button v-if="sku.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="sku.description">
<svg-icon icon="info-circle"></svg-icon>
- <span class="sr-only">More information</span>
+ <span class="sr-only">{{ $t('btn.moreinfo') }}</span>
</button>
</td>
</tr>
</tbody>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
- &sup1; applied discount: {{ discount }}% - {{ discount_description }}
+ &sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
</div>
</div>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
</form>
</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"></h5>
- <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
- <p>Do you really want to delete this user permanently?
- This will delete all account data and withdraw the permission to access the email account.
- Please note that this action cannot be undone.</p>
+ <p>{{ $t('user.delete-text') }}</p>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-danger modal-action" @click="deleteUser()">
- <svg-icon icon="trash-alt"></svg-icon> Delete
+ <svg-icon icon="trash-alt"></svg-icon> {{ $t('btn.delete') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
ListInput,
StatusComponent
},
data() {
return {
discount: 0,
discount_description: '',
user_id: null,
user: { aliases: [] },
packages: [],
package_id: null,
skus: [],
status: {}
}
},
created() {
this.user_id = this.$route.params.user
let wallet = this.$store.state.authInfo.accounts[0]
if (!wallet) {
wallet = this.$store.state.authInfo.wallets[0]
}
if (wallet && wallet.discount) {
this.discount = wallet.discount
this.discount_description = wallet.discount_description
}
this.$root.startLoading()
if (this.user_id === 'new') {
// do nothing (for now)
axios.get('/api/v4/packages')
.then(response => {
this.$root.stopLoading()
this.packages = response.data.filter(pkg => !pkg.isDomain)
this.package_id = this.packages[0].id
})
.catch(this.$root.errorHandler)
}
else {
axios.get('/api/v4/users/' + this.user_id)
.then(response => {
this.$root.stopLoading()
this.user = response.data
this.user.first_name = response.data.settings.first_name
this.user.last_name = response.data.settings.last_name
this.user.organization = response.data.settings.organization
this.discount = this.user.wallet.discount
this.discount_description = this.user.wallet.discount_description
this.status = response.data.statusInfo
axios.get('/api/v4/users/' + this.user_id + '/skus?type=user')
.then(response => {
// "merge" SKUs with user entitlement-SKUs
this.skus = response.data
.map(sku => {
const userSku = this.user.skus[sku.id]
if (userSku) {
sku.enabled = true
sku.skuCost = sku.cost
sku.cost = userSku.costs.reduce((sum, current) => sum + current)
sku.value = userSku.count
sku.costs = userSku.costs
} else if (!sku.readonly) {
sku.enabled = false
}
return sku
})
// Update all range inputs (and price)
this.$nextTick(() => {
$('#user-skus input[type=range]').each((idx, elem) => { this.rangeUpdate(elem) })
})
})
.catch(this.$root.errorHandler)
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#first_name').focus()
},
methods: {
submit() {
this.$root.clearFormValidation($('#user-info form'))
let method = 'post'
let location = '/api/v4/users'
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
})
this.user.skus = skus
} else {
this.user.package = this.package_id
}
axios[method](location, this.user)
.then(response => {
if (response.data.statusInfo) {
this.$store.state.authInfo.statusInfo = response.data.statusInfo
}
this.$toast.success(response.data.message)
this.$router.push({ name: 'users' })
})
},
onInputSku(e) {
let input = e.target
let sku = this.findSku(input.value)
let required = []
// We use 'readonly', not 'disabled', because we might want to handle
// input events. For example to display an error when someone clicks
// the locked input
if (input.readOnly) {
input.checked = !input.checked
// TODO: Display an alert explaining why it's locked
return
}
// TODO: Following code might not work if we change definition of forbidden/required
// or we just need more sophisticated SKU dependency rules
if (input.checked) {
// Check if a required SKU is selected, alert the user if not
(sku.required || []).forEach(title => {
this.skus.forEach(item => {
let checkbox
if (item.handler == title && (checkbox = $('#s' + item.id).find('input[type=checkbox]')[0])) {
if (!checkbox.checked) {
required.push(item.name)
}
}
})
})
if (required.length) {
input.checked = false
- return alert(sku.name + ' requires ' + required.join(', ') + '.')
+ return alert(this.$t('user.skureq', { sku: sku.name, list: required.join(', ') }))
}
} else {
// Uncheck all dependent SKUs, e.g. when unchecking Groupware we also uncheck Activesync
// TODO: Should we display an alert instead?
this.skus.forEach(item => {
if (item.required && item.required.indexOf(sku.handler) > -1) {
$('#s' + item.id).find('input[type=checkbox]').prop('checked', false)
}
})
}
// Uncheck+lock/unlock conflicting SKUs
(sku.forbidden || []).forEach(title => {
this.skus.forEach(item => {
let checkbox
if (item.handler == title && (checkbox = $('#s' + item.id).find('input[type=checkbox]')[0])) {
if (input.checked) {
checkbox.checked = false
checkbox.readOnly = true
} else {
checkbox.readOnly = false
}
}
})
})
},
selectPackage(e) {
// Make sure there always is only one package selected
$('#user-packages input').prop('checked', false)
this.package_id = $(e.target).prop('checked', false).val()
},
rangeUpdate(e) {
let input = $(e.target || e)
let value = input.val()
let record = input.parents('tr').first()
let sku_id = record.find('input[type=checkbox]').val()
let sku = this.findSku(sku_id)
let existing = sku.costs ? sku.costs.length : 0
let cost
// Calculate cost, considering both existing entitlement cost and sku cost
if (existing) {
cost = sku.costs
.sort((a, b) => a - b) // sort by cost ascending (free units first)
.slice(0, value)
.reduce((sum, current) => sum + current)
if (value > existing) {
cost += sku.skuCost * (value - existing)
}
} else {
cost = sku.cost * (value - sku.units_free)
}
// Update the label
input.prev().text(value + ' ' + sku.range.unit)
// Update the price
record.find('.price').text(this.$root.priceLabel(cost, this.discount))
},
findSku(id) {
for (let i = 0; i < this.skus.length; i++) {
if (this.skus[i].id == id) {
return this.skus[i];
}
}
},
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() {
// Deleting self, redirect to /profile/delete page
if (this.user_id == this.$store.state.authInfo.id) {
this.$router.push({ name: 'profile-delete' })
} else {
// Display the warning
let dialog = $('#delete-warning')
- dialog.find('.modal-title').text('Delete ' + this.user.email)
+ dialog.find('.modal-title').text(this.$t('user.delete-email', { email: this.user.email }))
dialog.on('shown.bs.modal', () => {
dialog.find('button.modal-cancel').focus()
}).modal()
}
}
}
}
</script>
diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue
index 9e77c140..1e34f4cd 100644
--- a/src/resources/vue/User/List.vue
+++ b/src/resources/vue/User/List.vue
@@ -1,57 +1,57 @@
<template>
<div class="container">
<div class="card" id="user-list">
<div class="card-body">
<div class="card-title">
- User Accounts
+ {{ $t('user.list-title') }}
<router-link class="btn btn-success float-right create-user" :to="{ path: 'user/new' }" tag="button">
- <svg-icon icon="user"></svg-icon> Create user
+ <svg-icon icon="user"></svg-icon> {{ $t('user.create') }}
</router-link>
</div>
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
- <th scope="col">Primary Email</th>
+ <th scope="col">{{ $t('form.primary-email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :id="'user' + user.id" :key="user.id" @click="$root.clickRecord">
<td>
<svg-icon icon="user" :class="$root.userStatusClass(user)" :title="$root.userStatusText(user)"></svg-icon>
<router-link :to="{ path: 'user/' + user.id }">{{ user.email }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td>There are no users in this account.</td>
+ <td>{{ $t('user.users-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
users: [],
current_user: null
}
},
created() {
this.$root.startLoading()
axios.get('/api/v4/users')
.then(response => {
this.$root.stopLoading()
this.users = response.data
})
.catch(this.$root.errorHandler)
}
}
</script>
diff --git a/src/resources/vue/User/Profile.vue b/src/resources/vue/User/Profile.vue
index 179b37c5..dad15758 100644
--- a/src/resources/vue/User/Profile.vue
+++ b/src/resources/vue/User/Profile.vue
@@ -1,121 +1,121 @@
<template>
<div class="container">
<div class="card" id="user-profile">
<div class="card-body">
<div class="card-title">
- Your profile
+ {{ $t('user.profile-title') }}
<router-link
v-if="$root.isController(wallet_id)"
class="btn btn-outline-danger button-delete float-right"
to="/profile/delete" tag="button"
>
- <svg-icon icon="trash-alt"></svg-icon> Delete account
+ <svg-icon icon="trash-alt"></svg-icon> {{ $t('user.profile-delete') }}
</router-link>
</div>
<div class="card-text">
<form @submit.prevent="submit">
<div class="form-group row plaintext">
- <label class="col-sm-4 col-form-label">Customer No.</label>
+ <label class="col-sm-4 col-form-label">{{ $t('user.custno') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="userid">{{ user_id }}</span>
</div>
</div>
<div class="form-group row">
- <label for="first_name" class="col-sm-4 col-form-label">First name</label>
+ <label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="first_name" v-model="profile.first_name">
</div>
</div>
<div class="form-group row">
- <label for="last_name" class="col-sm-4 col-form-label">Last name</label>
+ <label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="last_name" v-model="profile.last_name">
</div>
</div>
<div class="form-group row">
- <label for="organization" class="col-sm-4 col-form-label">Organization</label>
+ <label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="organization" v-model="profile.organization">
</div>
</div>
<div class="form-group row">
- <label for="phone" class="col-sm-4 col-form-label">Phone</label>
+ <label for="phone" class="col-sm-4 col-form-label">{{ $t('form.phone') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="phone" v-model="profile.phone">
</div>
</div>
<div class="form-group row">
- <label for="external_email" class="col-sm-4 col-form-label">External email</label>
+ <label for="external_email" class="col-sm-4 col-form-label">{{ $t('user.ext-email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="external_email" v-model="profile.external_email">
</div>
</div>
<div class="form-group row">
- <label for="billing_address" class="col-sm-4 col-form-label">Address</label>
+ <label for="billing_address" class="col-sm-4 col-form-label">{{ $t('user.address') }}</label>
<div class="col-sm-8">
<textarea class="form-control" id="billing_address" rows="3" v-model="profile.billing_address"></textarea>
</div>
</div>
<div class="form-group row">
- <label for="country" class="col-sm-4 col-form-label">Country</label>
+ <label for="country" class="col-sm-4 col-form-label">{{ $t('user.country') }}</label>
<div class="col-sm-8">
<select class="form-control custom-select" id="country" v-model="profile.country">
<option value="">-</option>
<option v-for="(item, code) in countries" :value="code" :key="code">{{ item[1] }}</option>
</select>
</div>
</div>
<div class="form-group row">
- <label for="password" class="col-sm-4 col-form-label">Password</label>
+ <label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
<div class="col-sm-8">
<input type="password" class="form-control" id="password" v-model="profile.password">
</div>
</div>
<div class="form-group row">
- <label for="password_confirmaton" class="col-sm-4 col-form-label">Confirm password</label>
+ <label for="password_confirmaton" class="col-sm-4 col-form-label">{{ $t('form.password-confirm') }}</label>
<div class="col-sm-8">
<input type="password" class="form-control" id="password_confirmation" v-model="profile.password_confirmation">
</div>
</div>
- <button class="btn btn-primary button-submit" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ <button class="btn btn-primary button-submit" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
profile: {},
user_id: null,
wallet_id: null,
countries: window.config.countries
}
},
created() {
this.wallet_id = this.$store.state.authInfo.wallet.id
this.profile = this.$store.state.authInfo.settings
this.user_id = this.$store.state.authInfo.id
},
mounted() {
$('#first_name').focus()
},
methods: {
submit() {
this.$root.clearFormValidation($('#user-profile form'))
axios.put('/api/v4/users/' + this.user_id, this.profile)
.then(response => {
delete this.profile.password
delete this.profile.password_confirm
this.$toast.success(response.data.message)
this.$router.push({ name: 'dashboard' })
})
}
}
}
</script>
diff --git a/src/resources/vue/User/ProfileDelete.vue b/src/resources/vue/User/ProfileDelete.vue
index a589ec1e..2456b2c6 100644
--- a/src/resources/vue/User/ProfileDelete.vue
+++ b/src/resources/vue/User/ProfileDelete.vue
@@ -1,56 +1,48 @@
<template>
<div class="container">
<div class="card" id="user-delete">
<div class="card-body">
<div class="card-title">Delete this account?</div>
<div class="card-text">
- <p>This will delete the account as well as all domains, users and aliases associated with this account.
- <strong>This operation is irreversible</strong>.</p>
- <p>As you will not be able to recover anything after this point, please make sure
- that you have migrated all data before proceeding.</p>
- <p v-if="supportEmail">
- As we always strive to improve, we would like to ask for 2 minutes of your time.
- The best tool for improvement is feedback from users, and we would like to ask
- for a few words about your reasons for leaving our service. Please send your feedback
- to <a :href="'mailto:' + supportEmail">{{ supportEmail }}</a>.
- </p>
- <p>Also feel free to contact {{ $root.appName }} Support with any questions
- or concerns that you may have in this context.</p>
- <button class="btn btn-secondary button-cancel" @click="$router.go(-1)">Cancel</button>
+ <p>{{ $t('user.profile-delete-text1') }} <strong>{{ $t('user.profile-delete-warning') }}</strong>.</p>
+ <p>{{ $t('user.profile-delete-text2') }}</p>
+ <p v-if="supportEmail" v-html="$t('user.profile-delete-support', { href: 'mailto:' + supportEmail, email: supportEmail })"></p>
+ <p>{{ $t('user.profile-delete-contact', { app: $root.appName }) }}</p>
+ <button class="btn btn-secondary button-cancel" @click="$router.go(-1)">{{ $t('btn.cancel') }}</button>
<button class="btn btn-danger button-delete" @click="deleteProfile">
- <svg-icon icon="trash-alt"></svg-icon> Delete account
+ <svg-icon icon="trash-alt"></svg-icon> {{ $t('user.profile-delete') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
supportEmail: window.config['app.support_email']
}
},
created() {
if (!this.$root.isController(this.$store.state.authInfo.wallet.id)) {
this.$root.errorPage(403)
}
},
mounted() {
$('button.btn-secondary').focus()
},
methods: {
deleteProfile() {
axios.delete('/api/v4/users/' + this.$store.state.authInfo.id)
.then(response => {
if (response.data.status == 'success') {
this.$root.logoutUser()
this.$toast.success(response.data.message)
}
})
}
}
}
</script>
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
index b3a16860..025f461f 100644
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -1,473 +1,461 @@
<template>
<div class="container" dusk="wallet-component">
<div v-if="wallet.id" id="wallet" class="card">
<div class="card-body">
- <div class="card-title">Account balance <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'">{{ $root.price(wallet.balance, wallet.currency) }}</span></div>
+ <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">
- You have payments that are still in progress. See the "Pending Payments" tab below.
+ {{ $t('wallet.pending-payments-warning') }}
</div>
<p>
- <button type="button" class="btn btn-primary" @click="paymentMethodForm('manual')">Add credit</button>
+ <button type="button" class="btn btn-primary" @click="paymentMethodForm('manual')">{{ $t('wallet.add-credit') }}</button>
</p>
<div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending">
<template v-if="mandate.id && !mandate.isValid">
<div class="alert alert-danger">
- The setup of automatic payments failed. Restart the process to enable automatic top-ups.
+ {{ $t('wallet.auto-payment-failed') }}
</div>
- <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
+ <button type="button" class="btn btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</button>
</template>
- <button type="button" class="btn btn-primary" @click="paymentMethodForm('auto')">Set up auto-payment</button>
+ <button type="button" class="btn btn-primary" @click="paymentMethodForm('auto')">{{ $t('wallet.auto-payment-setup') }}</button>
</div>
<div id="mandate-info" v-else>
<div v-if="mandate.isDisabled" class="disabled-mandate alert alert-danger">
- The configured auto-payment has been disabled. Top up your wallet or
- raise the auto-payment amount.
+ {{ $t('wallet.auto-payment-disabled') }}
</div>
<template v-else>
- <p>
- Auto-payment is <b>set</b> to fill up your account by <b>{{ mandate.amount }} CHF</b>
- every time your account balance gets under <b>{{ mandate.balance }} CHF</b>.
- </p>
- <p>
- Method of payment: {{ mandate.method }}
- </p>
+ <p v-html="$t('wallet.auto-payment-info', { amount: mandate.amount, balance: mandate.balance })"></p>
+ <p>{{ $t('wallet.payment-method', { method: mandate.method }) }}</p>
</template>
<div v-if="mandate.isPending" class="alert alert-warning">
- The setup of the automatic payment is still in progress.
+ {{ $t('wallet.auto-payment-inprogress') }}
</div>
<p>
- <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
- <button type="button" class="btn btn-primary" @click="autoPaymentChange">Change auto-payment</button>
+ <button type="button" class="btn btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</button>
+ <button type="button" class="btn btn-primary" @click="autoPaymentChange">{{ $t('wallet.auto-payment-change') }}</button>
</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">
- Receipts
+ {{ $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">
- History
+ {{ $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">
- Pending Payments
+ {{ $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">
- Here you can download receipts (in PDF format) for payments in specified period.
- Select the period and press the Download button.
+ {{ $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>
<div class="input-group-append">
<button type="button" class="btn btn-secondary" @click="receiptDownload">
- <svg-icon icon="download"></svg-icon> Download
+ <svg-icon icon="download"></svg-icon> {{ $t('btn.download') }}
</button>
</div>
</div>
<p v-if="!receipts.length">
- There are no receipts for payments in this account. Please, note that you can download
- receipts after the month ends.
+ {{ $t('wallet.receipts-none') }}
</p>
</div>
</div>
</div>
<div class="tab-pane" id="wallet-history" role="tabpanel" aria-labelledby="tab-history">
<div class="card-body">
<transaction-log v-if="walletId && loadTransactions" class="card-text" :wallet-id="walletId"></transaction-log>
</div>
</div>
<div class="tab-pane" id="wallet-payments" role="tabpanel" aria-labelledby="tab-payments">
<div class="card-body">
<payment-log v-if="walletId && loadPayments" class="card-text" :wallet-id="walletId"></payment-log>
</div>
</div>
</div>
<div id="payment-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ paymentDialogTitle }}</h5>
- <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div id="payment-method" v-if="paymentForm == 'method'">
<form data-validation-prefix="mandate_">
<div id="payment-method-selection">
<a :id="method.id" v-for="method in paymentMethods" :key="method.id" @click="selectPaymentMethod(method)" href="#" class="card link-profile">
<svg-icon v-if="method.icon" :icon="[method.icon.prefix, method.icon.name]" />
<img v-if="method.image" :src="method.image" />
<span class="name">{{ method.name }}</span>
</a>
</div>
</form>
</div>
<div id="manual-payment" v-if="paymentForm == 'manual'">
<p v-if="wallet.currency != selectedPaymentMethod.currency">
- Here is how it works: You specify the amount by which you want to to up your wallet in {{ wallet.currency }}.
- We will then convert this to {{ selectedPaymentMethod.currency }}, and on the next page you will be provided with the bank-details
- to transfer the amount in {{ selectedPaymentMethod.currency }}.
+ {{ $t('wallet.currency-conv', { wc: wallet.currency, pc: selectedPaymentMethod.currency }) }}
</p>
<p v-if="selectedPaymentMethod.id == 'banktransfer'">
- Please note that a bank transfer can take several days to complete.
+ {{ $t('wallet.banktransfer-hint') }}
+ </p>
+ <p>
+ {{ $t('wallet.payment-amount-hint') }}
</p>
- <p>Choose the amount by which you want to top up your wallet.</p>
<form id="payment-form" @submit.prevent="payment">
<div class="form-group">
<div class="input-group">
<input type="text" class="form-control" id="amount" v-model="amount" required>
<span class="input-group-append">
<span class="input-group-text">{{ wallet.currency }}</span>
</span>
</div>
</div>
<div v-if="wallet.currency != selectedPaymentMethod.currency && !isNaN(amount)" class="alert alert-warning">
- You will be charged for {{ $root.price(amount * selectedPaymentMethod.exchangeRate * 100, selectedPaymentMethod.currency) }}
+ {{ $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>
- Here is how it works: Every time your account runs low,
- we will charge your preferred payment method for an amount you choose.
- You can cancel or change the auto-payment option at any time.
+ {{ $t('wallet.auto-payment-hint') }}
</p>
<div class="form-group row">
- <label for="mandate_amount" class="col-sm-6 col-form-label">Fill up by</label>
+ <label for="mandate_amount" class="col-sm-6 col-form-label">{{ $t('wallet.fill-up') }}</label>
<div class="input-group col-sm-6">
<input type="text" class="form-control" id="mandate_amount" v-model="mandate.amount" required>
<span class="input-group-append">
<span class="input-group-text">{{ wallet.currency }}</span>
</span>
</div>
</div>
<div class="form-group row">
- <label for="mandate_balance" class="col-sm-6 col-form-label">when account balance is below</label>
+ <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-append">
<span class="input-group-text">{{ wallet.currency }}</span>
</span>
</div>
</div>
</div>
<p v-if="!mandate.isValid">
- Next, you will be redirected to the checkout page, where you can provide
- your credit card details.
+ {{ $t('wallet.auto-payment-next') }}
</p>
<div v-if="mandate.isValid && mandate.isDisabled" class="disabled-mandate alert alert-danger">
- The auto-payment is disabled. Immediately after you submit new settings we'll
- enable it and attempt to top up your wallet.
+ {{ $t('wallet.auto-payment-disabled-next') }}
</div>
</form>
</div>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action"
v-if="paymentForm == 'auto' && (mandate.isValid || mandate.isPending)"
@click="autoPayment"
>
- <svg-icon icon="check"></svg-icon> Submit
+ <svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
<button type="button" class="btn btn-primary modal-action"
v-if="paymentForm == 'auto' && !mandate.isValid && !mandate.isPending"
@click="autoPayment"
>
- <svg-icon icon="check"></svg-icon> Continue
+ <svg-icon icon="check"></svg-icon> {{ $t('btn.continue') }}
</button>
<button type="button" class="btn btn-primary modal-action"
v-if="paymentForm == 'manual'"
@click="payment"
>
- <svg-icon icon="check"></svg-icon> Continue
+ <svg-icon icon="check"></svg-icon> {{ $t('btn.continue') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import TransactionLog from './Widgets/TransactionLog'
import PaymentLog from './Widgets/PaymentLog'
export default {
components: {
TransactionLog,
PaymentLog
},
data() {
return {
amount: '',
mandate: { amount: 10, balance: 0, method: null },
paymentDialogTitle: null,
paymentForm: null,
nextForm: null,
receipts: [],
stripe: null,
loadTransactions: false,
loadPayments: false,
showPendingPayments: false,
wallet: {},
walletId: null,
paymentMethods: [],
selectedPaymentMethod: null
}
},
mounted() {
$('#wallet button').focus()
this.walletId = this.$store.state.authInfo.wallets[0].id
this.$root.startLoading()
axios.get('/api/v4/wallets/' + this.walletId)
.then(response => {
this.$root.stopLoading()
this.wallet = response.data
const receiptsTab = $('#wallet-receipts')
this.$root.addLoader(receiptsTab)
axios.get('/api/v4/wallets/' + this.walletId + '/receipts')
.then(response => {
this.$root.removeLoader(receiptsTab)
this.receipts = response.data.list
})
.catch(error => {
this.$root.removeLoader(receiptsTab)
})
if (this.wallet.provider == 'stripe') {
this.stripeInit()
}
})
.catch(this.$root.errorHandler)
this.loadMandate()
axios.get('/api/v4/payments/has-pending')
.then(response => {
this.showPendingPayments = response.data.hasPending
})
},
updated() {
$(this.$el).find('ul.nav-tabs a').on('click', e => {
e.preventDefault()
$(e.target).tab('show')
if ($(e.target).is('#tab-history')) {
this.loadTransactions = true
}
if ($(e.target).is('#tab-payments')) {
this.loadPayments = true
}
})
},
methods: {
loadMandate() {
const mandate_form = $('#mandate-form')
this.$root.removeLoader(mandate_form)
if (!this.mandate.id || this.mandate.isPending) {
this.$root.addLoader(mandate_form)
axios.get('/api/v4/payments/mandate')
.then(response => {
this.$root.removeLoader(mandate_form)
this.mandate = response.data
})
.catch(error => {
this.$root.removeLoader(mandate_form)
})
}
},
selectPaymentMethod(method) {
this.formLock = false
this.selectedPaymentMethod = method
this.paymentForm = this.nextForm
this.formLock = false
setTimeout(() => {
this.dialog.find('#mandate_amount').focus()
this.dialog.find('#amount').focus()
}, 10)
},
payment() {
if (this.formLock) {
return
}
// Lock the form to prevent from double submission
this.formLock = true
let onFinish = () => { this.formLock = false }
this.$root.clearFormValidation($('#payment-form'))
axios.post('/api/v4/payments', {amount: this.amount, methodId: this.selectedPaymentMethod.id, currency: this.selectedPaymentMethod.currency}, { onFinish })
.then(response => {
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
} else {
this.stripeCheckout(response.data)
}
})
},
autoPayment() {
if (this.formLock) {
return
}
// Lock the form to prevent from double submission
this.formLock = true
let onFinish = () => { this.formLock = false }
const method = this.mandate.id && (this.mandate.isValid || this.mandate.isPending) ? 'put' : 'post'
let post = {
amount: this.mandate.amount,
balance: this.mandate.balance,
}
// Modifications can't change the method of payment
if (this.selectedPaymentMethod) {
post['methodId'] = this.selectedPaymentMethod.id;
post['currency'] = this.selectedPaymentMethod.currency;
}
this.$root.clearFormValidation($('#auto-payment form'))
axios[method]('/api/v4/payments/mandate', post, { onFinish })
.then(response => {
if (method == 'post') {
this.mandate.id = null
// a new mandate, redirect to the chackout page
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
} else if (response.data.id) {
this.stripeCheckout(response.data)
}
} else {
// an update
if (response.data.status == 'success') {
this.dialog.modal('hide');
this.mandate = response.data
this.$toast.success(response.data.message)
}
}
})
},
autoPaymentChange(event) {
- this.autoPaymentForm(event, 'Update auto-payment')
+ 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) {
const dialog = $('#payment-dialog')
this.formLock = false
this.paymentMethods = []
this.paymentForm = 'method'
this.nextForm = nextForm
if (nextForm == 'auto') {
- this.paymentDialogTitle = 'Add auto-payment'
+ this.paymentDialogTitle = this.$t('wallet.auto-payment-setup')
} else {
- this.paymentDialogTitle = 'Top up your wallet'
+ this.paymentDialogTitle = this.$t('wallet.top-up')
}
const methods = $('#payment-method')
this.$root.addLoader(methods, false)
axios.get('/api/v4/payments/methods', {params: {type: nextForm == 'manual' ? 'oneoff' : 'recurring'}})
.then(response => {
this.$root.removeLoader(methods)
this.paymentMethods = response.data
})
.catch(this.$root.errorHandler)
this.dialog = dialog.on('shown.bs.modal', () => {}).modal()
},
autoPaymentForm(event, title) {
const dialog = $('#payment-dialog')
this.paymentForm = 'auto'
this.paymentDialogTitle = title
this.formLock = false
this.dialog = dialog.on('shown.bs.modal', () => {
dialog.find('#mandate_amount').focus()
}).modal()
setTimeout(() => { this.dialog.find('#mandate_amount').focus()}, 10)
},
receiptDownload() {
const receipt = $('#receipt-id').val()
this.$root.downloadFile('/api/v4/wallets/' + this.walletId + '/receipts/' + receipt)
},
stripeInit() {
let script = $('#stripe-script')
if (!script.length) {
script = document.createElement('script')
script.onload = () => {
this.stripe = Stripe(window.config.stripePK)
}
script.id = 'stripe-script'
script.src = 'https://js.stripe.com/v3/'
document.getElementsByTagName('head')[0].appendChild(script)
} else {
this.stripe = Stripe(window.config.stripePK)
}
},
stripeCheckout(data) {
if (!this.stripe) {
return
}
this.stripe.redirectToCheckout({
sessionId: data.id
}).then(result => {
// If it fails due to a browser or network error,
// display the localized error message to the user
if (result.error) {
this.$toast.error(result.error.message)
}
})
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/ListInput.vue b/src/resources/vue/Widgets/ListInput.vue
index 3a473944..95fe7f00 100644
--- a/src/resources/vue/Widgets/ListInput.vue
+++ b/src/resources/vue/Widgets/ListInput.vue
@@ -1,75 +1,75 @@
<template>
<div class="list-input" :id="id">
<div class="input-group">
<input :id="id + '-input'" type="text" class="form-control main-input" @keydown="keyDown">
<div class="input-group-append">
<a href="#" class="btn btn-outline-secondary" @click.prevent="addItem">
<svg-icon icon="plus"></svg-icon>
- <span class="sr-only">Add</span>
+ <span class="sr-only">{{ $t('btn.add') }}</span>
</a>
</div>
</div>
<div class="input-group" v-for="(item, index) in list" :key="index">
<input type="text" class="form-control" v-model="list[index]">
<div class="input-group-append">
<a href="#" class="btn btn-outline-secondary" @click.prevent="deleteItem(index)">
<svg-icon icon="trash-alt"></svg-icon>
- <span class="sr-only">Delete</span>
+ <span class="sr-only">{{ $t('btn.delete') }}</span>
</a>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
list: { type: Array, default: () => [] },
id: { type: String, default: '' }
},
mounted() {
this.input = $(this.$el).find('.main-input')[0]
// On form submit add the text from main input to the list
// Users tend to forget about pressing the "plus" button
// Note: We can't use form.onsubmit (too late)
// Note: Use of input.onblur has been proven to be problematic
// TODO: What with forms that have no submit button?
$(this.$el).closest('form').find('button[type=submit]').on('click', () => {
this.addItem(false)
})
},
methods: {
addItem(focus) {
let value = this.input.value
if (value) {
this.list.push(value)
this.input.value = ''
this.input.classList.remove('is-invalid')
if (focus !== false) {
this.input.focus()
}
if (this.list.length == 1) {
this.$el.classList.remove('is-invalid')
}
}
},
deleteItem(index) {
this.$delete(this.list, index)
if (!this.list.length) {
this.$el.classList.remove('is-invalid')
}
},
keyDown(e) {
if (e.which == 13 && e.target.value) {
this.addItem()
e.preventDefault()
}
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/PaymentLog.vue b/src/resources/vue/Widgets/PaymentLog.vue
index b9e4543b..76775be7 100644
--- a/src/resources/vue/Widgets/PaymentLog.vue
+++ b/src/resources/vue/Widgets/PaymentLog.vue
@@ -1,76 +1,76 @@
<template>
<div>
<table class="table table-sm m-0 payments">
<thead class="thead-light">
<tr>
- <th scope="col">Date</th>
- <th scope="col">Description</th>
+ <th scope="col">{{ $t('form.date') }}</th>
+ <th scope="col">{{ $t('form.description') }}</th>
<th scope="col"></th>
- <th scope="col" class="price">Amount</th>
+ <th scope="col" class="price">{{ $t('form.amount') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="payment in payments" :id="'log' + payment.id" :key="payment.id">
<td class="datetime">{{ payment.createdAt }}</td>
<td class="description">{{ payment.description }}</td>
- <td><a v-if="payment.checkoutUrl" :href="payment.checkoutUrl">Details</a></td>
+ <td><a v-if="payment.checkoutUrl" :href="payment.checkoutUrl">{{ $t('form.details') }}</a></td>
<td class="price text-success">{{ amount(payment) }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td colspan="4">There are no pending payments for this account.</td>
+ <td colspan="4">{{ $t('wallet.pending-payments-none') }}</td>
</tr>
</tfoot>
</table>
<div class="text-center p-3" id="payments-loader" v-if="hasMore">
- <button class="btn btn-secondary" @click="loadLog(true)">Load more</button>
+ <button class="btn btn-secondary" @click="loadLog(true)">{{ $t('nav.more') }}</button>
</div>
</div>
</template>
<script>
export default {
props: {
},
data() {
return {
payments: [],
hasMore: false,
page: 1
}
},
mounted() {
this.loadLog()
},
methods: {
loadLog(more) {
let loader = $(this.$el)
let param = ''
if (more) {
param = '?page=' + (this.page + 1)
loader = $('#payments-loader')
}
this.$root.addLoader(loader)
axios.get('/api/v4/payments/pending' + param)
.then(response => {
this.$root.removeLoader(loader)
// Note: In Vue we can't just use .concat()
for (let i in response.data.list) {
this.$set(this.payments, this.payments.length, response.data.list[i])
}
this.hasMore = response.data.hasMore
this.page = response.data.page || 1
})
.catch(error => {
this.$root.removeLoader(loader)
})
},
amount(payment) {
return this.$root.price(payment.amount)
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/Status.vue b/src/resources/vue/Widgets/Status.vue
index ed7d56bb..db3ea1ea 100644
--- a/src/resources/vue/Widgets/Status.vue
+++ b/src/resources/vue/Widgets/Status.vue
@@ -1,203 +1,204 @@
<template>
<div v-if="!state.isReady" id="status-box" :class="'p-4 mb-3 rounded process-' + className">
<div v-if="state.step != 'domain-confirmed'" class="d-flex align-items-start">
<p id="status-body" class="flex-grow-1">
- <span v-if="scope == 'dashboard'">We are preparing your account.</span>
- <span v-else-if="scope == 'domain'">We are preparing the domain.</span>
- <span v-else-if="scope == 'distlist'">We are preparing the distribution list.</span>
- <span v-else>We are preparing the user account.</span>
+ <span v-if="scope == 'dashboard'">{{ $t('status.prepare-account') }}</span>
+ <span v-else-if="scope == 'domain'">{{ $t('status.prepare-domain') }}</span>
+ <span v-else-if="scope == 'distlist'">{{ $t('status.prepare-distlist') }}</span>
+ <span v-else>{{ $t('status.prepare-user') }}</span>
<br>
- Some features may be missing or readonly at the moment.<br>
- <span id="refresh-text" v-if="refresh">The process never ends? Press the "Refresh" button, please.</span>
+ {{ $t('status.prepare-hint') }}
+ <br>
+ <span id="refresh-text" v-if="refresh">{{ $t('status.prepare-refresh') }}</span>
</p>
<button v-if="refresh" id="status-refresh" href="#" class="btn btn-secondary" @click="statusRefresh">
- <svg-icon icon="sync-alt"></svg-icon> Refresh
+ <svg-icon icon="sync-alt"></svg-icon> {{ $t('btn.refresh') }}
</button>
</div>
<div v-else class="d-flex align-items-start">
<p id="status-body" class="flex-grow-1">
- <span v-if="scope == 'dashboard'">Your account is almost ready.</span>
- <span v-else-if="scope == 'domain'">The domain is almost ready.</span>
- <span v-else-if="scope == 'distlist'">The distribution list is almost ready.</span>
- <span v-else>The user account is almost ready.</span>
+ <span v-if="scope == 'dashboard'">{{ $t('status.ready-account') }}</span>
+ <span v-else-if="scope == 'domain'">{{ $t('status.ready-domain') }}</span>
+ <span v-else-if="scope == 'distlist'">{{ $t('status.ready-distlist') }}</span>
+ <span v-else>{{ $t('status.ready-user') }}</span>
<br>
- Verify your domain to finish the setup process.
+ {{ $t('status.verify') }}
</p>
<div v-if="scope == 'domain'">
<button id="status-verify" class="btn btn-secondary text-nowrap" @click="confirmDomain">
- <svg-icon icon="sync-alt"></svg-icon> Verify
+ <svg-icon icon="sync-alt"></svg-icon> {{ $t('btn.verify') }}
</button>
</div>
<div v-else-if="state.link && scope != 'domain'">
- <router-link id="status-link" class="btn btn-secondary" :to="{ path: state.link }">Verify domain</router-link>
+ <router-link id="status-link" class="btn btn-secondary" :to="{ path: state.link }">{{ $t('status.verify-domain') }}</router-link>
</div>
</div>
<div class="status-progress text-center">
<div class="progress">
<div class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
- <span class="progress-label">{{ state.title || 'Initializing...' }}</span>
+ <span class="progress-label">{{ state.title || $t('msg.initializing') }}</span>
</div>
</div>
</template>
<script>
export default {
props: {
status: { type: Object, default: () => {} }
},
data() {
return {
className: 'pending',
refresh: false,
delay: 5000,
scope: 'user',
state: { isReady: true },
waiting: 0,
}
},
watch: {
// We use property watcher because parent component
// might set the property with a delay and we need to parse it
// FIXME: Problem with this and update-status event is that whenever
// we emit the event a watcher function is executed, causing
// duplicate parseStatusInfo() calls. Fortunaltely this does not
// cause duplicate http requests.
status: function (val, oldVal) {
this.parseStatusInfo(val)
}
},
destroyed() {
clearTimeout(window.infoRequest)
},
mounted() {
this.scope = this.$route.name
},
methods: {
// Displays account status information
parseStatusInfo(info) {
if (info) {
if (!info.isReady) {
let failedCount = 0
let allCount = info.process.length
info.process.forEach((step, idx) => {
if (!step.state) {
failedCount++
if (!info.title) {
info.title = step.title
info.step = step.label
info.link = step.link
}
}
})
info.percent = Math.floor((allCount - failedCount) / allCount * 100);
}
this.state = info || {}
this.$nextTick(function() {
$(this.$el).find('.progress-bar')
.css('width', info.percent + '%')
.attr('aria-valuenow', info.percent)
})
// Unhide the Refresh button, the process is in failure state
this.refresh = info.processState == 'failed' && this.waiting == 0
if (this.refresh || info.step == 'domain-confirmed') {
this.className = 'failed'
}
// A async job has been dispatched, switch to a waiting mode where
// we hide the Refresh button and pull status for about a minute,
// after that we switch to normal mode, i.e. user can Refresh again (if still not ready)
if (info.processState == 'waiting') {
this.waiting = 10
this.delay = 5000
} else if (this.waiting > 0) {
this.waiting -= 1
}
}
// Update status process info every 5,6,7,8,9,... seconds
clearTimeout(window.infoRequest)
if ((!this.refresh || this.waiting > 0) && (!info || !info.isReady)) {
window.infoRequest = setTimeout(() => {
delete window.infoRequest
// Stop updates after user logged out
if (!this.$store.state.isLoggedIn) {
return;
}
axios.get(this.getUrl())
.then(response => {
this.parseStatusInfo(response.data)
this.emitEvent(response.data)
})
.catch(error => {
this.parseStatusInfo(info)
})
}, this.delay);
this.delay += 1000;
}
},
statusRefresh() {
clearTimeout(window.infoRequest)
axios.get(this.getUrl() + '?refresh=1')
.then(response => {
this.$toast[response.data.status](response.data.message)
this.parseStatusInfo(response.data)
this.emitEvent(response.data)
})
.catch(error => {
this.parseStatusInfo(this.state)
})
},
confirmDomain() {
axios.get('/api/v4/domains/' + this.$route.params.domain + '/confirm')
.then(response => {
if (response.data.message) {
this.$toast[response.data.status](response.data.message)
}
if (response.data.status == 'success') {
this.parseStatusInfo(response.data.statusInfo)
response.data.isConfirmed = true
this.emitEvent(response.data)
}
})
},
emitEvent(data) {
// Remove useless data and emit the event (to parent components)
delete data.status
delete data.message
this.$emit('status-update', data)
},
getUrl() {
let url
switch (this.scope) {
case 'dashboard':
url = '/api/v4/users/' + this.$store.state.authInfo.id + '/status'
break
case 'domain':
url = '/api/v4/domains/' + this.$route.params.domain + '/status'
break
case 'distlist':
url = '/api/v4/groups/' + this.$route.params.list + '/status'
break
default:
url = '/api/v4/users/' + this.$route.params.user + '/status'
}
return url
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/SupportForm.vue b/src/resources/vue/Widgets/SupportForm.vue
index 2c7e822b..11067677 100644
--- a/src/resources/vue/Widgets/SupportForm.vue
+++ b/src/resources/vue/Widgets/SupportForm.vue
@@ -1,106 +1,106 @@
<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">Contact Support</h5>
- <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <h5 class="modal-title">{{ $t('support.title') }}</h5>
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="form">
<div class="form-group">
- <label>Customer number or email address you have with us</label>
- <input id="support-user" type="text" class="form-control" placeholder="e.g. 12345678 or john@kolab.org" v-model="user" />
- <small class="form-text text-muted">Leave blank if you are not a customer yet</small>
+ <label for="support-user">{{ $t('support.id') }}</label>
+ <input id="support-user" type="text" class="form-control" :placeholder="$t('support.id-pl')" v-model="user" />
+ <small class="form-text text-muted">{{ $t('support.id-hint') }}</small>
</div>
<div class="form-group">
- <label>Name</label>
- <input id="support-name" type="text" class="form-control" placeholder="how we should call you in our reply" v-model="name" />
+ <label for="support-name">{{ $t('support.name') }}</label>
+ <input id="support-name" type="text" class="form-control" :placeholder="$t('support.name-pl')" v-model="name" />
</div>
<div class="form-group">
- <label>Working email address</label>
- <input id="support-email" type="email" class="form-control" placeholder="make sure we can reach you at this address" v-model="email" required />
+ <label for="support-email">{{ $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="form-group">
- <label>Issue Summary</label>
- <input id="support-summary" type="text" class="form-control" placeholder="one sentence that summarizes your issue" v-model="summary" required />
+ <label for="support-summary">{{ $t('support.summary') }}</label>
+ <input id="support-summary" type="text" class="form-control" :placeholder="$t('support.summary-pl')" v-model="summary" required />
</div>
<div class="form-group">
- <label>Issue Explanation</label>
+ <label for="support-body">{{ $t('support.expl') }}</label>
<textarea id="support-body" class="form-control" rows="5" v-model="body" required></textarea>
</div>
</div>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
- <button type="submit" class="btn btn-primary modal-action"><svg-icon icon="check"></svg-icon> Submit</button>
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">{{ $t('btn.cancel') }}</button>
+ <button type="submit" class="btn btn-primary modal-action"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
</div>
</form>
</div>
</div>
</template>
<script>
export default {
data() {
return {
body: '',
email: '',
name: '',
summary: '',
user: ''
}
},
mounted() {
this.dialog = $('#support-dialog')
.on('hide.bs.modal', () => {
this.lockForm(false)
if (this.cancelToken) {
this.cancelToken()
this.cancelToken = null
}
})
.on('show.bs.modal', () => {
this.cancelToken = null
})
},
methods: {
lockForm(lock) {
this.dialog.find('input,textarea,.modal-action').prop('disabled', lock)
},
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.modal('hide')
this.$toast.success(response.data.message)
})
.catch(error => {
this.lockForm(false)
})
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/Toast.vue b/src/resources/vue/Widgets/Toast.vue
index a4693eed..326f316e 100644
--- a/src/resources/vue/Widgets/Toast.vue
+++ b/src/resources/vue/Widgets/Toast.vue
@@ -1,114 +1,116 @@
<template>
<div class="toast-container" aria-live="polite" aria-atomic="true"></div>
</template>
<script>
import ToastMessage from './ToastMessage.vue'
+ import { i18n } from '../../js/locale'
export default {
methods: {
addToast(data) {
+ ToastMessage.i18n = i18n
const msg = Vue.extend(ToastMessage)
const instance = new msg({ propsData: { data: data } })
instance.$mount()
$(instance.$el).prependTo(this.$el)
},
processObjectData(data) {
if (typeof data === 'object' && data.msg !== undefined) {
if (data.type === undefined) {
data.type = this.defaultType
}
if (data.timeout === undefined) {
data.timeout = this.defaultTimeout
}
return data
}
return {
msg: data.toString(),
type: this.defaultType,
timeout: this.defaultTimeout
}
},
error(msg, title) {
let data = this.processObjectData(msg)
data.type = 'error'
if (title !== undefined) {
data.title = title
}
if (!msg.timeout) {
data.timeout *= 2
}
return this.addToast(data)
},
success(msg, title) {
let data = this.processObjectData(msg)
data.type = 'success'
if (title !== undefined) {
data.title = title
}
return this.addToast(data)
},
warning(msg, title) {
let data = this.processObjectData(msg)
data.type = 'warning'
if (title !== undefined) {
data.title = title
}
if (!msg.timeout) {
data.timeout *= 2
}
return this.addToast(data)
},
info(msg, title) {
let data = this.processObjectData(msg)
data.type = 'info'
if (title !== undefined) {
data.title = title
}
return this.addToast(data)
},
message(data) {
if (data.type === undefined) {
data.type = 'custom'
}
if (data.timeout === undefined) {
data.timeout = this.defaultTimeout
}
return this.addToast(data)
}
},
// Plugin installer method
install(Vue, options) {
const defaultOptions = {
defaultType: 'info',
defaultTimeout: 5000
}
options = $.extend(defaultOptions, [options || {}])
const Comp = Vue.extend(this)
const vm = new Comp({ data: options }).$mount()
document.body.appendChild(vm.$el)
Vue.prototype.$toast = vm
}
}
</script>
diff --git a/src/resources/vue/Widgets/ToastMessage.vue b/src/resources/vue/Widgets/ToastMessage.vue
index 8030d2b8..228fce75 100644
--- a/src/resources/vue/Widgets/ToastMessage.vue
+++ b/src/resources/vue/Widgets/ToastMessage.vue
@@ -1,74 +1,61 @@
<template>
<div :class="toastClassName()" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header" :class="className()">
<svg-icon icon="info-circle" v-if="data.type == 'info'"></svg-icon>
<svg-icon icon="check-circle" v-else-if="data.type == 'success'"></svg-icon>
<svg-icon icon="exclamation-circle" v-else-if="data.type == 'error'"></svg-icon>
<svg-icon icon="exclamation-circle" v-else-if="data.type == 'warning'"></svg-icon>
<svg-icon :icon="data.icon" v-else-if="data.type == 'custom' && data.icon"></svg-icon>
- <strong>{{ data.title || title() }}</strong>
- <button type="button" class="close" data-dismiss="toast" aria-label="Close">
+ <strong>{{ data.title || $t('msg.' + data.type) }}</strong>
+ <button type="button" class="close" data-dismiss="toast" :aria-label="$t('btn.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div v-if="data.body" v-html="data.body" class="toast-body"></div>
<div v-else class="toast-body">{{ data.msg }}</div>
</div>
</template>
<script>
export default {
props: {
data: { type: Object, default: () => {} }
},
mounted() {
$(this.$el)
.on('hidden.bs.toast', () => {
(this.$el).remove()
this.$destroy()
})
.on('shown.bs.toast', () => {
if (this.data.onShow) {
this.data.onShow(this.$el)
}
})
.toast({
animation: true,
autohide: this.data.timeout > 0,
delay: this.data.timeout
})
.toast('show')
},
methods: {
className() {
switch (this.data.type) {
case 'error':
return 'text-danger'
case 'warning':
case 'info':
case 'success':
return 'text-' + this.data.type
case 'custom':
return this.data.titleClassName || ''
}
},
- title() {
- const type = this.data.type
- switch (type) {
- case 'info':
- return 'Information';
- case 'error':
- case 'warning':
- case 'success':
- return type.charAt(0).toUpperCase() + type.slice(1)
- }
-
- return ''
- },
toastClassName() {
return 'toast hide toast-' + this.data.type
+ (this.data.className ? ' ' + this.data.className : '')
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/TransactionLog.vue b/src/resources/vue/Widgets/TransactionLog.vue
index 3e725251..9afa8f6c 100644
--- a/src/resources/vue/Widgets/TransactionLog.vue
+++ b/src/resources/vue/Widgets/TransactionLog.vue
@@ -1,122 +1,122 @@
<template>
<div>
<table class="table table-sm m-0 transactions">
<thead class="thead-light">
<tr>
- <th scope="col">Date</th>
- <th scope="col" v-if="isAdmin">User</th>
+ <th scope="col">{{ $t('form.date') }}</th>
+ <th scope="col" v-if="isAdmin">{{ $t('form.user') }}</th>
<th scope="col"></th>
- <th scope="col">Description</th>
- <th scope="col" class="price">Amount</th>
+ <th scope="col">{{ $t('form.description') }}</th>
+ <th scope="col" class="price">{{ $t('form.amount') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="transaction in transactions" :id="'log' + transaction.id" :key="transaction.id">
<td class="datetime">{{ transaction.createdAt }}</td>
<td class="email" v-if="isAdmin">{{ transaction.user }}</td>
<td class="selection">
<button class="btn btn-lg btn-link btn-action" title="Details" type="button"
v-if="transaction.hasDetails"
@click="loadTransaction(transaction.id)"
>
<svg-icon icon="info-circle"></svg-icon>
</button>
</td>
<td class="description">{{ description(transaction) }}</td>
<td :class="'price ' + className(transaction)">{{ amount(transaction) }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td :colspan="isAdmin ? 5 : 4">There are no transactions for this account.</td>
+ <td :colspan="isAdmin ? 5 : 4">{{ $t('wallet.transactions-none') }}</td>
</tr>
</tfoot>
</table>
<div class="text-center p-3" id="transactions-loader" v-if="hasMore">
- <button class="btn btn-secondary" @click="loadLog(true)">Load more</button>
+ <button class="btn btn-secondary" @click="loadLog(true)">{{ $t('nav.more') }}</button>
</div>
</div>
</template>
<script>
export default {
props: {
walletId: { type: String, default: null },
isAdmin: { type: Boolean, default: false },
},
data() {
return {
transactions: [],
hasMore: false,
page: 1
}
},
mounted() {
this.loadLog()
},
methods: {
loadLog(more) {
if (!this.walletId) {
return
}
let loader = $(this.$el)
let param = ''
if (more) {
param = '?page=' + (this.page + 1)
loader = $('#transactions-loader')
}
this.$root.addLoader(loader)
axios.get('/api/v4/wallets/' + this.walletId + '/transactions' + param)
.then(response => {
this.$root.removeLoader(loader)
// Note: In Vue we can't just use .concat()
for (let i in response.data.list) {
this.$set(this.transactions, this.transactions.length, response.data.list[i])
}
this.hasMore = response.data.hasMore
this.page = response.data.page || 1
})
.catch(error => {
this.$root.removeLoader(loader)
})
},
loadTransaction(id) {
let record = $('#log' + id)
let cell = record.find('td.description')
let details = $('<div class="list-details"><ul></ul><div>').appendTo(cell)
this.$root.addLoader(cell)
axios.get('/api/v4/wallets/' + this.walletId + '/transactions' + '?transaction=' + id)
.then(response => {
this.$root.removeLoader(cell)
record.find('button').remove()
let list = details.find('ul')
response.data.list.forEach(elem => {
list.append($('<li>').text(this.description(elem)))
})
})
.catch(error => {
this.$root.removeLoader(cell)
})
},
amount(transaction) {
return this.$root.price(transaction.amount)
},
className(transaction) {
return transaction.amount < 0 ? 'text-danger' : 'text-success';
},
description(transaction) {
let desc = transaction.description
if (/^(billed|created|deleted)$/.test(transaction.type)) {
desc += ' (' + this.$root.price(transaction.amount) + ')'
}
return desc
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/UserSearch.vue b/src/resources/vue/Widgets/UserSearch.vue
index e3135442..4a6b014a 100644
--- a/src/resources/vue/Widgets/UserSearch.vue
+++ b/src/resources/vue/Widgets/UserSearch.vue
@@ -1,78 +1,78 @@
<template>
<div id="search-box" class="card">
<div class="card-body">
<form @submit.prevent="searchUser" class="row justify-content-center">
<div class="input-group col-sm-8">
- <input class="form-control" type="text" placeholder="User ID, email or domain" v-model="search">
+ <input class="form-control" type="text" :placeholder="$t('user.search-pl')" v-model="search">
<div class="input-group-append">
- <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> Search</button>
+ <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> {{ $t('btn.search') }}</button>
</div>
</div>
</form>
<table v-if="users.length" class="table table-sm table-hover mt-4">
<thead class="thead-light">
<tr>
- <th scope="col">Primary Email</th>
- <th scope="col">ID</th>
- <th scope="col" class="d-none d-md-table-cell">Created</th>
- <th scope="col" class="d-none d-md-table-cell">Deleted</th>
+ <th scope="col">{{ $t('form.primary-email') }}</th>
+ <th scope="col">{{ $t('form.id') }}</th>
+ <th scope="col" class="d-none d-md-table-cell">{{ $t('form.created') }}</th>
+ <th scope="col" class="d-none d-md-table-cell">{{ $t('form.deleted') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :id="'user' + user.id" :key="user.id" :class="user.isDeleted ? 'text-secondary' : ''">
<td class="text-nowrap">
<svg-icon icon="user" :class="$root.userStatusClass(user)" :title="$root.userStatusText(user)"></svg-icon>
<router-link v-if="!user.isDeleted" :to="{ path: 'user/' + user.id }">{{ user.email }}</router-link>
<span v-if="user.isDeleted">{{ user.email }}</span>
</td>
<td>
<router-link v-if="!user.isDeleted" :to="{ path: 'user/' + user.id }">{{ user.id }}</router-link>
<span v-if="user.isDeleted">{{ user.id }}</span>
</td>
<td class="d-none d-md-table-cell">{{ toDate(user.created_at) }}</td>
<td class="d-none d-md-table-cell">{{ toDate(user.deleted_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
export default {
data() {
return {
search: '',
users: []
}
},
mounted() {
$('#search-box input').focus()
},
methods: {
searchUser() {
this.users = []
axios.get('/api/v4/users', { params: { search: this.search } })
.then(response => {
if (response.data.count == 1 && !response.data.list[0].isDeleted) {
this.$router.push({ name: 'user', params: { user: response.data.list[0].id } })
return
}
if (response.data.message) {
this.$toast.info(response.data.message)
}
this.users = response.data.list
})
.catch(this.$root.errorHandler)
},
toDate(datetime) {
if (datetime) {
return datetime.split(' ')[0]
}
}
}
}
</script>
diff --git a/src/tests/Browser/Admin/DistlistTest.php b/src/tests/Browser/Admin/DistlistTest.php
index d89bd4e6..e140bc49 100644
--- a/src/tests/Browser/Admin/DistlistTest.php
+++ b/src/tests/Browser/Admin/DistlistTest.php
@@ -1,128 +1,128 @@
<?php
namespace Tests\Browser\Admin;
use App\Group;
use Illuminate\Support\Facades\Queue;
use Tests\Browser;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\Distlist as DistlistPage;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
class DistlistTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
$this->deleteTestGroup('group-test@kolab.org');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestGroup('group-test@kolab.org');
parent::tearDown();
}
/**
* Test distlist info page (unauthenticated)
*/
public function testDistlistUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('john@kolab.org');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
$browser->visit('/distlist/' . $group->id)->on(new Home());
});
}
/**
* Test distribution list info page
*/
public function testInfo(): void
{
Queue::fake();
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('john@kolab.org');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
$group->members = ['test1@gmail.com', 'test2@gmail.com'];
$group->save();
$distlist_page = new DistlistPage($group->id);
$user_page = new UserPage($user->id);
// Goto the distlist page
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true)
->on(new Dashboard())
->visit($user_page)
->on($user_page)
->click('@nav #tab-distlists')
->pause(1000)
->click('@user-distlists table tbody tr:first-child td a')
->on($distlist_page)
->assertSeeIn('@distlist-info .card-title', $group->email)
->with('@distlist-info form', function (Browser $browser) use ($group) {
$browser->assertElementsCount('.row', 3)
- ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)')
+ ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #distlistid', "{$group->id} ({$group->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status.text-danger', 'Not Ready')
->assertSeeIn('.row:nth-child(3) label', 'Recipients')
->assertSeeIn('.row:nth-child(3) #members', $group->members[0])
->assertSeeIn('.row:nth-child(3) #members', $group->members[1]);
});
// Test invalid group identifier
$browser->visit('/distlist/abc')->assertErrorPage(404);
});
}
/**
* Test suspending/unsuspending a distribution list
*
* @depends testInfo
*/
public function testSuspendAndUnsuspend(): void
{
Queue::fake();
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('john@kolab.org');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
$group->status = Group::STATUS_ACTIVE | Group::STATUS_LDAP_READY;
$group->save();
$browser->visit(new DistlistPage($group->id))
->assertVisible('@distlist-info #button-suspend')
->assertMissing('@distlist-info #button-unsuspend')
->assertSeeIn('@distlist-info #status.text-success', 'Active')
->click('@distlist-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'Distribution list suspended successfully.')
->assertSeeIn('@distlist-info #status.text-warning', 'Suspended')
->assertMissing('@distlist-info #button-suspend')
->click('@distlist-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'Distribution list unsuspended successfully.')
->assertSeeIn('@distlist-info #status.text-success', 'Active')
->assertVisible('@distlist-info #button-suspend')
->assertMissing('@distlist-info #button-unsuspend');
});
}
}
diff --git a/src/tests/Browser/Admin/DomainTest.php b/src/tests/Browser/Admin/DomainTest.php
index 109c55bd..a13f86ce 100644
--- a/src/tests/Browser/Admin/DomainTest.php
+++ b/src/tests/Browser/Admin/DomainTest.php
@@ -1,119 +1,119 @@
<?php
namespace Tests\Browser\Admin;
use App\Domain;
use Tests\Browser;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\Domain as DomainPage;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class DomainTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
parent::tearDown();
}
/**
* Test domain info page (unauthenticated)
*/
public function testDomainUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('kolab.org');
$browser->visit('/domain/' . $domain->id)->on(new Home());
});
}
/**
* Test domain info page
*/
public function testDomainInfo(): void
{
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('kolab.org');
$domain_page = new DomainPage($domain->id);
$john = $this->getTestUser('john@kolab.org');
$user_page = new UserPage($john->id);
// Goto the domain page
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true)
->on(new Dashboard())
->visit($user_page)
->on($user_page)
->click('@nav #tab-domains')
->pause(1000)
->click('@user-domains table tbody tr:first-child td a');
$browser->on($domain_page)
->assertSeeIn('@domain-info .card-title', 'kolab.org')
->with('@domain-info form', function (Browser $browser) use ($domain) {
$browser->assertElementsCount('.row', 2)
- ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)')
+ ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 1);
// Assert Configuration tab
$browser->assertSeeIn('@nav #tab-config', 'Configuration')
->with('@domain-config', function (Browser $browser) {
$browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.')
->assertSeeIn('pre#dns-config', 'kolab.org.');
});
});
}
/**
* Test suspending/unsuspending a domain
*
* @depends testDomainInfo
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('domainscontroller.com', [
'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE
| Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED
| Domain::STATUS_VERIFIED,
'type' => Domain::TYPE_EXTERNAL,
]);
$browser->visit(new DomainPage($domain->id))
->assertVisible('@domain-info #button-suspend')
->assertMissing('@domain-info #button-unsuspend')
->click('@domain-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.')
->assertSeeIn('@domain-info #status span.text-warning', 'Suspended')
->assertMissing('@domain-info #button-suspend')
->click('@domain-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.')
->assertSeeIn('@domain-info #status span.text-success', 'Active')
->assertVisible('@domain-info #button-suspend')
->assertMissing('@domain-info #button-unsuspend');
});
}
}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
index 35068244..771200f5 100644
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -1,498 +1,498 @@
<?php
namespace Tests\Browser\Admin;
use App\Auth\SecondFactor;
use App\Discount;
use App\Entitlement;
use App\Sku;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
Entitlement::where('cost', '>=', 5000)->delete();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
Entitlement::where('cost', '>=', 5000)->delete();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testUserUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$browser->visit('/user/' . $jack->id)->on(new Home());
});
}
/**
* Test user info page
*/
public function testUserInfo(): void
{
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$page = new UserPage($jack->id);
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true)
->on(new Dashboard())
->visit($page)
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $jack->email)
->with('@user-info form', function (Browser $browser) use ($jack) {
$browser->assertElementsCount('.row', 7)
->assertSeeIn('.row:nth-child(1) label', 'Managed by')
->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org')
- ->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)')
+ ->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})")
->assertSeeIn('.row:nth-child(3) label', 'Status')
->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active')
- ->assertSeeIn('.row:nth-child(4) label', 'First name')
+ ->assertSeeIn('.row:nth-child(4) label', 'First Name')
->assertSeeIn('.row:nth-child(4) #first_name', 'Jack')
- ->assertSeeIn('.row:nth-child(5) label', 'Last name')
+ ->assertSeeIn('.row:nth-child(5) label', 'Last Name')
->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels')
- ->assertSeeIn('.row:nth-child(6) label', 'External email')
+ ->assertSeeIn('.row:nth-child(6) label', 'External Email')
->assertMissing('.row:nth-child(6) #external_email a')
->assertSeeIn('.row:nth-child(7) label', 'Country')
->assertSeeIn('.row:nth-child(7) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 6);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF')
->assertMissing('table tfoot')
->assertMissing('#reset2fa');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
});
}
/**
* Test user info page (continue)
*
* @depends testUserInfo
*/
public function testUserInfo2(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$page = new UserPage($john->id);
$discount = Discount::where('code', 'TEST')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($john->wallets->first());
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $john->email)
->with('@user-info form', function (Browser $browser) use ($john) {
$ext_email = $john->getSetting('external_email');
$browser->assertElementsCount('.row', 9)
- ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)')
+ ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active')
- ->assertSeeIn('.row:nth-child(3) label', 'First name')
+ ->assertSeeIn('.row:nth-child(3) label', 'First Name')
->assertSeeIn('.row:nth-child(3) #first_name', 'John')
- ->assertSeeIn('.row:nth-child(4) label', 'Last name')
+ ->assertSeeIn('.row:nth-child(4) label', 'Last Name')
->assertSeeIn('.row:nth-child(4) #last_name', 'Doe')
->assertSeeIn('.row:nth-child(5) label', 'Organization')
->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers')
->assertSeeIn('.row:nth-child(6) label', 'Phone')
->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone'))
- ->assertSeeIn('.row:nth-child(7) label', 'External email')
+ ->assertSeeIn('.row:nth-child(7) label', 'External Email')
->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email)
->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email")
->assertSeeIn('.row:nth-child(8) label', 'Address')
->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address'))
->assertSeeIn('.row:nth-child(9) label', 'Country')
->assertSeeIn('.row:nth-child(9) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 6);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (1)')
->click('@nav #tab-domains')
->with('@user-domains table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertMissing('tfoot');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)')
->click('@nav #tab-distlists')
->with('@user-distlists table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'group-test@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger')
->assertMissing('tfoot');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (4)')
->click('@nav #tab-users')
->with('@user-users table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
->assertMissing('tfoot');
});
});
// Now we go to Ned's info page, he's a controller on John's wallet
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
$beta_sku = Sku::where('title', 'beta')->first();
$storage_sku = Sku::where('title', 'storage')->first();
$wallet = $ned->wallet();
// Add an extra storage and beta entitlement with different prices
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $beta_sku->id,
'cost' => 5010,
'entitleable_id' => $ned->id,
'entitleable_type' => User::class
]);
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $storage_sku->id,
'cost' => 5000,
'entitleable_id' => $ned->id,
'entitleable_type' => User::class
]);
$page = new UserPage($ned->id);
$browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $ned->email)
->with('@user-info form', function (Browser $browser) use ($ned) {
- $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)')
+ $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})");
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 6);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'This user has no email aliases.');
});
// Assert Subscriptions tab, we expect John's discount here
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (6)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 6)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 3 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '45,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,90 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(6) td:first-child', 'Private Beta (invitation only)')
->assertSeeIn('table tbody tr:nth-child(6) td:last-child', '45,09 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher')
->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth');
});
// We don't expect John's domains here
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// We don't expect John's users here
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// We don't expect John's distribution lists here
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
});
}
/**
* Test editing an external email
*
* @depends testUserInfo2
*/
public function testExternalEmail(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->waitFor('@user-info #external_email button')
->click('@user-info #external_email button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#email-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'External email')
+ $browser->assertSeeIn('@title', 'External Email')
->assertFocused('@body input')
->assertValue('@body input', 'john.doe.external@gmail.com')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#email-dialog')
->click('@user-info #external_email button')
// Test email validation error handling, and email update
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->type('@body input', 'test')
->click('@button-action')
->waitFor('@body input.is-invalid')
->assertSeeIn(
'@body input + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('@body input', 'test@test.com')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->assertSeeIn('@user-info #external_email a', 'test@test.com')
->click('@user-info #external_email button')
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertValue('@body input', 'test@test.com')
->assertMissing('@body input.is-invalid')
->assertMissing('@body input + .invalid-feedback')
->click('@button-cancel');
})
->assertSeeIn('@user-info #external_email a', 'test@test.com');
// $john->getSetting() may not work here as it uses internal cache
// read the value form database
$current_ext_email = $john->settings()->where('key', 'external_email')->first()->value;
$this->assertSame('test@test.com', $current_ext_email);
});
}
/**
* Test suspending/unsuspending the user
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend')
->click('@user-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.')
->assertSeeIn('@user-info #status span.text-warning', 'Suspended')
->assertMissing('@user-info #button-suspend')
->click('@user-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.')
->assertSeeIn('@user-info #status span.text-success', 'Active')
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend');
});
}
/**
* Test resetting 2FA for the user
*/
public function testReset2FA(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
$sku2fa = Sku::firstOrCreate(['title' => '2fa']);
$user->assignSku($sku2fa);
SecondFactor::seed('userstest1@kolabnow.com');
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) {
$browser->waitFor('#reset2fa')
->assertVisible('#sku' . $sku2fa->id);
})
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)')
->click('#reset2fa')
->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', '2-Factor Authentication Reset')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Reset')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.')
->assertMissing('#sku' . $sku2fa->id)
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)');
});
}
}
diff --git a/src/tests/Browser/Pages/Reseller/Invitations.php b/src/tests/Browser/Pages/Reseller/Invitations.php
index 8837c21c..f9bd044f 100644
--- a/src/tests/Browser/Pages/Reseller/Invitations.php
+++ b/src/tests/Browser/Pages/Reseller/Invitations.php
@@ -1,49 +1,49 @@
<?php
namespace Tests\Browser\Pages\Reseller;
use Laravel\Dusk\Page;
class Invitations extends Page
{
/**
* Get the URL for the page.
*
* @return string
*/
public function url(): string
{
return '/invitations';
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function assert($browser)
{
$browser->assertPathIs($this->url())
->waitUntilMissing('@app .app-loader')
- ->assertSeeIn('#invitations .card-title', 'Signup Invitations');
+ ->assertSeeIn('#invitations .card-title', 'Signup invitations');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
return [
'@app' => '#app',
'@create-button' => '.card-text button.create-invite',
'@create-dialog' => '#invite-create',
'@search-button' => '#search-form button',
'@search-input' => '#search-form input',
'@table' => '#invitations-list',
];
}
}
diff --git a/src/tests/Browser/Pages/UserList.php b/src/tests/Browser/Pages/UserList.php
index da5b2d71..9a7c0702 100644
--- a/src/tests/Browser/Pages/UserList.php
+++ b/src/tests/Browser/Pages/UserList.php
@@ -1,45 +1,45 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Page;
class UserList extends Page
{
/**
* Get the URL for the page.
*
* @return string
*/
public function url(): string
{
return '/users';
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function assert($browser)
{
$browser->assertPathIs($this->url())
->waitUntilMissing('@app .app-loader')
- ->assertSeeIn('#user-list .card-title', 'User Accounts');
+ ->assertSeeIn('#user-list .card-title', 'User accounts');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
return [
'@app' => '#app',
'@table' => '#user-list table',
];
}
}
diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php
index 27018d3f..24000621 100644
--- a/src/tests/Browser/PaymentMollieTest.php
+++ b/src/tests/Browser/PaymentMollieTest.php
@@ -1,313 +1,313 @@
<?php
namespace Tests\Browser;
use App\Providers\PaymentProvider;
use App\Wallet;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\PaymentMollie;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
class PaymentMollieTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('payment-test@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('payment-test@kolabnow.com');
parent::tearDown();
}
/**
* Test the payment process
*
* @group mollie
*/
public function testPayment(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('@main button', 'Add credit')
->click('@main button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->waitFor('#payment-method-selection #creditcard')
->waitFor('#payment-method-selection #paypal')
->assertMissing('#payment-method-selection #banktransfer')
->click('#creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->assertFocused('#amount')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #amount', 'aaa')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #amount', '12.34')
// Note we use double click to assert it does not create redundant requests
->click('@button-action')
->click('@button-action');
})
->on(new PaymentMollie())
->assertSeeIn('@title', \config('app.name') . ' Payment')
->assertSeeIn('@amount', 'CHF 12.34');
$this->assertSame(1, $user->wallets()->first()->payments()->count());
// Looks like the Mollie testing mode is limited.
// We'll select credit card method and mark the payment as paid
// We can't do much more, we have to trust Mollie their page works ;)
// For some reason I don't get the method selection form, it
// immediately jumps to the next step. Let's detect that
if ($browser->element('@methods')) {
$browser->click('@methods button.grid-button-creditcard')
->waitFor('button.form__button');
}
$browser->click('@status-table input[value="paid"]')
->click('button.form__button');
// Now it should redirect back to wallet page and in background
// use the webhook to update payment status (and balance).
// Looks like in test-mode the webhook is executed before redirect
// so we can expect balance updated on the wallet page
$browser->waitForLocation('/wallet')
->on(new WalletPage())
->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF');
});
}
/**
* Test the auto-payment setup process
*
* @group mollie
*/
public function testAutoPaymentSetup(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertMissing('@body #mandate-form .alert')
->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Add auto-payment')
+ $browser->assertSeeIn('@title', 'Set up auto-payment')
->waitFor('#payment-method-selection #creditcard')
->assertMissing('#payment-method-selection #paypal')
->assertMissing('#payment-method-selection #banktransfer')
->click('#creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Add auto-payment')
+ $browser->assertSeeIn('@title', 'Set up auto-payment')
->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by')
->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100)
->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore
->assertValue('@body #mandate_balance', '0')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '-1')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertVisible('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.')
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '0')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertMissing('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertMissing('#mandate_balance + span + .invalid-feedback')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
// Note we use double click to assert it does not create redundant requests
->click('@button-action')
->click('@button-action');
})
->on(new PaymentMollie())
->assertSeeIn('@title', \config('app.name') . ' Auto-Payment Setup')
->assertMissing('@amount')
->submitValidCreditCard()
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
->waitFor('#mandate-info')
->assertPresent('#mandate-info p:first-child')
->assertSeeIn(
'#mandate-info p:first-child',
'Auto-payment is set to fill up your account by 100 CHF ' .
'every time your account balance gets under 0 CHF.'
)
->assertSeeIn(
'#mandate-info p:nth-child(2)',
'Mastercard (**** **** **** 6787)'
)
->assertMissing('@body .alert');
$this->assertSame(1, $user->wallets()->first()->payments()->count());
});
// Test updating (disabled) auto-payment
$this->browse(function (Browser $browser) use ($user) {
$wallet = $user->wallets()->first();
$wallet->setSetting('mandate_disabled', 1);
$browser->refresh()
->on(new WalletPage())
->waitFor('#mandate-info')
->assertSeeIn(
'#mandate-info .disabled-mandate',
'The configured auto-payment has been disabled'
)
->assertSeeIn('#mandate-info button.btn-primary', 'Change auto-payment')
->click('#mandate-info button.btn-primary')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Update auto-payment')
->assertSeeIn(
'@body form .disabled-mandate',
'The auto-payment is disabled.'
)
->assertValue('@body #mandate_amount', '100')
->assertValue('@body #mandate_balance', '0')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
// Test error handling
->type('@body #mandate_amount', 'aaa')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #mandate_amount', '50')
->click('@button-action');
})
->waitUntilMissing('#payment-dialog')
->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.')
// make sure the "disabled" text isn't there
->assertMissing('#mandate-info .disabled-mandate')
->click('#mandate-info button.btn-primary')
->assertMissing('form .disabled-mandate')
->click('button.modal-cancel');
});
// Test deleting auto-payment
$this->browse(function (Browser $browser) {
$browser->on(new WalletPage())
->waitFor('#mandate-info')
->assertSeeIn('#mandate-info * button.btn-danger', 'Cancel auto-payment')
->assertVisible('#mandate-info * button.btn-danger')
->click('#mandate-info * button.btn-danger')
->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
->assertVisible('#mandate-form')
->assertMissing('#mandate-info');
});
// Test pending and failed mandate
$this->browse(function (Browser $browser) {
$browser->on(new WalletPage())
->assertMissing('@body #mandate-form .alert')
->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Add auto-payment')
+ $browser->assertSeeIn('@title', 'Set up auto-payment')
->waitFor('#payment-method-selection #creditcard')
->click('#creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Add auto-payment')
+ $browser->assertSeeIn('@title', 'Set up auto-payment')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
->click('@button-action');
})
->on(new PaymentMollie())
->submitValidCreditCard('open')
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
->on(new WalletPage())
->assertSeeIn(
'#mandate-info .alert-warning',
'The setup of the automatic payment is still in progress.'
)
// Delete the mandate
->click('#mandate-info * button.btn-danger')
->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
->assertMissing('@body #mandate-form .alert')
// Create a new mandate
->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Add auto-payment')
+ $browser->assertSeeIn('@title', 'Set up auto-payment')
->waitFor('#payment-method-selection #creditcard')
->click('#creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Add auto-payment')
+ $browser->assertSeeIn('@title', 'Set up auto-payment')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
->click('@button-action');
})
->on(new PaymentMollie())
->submitValidCreditCard('failed')
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
->on(new WalletPage())
->waitFor('#mandate-form .alert-danger')
->assertSeeIn(
'#mandate-form .alert-danger',
'The setup of automatic payments failed. Restart the process to enable'
)
->click('@main button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->waitFor('#mandate-form')
->assertMissing('#mandate-info');
});
});
}
}
diff --git a/src/tests/Browser/PaymentStripeTest.php b/src/tests/Browser/PaymentStripeTest.php
index 5bb34510..5ce1073e 100644
--- a/src/tests/Browser/PaymentStripeTest.php
+++ b/src/tests/Browser/PaymentStripeTest.php
@@ -1,234 +1,234 @@
<?php
namespace Tests\Browser;
use App\Providers\PaymentProvider;
use App\Wallet;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\PaymentStripe;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
class PaymentStripeTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('payment-test@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('payment-test@kolabnow.com');
parent::tearDown();
}
/**
* Test the payment process
*
* @group stripe
*/
public function testPayment(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'stripe'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('@main button', 'Add credit')
->click('@main button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->waitFor('#payment-method-selection #creditcard')
->waitFor('#payment-method-selection #paypal')
->assertMissing('#payment-method-selection #banktransfer')
->click('#creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->assertFocused('#amount')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #amount', 'aaa')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #amount', '12.34')
// Note we use double click to assert it does not create redundant requests
->click('@button-action')
->click('@button-action');
})
->on(new PaymentStripe())
->assertSeeIn('@title', \config('app.name') . ' Payment')
->assertSeeIn('@amount', 'CHF 12.34')
->assertValue('@email-input', $user->email)
->submitValidCreditCard();
// Now it should redirect back to wallet page and in background
// use the webhook to update payment status (and balance).
// Looks like in test-mode the webhook is executed before redirect
// so we can expect balance updated on the wallet page
$browser->waitForLocation('/wallet', 30) // need more time than default 5 sec.
->on(new WalletPage())
->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF');
});
}
/**
* Test the auto-payment setup process
*
* @group stripe
*/
public function testAutoPaymentSetup(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
// Test creating auto-payment
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'stripe'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertMissing('@body #mandate-form .alert')
->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Add auto-payment')
+ $browser->assertSeeIn('@title', 'Set up auto-payment')
->waitFor('#payment-method-selection #creditcard')
->assertMissing('#payment-method-selection #paypal')
->assertMissing('#payment-method-selection #banktransfer')
->click('#creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Add auto-payment')
+ $browser->assertSeeIn('@title', 'Set up auto-payment')
->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by')
->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100)
->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore
->assertValue('@body #mandate_balance', '0')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '-1')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertVisible('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.')
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '0')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertMissing('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertMissing('#mandate_balance + span + .invalid-feedback')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
// Note we use double click to assert it does not create redundant requests
->click('@button-action')
->click('@button-action');
})
->on(new PaymentStripe())
->assertMissing('@title')
->assertMissing('@amount')
->assertValue('@email-input', $user->email)
->submitValidCreditCard()
->waitForLocation('/wallet', 30) // need more time than default 5 sec.
->visit('/wallet?paymentProvider=stripe')
->waitFor('#mandate-info')
->assertPresent('#mandate-info p:first-child')
->assertSeeIn(
'#mandate-info p:first-child',
'Auto-payment is set to fill up your account by 100 CHF ' .
'every time your account balance gets under 0 CHF.'
)
->assertSeeIn(
'#mandate-info p:nth-child(2)',
'Visa (**** **** **** 4242)'
)
->assertMissing('@body .alert');
});
// Test updating (disabled) auto-payment
$this->browse(function (Browser $browser) use ($user) {
$wallet = $user->wallets()->first();
$wallet->setSetting('mandate_disabled', 1);
$browser->refresh()
->on(new WalletPage())
->waitFor('#mandate-info')
->assertSeeIn(
'#mandate-info .disabled-mandate',
'The configured auto-payment has been disabled'
)
->assertSeeIn('#mandate-info button.btn-primary', 'Change auto-payment')
->click('#mandate-info button.btn-primary')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Update auto-payment')
->assertSeeIn(
'@body form .disabled-mandate',
'The auto-payment is disabled.'
)
->assertValue('@body #mandate_amount', '100')
->assertValue('@body #mandate_balance', '0')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
// Test error handling
->type('@body #mandate_amount', 'aaa')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #mandate_amount', '50')
->click('@button-action');
})
->waitUntilMissing('#payment-dialog')
->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.')
// make sure the "disabled" text isn't there
->assertMissing('#mandate-info .disabled-mandate')
->click('#mandate-info button.btn-primary')
->assertMissing('form .disabled-mandate')
->click('button.modal-cancel');
});
// Test deleting auto-payment
$this->browse(function (Browser $browser) {
$browser->on(new WalletPage())
->waitFor('#mandate-info')
->assertSeeIn('#mandate-info * button.btn-danger', 'Cancel auto-payment')
->assertVisible('#mandate-info * button.btn-danger')
->click('#mandate-info * button.btn-danger')
->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
->assertVisible('#mandate-form')
->assertMissing('#mandate-info');
});
}
}
diff --git a/src/tests/Browser/Reseller/DistlistTest.php b/src/tests/Browser/Reseller/DistlistTest.php
index b45f8c89..637d8fd6 100644
--- a/src/tests/Browser/Reseller/DistlistTest.php
+++ b/src/tests/Browser/Reseller/DistlistTest.php
@@ -1,128 +1,128 @@
<?php
namespace Tests\Browser\Reseller;
use App\Group;
use Illuminate\Support\Facades\Queue;
use Tests\Browser;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\Distlist as DistlistPage;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
class DistlistTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
$this->deleteTestGroup('group-test@kolab.org');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestGroup('group-test@kolab.org');
parent::tearDown();
}
/**
* Test distlist info page (unauthenticated)
*/
public function testDistlistUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('john@kolab.org');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
$browser->visit('/distlist/' . $group->id)->on(new Home());
});
}
/**
* Test distribution list info page
*/
public function testInfo(): void
{
Queue::fake();
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('john@kolab.org');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
$group->members = ['test1@gmail.com', 'test2@gmail.com'];
$group->save();
$distlist_page = new DistlistPage($group->id);
$user_page = new UserPage($user->id);
// Goto the distlist page
$browser->visit(new Home())
->submitLogon('reseller@kolabnow.com', 'reseller', true)
->on(new Dashboard())
->visit($user_page)
->on($user_page)
->click('@nav #tab-distlists')
->pause(1000)
->click('@user-distlists table tbody tr:first-child td a')
->on($distlist_page)
->assertSeeIn('@distlist-info .card-title', $group->email)
->with('@distlist-info form', function (Browser $browser) use ($group) {
$browser->assertElementsCount('.row', 3)
- ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)')
+ ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #distlistid', "{$group->id} ({$group->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status.text-danger', 'Not Ready')
->assertSeeIn('.row:nth-child(3) label', 'Recipients')
->assertSeeIn('.row:nth-child(3) #members', $group->members[0])
->assertSeeIn('.row:nth-child(3) #members', $group->members[1]);
});
// Test invalid group identifier
$browser->visit('/distlist/abc')->assertErrorPage(404);
});
}
/**
* Test suspending/unsuspending a distribution list
*
* @depends testInfo
*/
public function testSuspendAndUnsuspend(): void
{
Queue::fake();
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('john@kolab.org');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
$group->status = Group::STATUS_ACTIVE | Group::STATUS_LDAP_READY;
$group->save();
$browser->visit(new DistlistPage($group->id))
->assertVisible('@distlist-info #button-suspend')
->assertMissing('@distlist-info #button-unsuspend')
->assertSeeIn('@distlist-info #status.text-success', 'Active')
->click('@distlist-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'Distribution list suspended successfully.')
->assertSeeIn('@distlist-info #status.text-warning', 'Suspended')
->assertMissing('@distlist-info #button-suspend')
->click('@distlist-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'Distribution list unsuspended successfully.')
->assertSeeIn('@distlist-info #status.text-success', 'Active')
->assertVisible('@distlist-info #button-suspend')
->assertMissing('@distlist-info #button-unsuspend');
});
}
}
diff --git a/src/tests/Browser/Reseller/DomainTest.php b/src/tests/Browser/Reseller/DomainTest.php
index e57bcfc9..405456bc 100644
--- a/src/tests/Browser/Reseller/DomainTest.php
+++ b/src/tests/Browser/Reseller/DomainTest.php
@@ -1,120 +1,120 @@
<?php
namespace Tests\Browser\Reseller;
use App\Domain;
use Tests\Browser;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\Domain as DomainPage;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class DomainTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
parent::tearDown();
}
/**
* Test domain info page (unauthenticated)
*/
public function testDomainUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('kolab.org');
$browser->visit('/domain/' . $domain->id)->on(new Home());
});
}
/**
* Test domain info page
*/
public function testDomainInfo(): void
{
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('kolab.org');
$domain_page = new DomainPage($domain->id);
$reseller = $this->getTestUser('reseller@kolabnow.com');
$user = $this->getTestUser('john@kolab.org');
$user_page = new UserPage($user->id);
// Goto the domain page
$browser->visit(new Home())
->submitLogon('reseller@kolabnow.com', 'reseller', true)
->on(new Dashboard())
->visit($user_page)
->on($user_page)
->click('@nav #tab-domains')
->pause(1000)
->click('@user-domains table tbody tr:first-child td a');
$browser->on($domain_page)
->assertSeeIn('@domain-info .card-title', 'kolab.org')
->with('@domain-info form', function (Browser $browser) use ($domain) {
$browser->assertElementsCount('.row', 2)
- ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)')
+ ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 1);
// Assert Configuration tab
$browser->assertSeeIn('@nav #tab-config', 'Configuration')
->with('@domain-config', function (Browser $browser) {
$browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.')
->assertSeeIn('pre#dns-config', 'kolab.org.');
});
});
}
/**
* Test suspending/unsuspending a domain
*
* @depends testDomainInfo
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('domainscontroller.com', [
'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE
| Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED
| Domain::STATUS_VERIFIED,
'type' => Domain::TYPE_EXTERNAL,
]);
$browser->visit(new DomainPage($domain->id))
->assertVisible('@domain-info #button-suspend')
->assertMissing('@domain-info #button-unsuspend')
->click('@domain-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.')
->assertSeeIn('@domain-info #status span.text-warning', 'Suspended')
->assertMissing('@domain-info #button-suspend')
->click('@domain-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.')
->assertSeeIn('@domain-info #status span.text-success', 'Active')
->assertVisible('@domain-info #button-suspend')
->assertMissing('@domain-info #button-unsuspend');
});
}
}
diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php
index 53ce54de..6eb7b4f8 100644
--- a/src/tests/Browser/Reseller/UserTest.php
+++ b/src/tests/Browser/Reseller/UserTest.php
@@ -1,473 +1,473 @@
<?php
namespace Tests\Browser\Reseller;
use App\Auth\SecondFactor;
use App\Discount;
use App\Sku;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testUserUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$browser->visit('/user/' . $jack->id)->on(new Home());
});
}
/**
* Test user info page
*/
public function testUserInfo(): void
{
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$page = new UserPage($jack->id);
$browser->visit(new Home())
->submitLogon('reseller@kolabnow.com', 'reseller', true)
->on(new Dashboard())
->visit($page)
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $jack->email)
->with('@user-info form', function (Browser $browser) use ($jack) {
$browser->assertElementsCount('.row', 7)
->assertSeeIn('.row:nth-child(1) label', 'Managed by')
->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org')
- ->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)')
+ ->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})")
->assertSeeIn('.row:nth-child(3) label', 'Status')
->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active')
- ->assertSeeIn('.row:nth-child(4) label', 'First name')
+ ->assertSeeIn('.row:nth-child(4) label', 'First Name')
->assertSeeIn('.row:nth-child(4) #first_name', 'Jack')
- ->assertSeeIn('.row:nth-child(5) label', 'Last name')
+ ->assertSeeIn('.row:nth-child(5) label', 'Last Name')
->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels')
- ->assertSeeIn('.row:nth-child(6) label', 'External email')
+ ->assertSeeIn('.row:nth-child(6) label', 'External Email')
->assertMissing('.row:nth-child(6) #external_email a')
->assertSeeIn('.row:nth-child(7) label', 'Country')
->assertSeeIn('.row:nth-child(7) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 6);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF')
->assertMissing('table tfoot')
->assertMissing('#reset2fa');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
});
}
/**
* Test user info page (continue)
*
* @depends testUserInfo
*/
public function testUserInfo2(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$page = new UserPage($john->id);
$discount = Discount::where('code', 'TEST')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($john->wallets->first());
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $john->email)
->with('@user-info form', function (Browser $browser) use ($john) {
$ext_email = $john->getSetting('external_email');
$browser->assertElementsCount('.row', 9)
- ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)')
+ ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active')
- ->assertSeeIn('.row:nth-child(3) label', 'First name')
+ ->assertSeeIn('.row:nth-child(3) label', 'First Name')
->assertSeeIn('.row:nth-child(3) #first_name', 'John')
- ->assertSeeIn('.row:nth-child(4) label', 'Last name')
+ ->assertSeeIn('.row:nth-child(4) label', 'Last Name')
->assertSeeIn('.row:nth-child(4) #last_name', 'Doe')
->assertSeeIn('.row:nth-child(5) label', 'Organization')
->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers')
->assertSeeIn('.row:nth-child(6) label', 'Phone')
->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone'))
- ->assertSeeIn('.row:nth-child(7) label', 'External email')
+ ->assertSeeIn('.row:nth-child(7) label', 'External Email')
->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email)
->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email")
->assertSeeIn('.row:nth-child(8) label', 'Address')
->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address'))
->assertSeeIn('.row:nth-child(9) label', 'Country')
->assertSeeIn('.row:nth-child(9) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 6);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (1)')
->click('@nav #tab-domains')
->with('@user-domains table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertMissing('tfoot');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)')
->click('@nav #tab-distlists')
->with('@user-distlists table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'group-test@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger')
->assertMissing('tfoot');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (4)')
->click('@nav #tab-users')
->with('@user-users table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
->assertMissing('tfoot');
});
});
// Now we go to Ned's info page, he's a controller on John's wallet
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
$page = new UserPage($ned->id);
$browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $ned->email)
->with('@user-info form', function (Browser $browser) use ($ned) {
- $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)')
+ $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})");
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 6);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'This user has no email aliases.');
});
// Assert Subscriptions tab, we expect John's discount here
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 5)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,90 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher')
->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth');
});
// We don't expect John's domains here
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// We don't expect John's users here
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// We don't expect John's distribution lists here
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
});
}
/**
* Test editing an external email
*
* @depends testUserInfo2
*/
public function testExternalEmail(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->waitFor('@user-info #external_email button')
->click('@user-info #external_email button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#email-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'External email')
+ $browser->assertSeeIn('@title', 'External Email')
->assertFocused('@body input')
->assertValue('@body input', 'john.doe.external@gmail.com')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#email-dialog')
->click('@user-info #external_email button')
// Test email validation error handling, and email update
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->type('@body input', 'test')
->click('@button-action')
->waitFor('@body input.is-invalid')
->assertSeeIn(
'@body input + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('@body input', 'test@test.com')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->assertSeeIn('@user-info #external_email a', 'test@test.com')
->click('@user-info #external_email button')
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertValue('@body input', 'test@test.com')
->assertMissing('@body input.is-invalid')
->assertMissing('@body input + .invalid-feedback')
->click('@button-cancel');
})
->assertSeeIn('@user-info #external_email a', 'test@test.com');
// $john->getSetting() may not work here as it uses internal cache
// read the value form database
$current_ext_email = $john->settings()->where('key', 'external_email')->first()->value;
$this->assertSame('test@test.com', $current_ext_email);
});
}
/**
* Test suspending/unsuspending the user
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend')
->click('@user-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.')
->assertSeeIn('@user-info #status span.text-warning', 'Suspended')
->assertMissing('@user-info #button-suspend')
->click('@user-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.')
->assertSeeIn('@user-info #status span.text-success', 'Active')
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend');
});
}
/**
* Test resetting 2FA for the user
*/
public function testReset2FA(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
$sku2fa = Sku::firstOrCreate(['title' => '2fa']);
$user->assignSku($sku2fa);
SecondFactor::seed('userstest1@kolabnow.com');
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) {
$browser->waitFor('#reset2fa')
->assertVisible('#sku' . $sku2fa->id);
})
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)')
->click('#reset2fa')
->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', '2-Factor Authentication Reset')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Reset')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.')
->assertMissing('#sku' . $sku2fa->id)
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)');
});
}
}
diff --git a/src/tests/Browser/UserProfileTest.php b/src/tests/Browser/UserProfileTest.php
index 1058c66c..5783f809 100644
--- a/src/tests/Browser/UserProfileTest.php
+++ b/src/tests/Browser/UserProfileTest.php
@@ -1,193 +1,193 @@
<?php
namespace Tests\Browser;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\UserProfile;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserProfileTest extends TestCaseDusk
{
private $profile = [
'first_name' => 'John',
'last_name' => 'Doe',
'currency' => 'USD',
'country' => 'US',
'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005",
'external_email' => 'john.doe.external@gmail.com',
'phone' => '+1 509-248-1111',
'organization' => 'Kolab Developers',
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
User::where('email', 'john@kolab.org')->first()->setSettings($this->profile);
$this->deleteTestUser('profile-delete@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
User::where('email', 'john@kolab.org')->first()->setSettings($this->profile);
$this->deleteTestUser('profile-delete@kolabnow.com');
parent::tearDown();
}
/**
* Test profile page (unauthenticated)
*/
public function testProfileUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/profile')->on(new Home());
});
}
/**
* Test profile page
*/
public function testProfile(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-profile', 'Your profile')
->click('@links .link-profile')
->on(new UserProfile())
->assertSeeIn('#user-profile .button-delete', 'Delete account')
->whenAvailable('@form', function (Browser $browser) {
$user = User::where('email', 'john@kolab.org')->first();
// Assert form content
$browser->assertFocused('div.row:nth-child(2) input')
->assertSeeIn('div.row:nth-child(1) label', 'Customer No.')
->assertSeeIn('div.row:nth-child(1) .form-control-plaintext', $user->id)
- ->assertSeeIn('div.row:nth-child(2) label', 'First name')
+ ->assertSeeIn('div.row:nth-child(2) label', 'First Name')
->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name'])
- ->assertSeeIn('div.row:nth-child(3) label', 'Last name')
+ ->assertSeeIn('div.row:nth-child(3) label', 'Last Name')
->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name'])
->assertSeeIn('div.row:nth-child(4) label', 'Organization')
->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization'])
->assertSeeIn('div.row:nth-child(5) label', 'Phone')
->assertValue('div.row:nth-child(5) input[type=text]', $this->profile['phone'])
- ->assertSeeIn('div.row:nth-child(6) label', 'External email')
+ ->assertSeeIn('div.row:nth-child(6) label', 'External Email')
->assertValue('div.row:nth-child(6) input[type=text]', $this->profile['external_email'])
->assertSeeIn('div.row:nth-child(7) label', 'Address')
->assertValue('div.row:nth-child(7) textarea', $this->profile['billing_address'])
->assertSeeIn('div.row:nth-child(8) label', 'Country')
->assertValue('div.row:nth-child(8) select', $this->profile['country'])
->assertSeeIn('div.row:nth-child(9) label', 'Password')
->assertValue('div.row:nth-child(9) input[type=password]', '')
- ->assertSeeIn('div.row:nth-child(10) label', 'Confirm password')
+ ->assertSeeIn('div.row:nth-child(10) label', 'Confirm Password')
->assertValue('div.row:nth-child(10) input[type=password]', '')
->assertSeeIn('button[type=submit]', 'Submit');
// Test form error handling
$browser->type('#phone', 'aaaaaa')
->type('#external_email', 'bbbbb')
->click('button[type=submit]')
->waitFor('#phone + .invalid-feedback')
->assertSeeIn('#phone + .invalid-feedback', 'The phone format is invalid.')
->assertSeeIn(
'#external_email + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertFocused('#phone')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->clearToasts();
// Clear all fields and submit
// FIXME: Should any of these fields be required?
$browser->vueClear('#first_name')
->vueClear('#last_name')
->vueClear('#organization')
->vueClear('#phone')
->vueClear('#external_email')
->vueClear('#billing_address')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
// On success we're redirected to Dashboard
->on(new Dashboard());
});
}
/**
* Test profile of non-controller user
*/
public function testProfileNonController(): void
{
// Test acting as non-controller
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->visit(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-profile', 'Your profile')
->click('@links .link-profile')
->on(new UserProfile())
->assertMissing('#user-profile .button-delete')
->whenAvailable('@form', function (Browser $browser) {
// TODO: decide on what fields the non-controller user should be able
// to see/change
});
// Test that /profile/delete page is not accessible
$browser->visit('/profile/delete')
->assertErrorPage(403);
});
}
/**
* Test profile delete page
*/
public function testProfileDelete(): void
{
$user = $this->getTestUser('profile-delete@kolabnow.com', ['password' => 'simple123']);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('profile-delete@kolabnow.com', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-profile', 'Your profile')
->click('@links .link-profile')
->on(new UserProfile())
->click('#user-profile .button-delete')
->waitForLocation('/profile/delete')
->assertSeeIn('#user-delete .card-title', 'Delete this account?')
->assertSeeIn('#user-delete .button-cancel', 'Cancel')
->assertSeeIn('#user-delete .card-text', 'This operation is irreversible')
->assertFocused('#user-delete .button-cancel')
->click('#user-delete .button-cancel')
->waitForLocation('/profile')
->on(new UserProfile());
// Test deleting the user
$browser->click('#user-profile .button-delete')
->waitForLocation('/profile/delete')
->click('#user-delete .button-delete')
->waitForLocation('/login')
->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.');
$this->assertTrue($user->fresh()->trashed());
});
}
// TODO: Test that Ned (John's "delegatee") can delete himself
// TODO: Test that Ned (John's "delegatee") can/can't delete John ?
}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
index c877cb23..7a8905c2 100644
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -1,713 +1,713 @@
<?php
namespace Tests\Browser;
use App\Discount;
use App\Entitlement;
use App\Sku;
use App\User;
use App\UserAlias;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\ListInput;
use Tests\Browser\Components\QuotaInput;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\UserInfo;
use Tests\Browser\Pages\UserList;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UsersTest extends TestCaseDusk
{
private $profile = [
'first_name' => 'John',
'last_name' => 'Doe',
'organization' => 'Kolab Developers',
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete();
Entitlement::where('cost', '>=', 5000)->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete();
Entitlement::where('cost', '>=', 5000)->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testInfoUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$user = User::where('email', 'john@kolab.org')->first();
$browser->visit('/user/' . $user->id)->on(new Home());
});
}
/**
* Test users list page (unauthenticated)
*/
public function testListUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/users')->on(new Home());
});
}
/**
* Test users list page
*/
public function testList(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-users', 'User accounts')
->click('@links .link-users')
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org')
->assertMissing('tfoot');
});
});
}
/**
* Test user account editing page (not profile page)
*
* @depends testList
*/
public function testInfo(): void
{
$this->browse(function (Browser $browser) {
$browser->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@form', function (Browser $browser) {
// Assert form content
$browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) #status', 'Active')
->assertFocused('div.row:nth-child(2) input')
- ->assertSeeIn('div.row:nth-child(2) label', 'First name')
+ ->assertSeeIn('div.row:nth-child(2) label', 'First Name')
->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name'])
- ->assertSeeIn('div.row:nth-child(3) label', 'Last name')
+ ->assertSeeIn('div.row:nth-child(3) label', 'Last Name')
->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name'])
->assertSeeIn('div.row:nth-child(4) label', 'Organization')
->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization'])
->assertSeeIn('div.row:nth-child(5) label', 'Email')
->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org')
->assertDisabled('div.row:nth-child(5) input[type=text]')
- ->assertSeeIn('div.row:nth-child(6) label', 'Email aliases')
+ ->assertSeeIn('div.row:nth-child(6) label', 'Email Aliases')
->assertVisible('div.row:nth-child(6) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['john.doe@kolab.org'])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(7) label', 'Password')
->assertValue('div.row:nth-child(7) input[type=password]', '')
- ->assertSeeIn('div.row:nth-child(8) label', 'Confirm password')
+ ->assertSeeIn('div.row:nth-child(8) label', 'Confirm Password')
->assertValue('div.row:nth-child(8) input[type=password]', '')
->assertSeeIn('button[type=submit]', 'Submit')
// Clear some fields and submit
->vueClear('#first_name')
->vueClear('#last_name')
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@form', function (Browser $browser) {
// Test error handling (password)
$browser->type('#password', 'aaaaaa')
->vueClear('#password_confirmation')
->click('button[type=submit]')
->waitFor('#password + .invalid-feedback')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.')
->assertFocused('#password')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
// TODO: Test password change
// Test form error handling (aliases)
$browser->vueClear('#password')
->vueClear('#password_confirmation')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(2, 'The specified alias is invalid.', false);
});
// Test adding aliases
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(2)
->addListEntry('john.test@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$john = User::where('email', 'john@kolab.org')->first();
$alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first();
$this->assertTrue(!empty($alias));
// Test subscriptions
$browser->with('@form', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions')
->assertVisible('@skus.row:nth-child(9)')
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox')
->assertSeeIn('tbody tr:nth-child(1) td.price', '4,44 CHF/month')
->assertChecked('tbody tr:nth-child(1) td.selection input')
->assertDisabled('tbody tr:nth-child(1) td.selection input')
->assertTip(
'tbody tr:nth-child(1) td.buttons button',
'Just a mailbox'
)
// Storage SKU
->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota')
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(2) td.selection input')
->assertDisabled('tbody tr:nth-child(2) td.selection input')
->assertTip(
'tbody tr:nth-child(2) td.buttons button',
'Some wiggle room'
)
->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) {
$browser->assertQuotaValue(2)->setQuotaValue(3);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features')
->assertSeeIn('tbody tr:nth-child(3) td.price', '5,55 CHF/month')
->assertChecked('tbody tr:nth-child(3) td.selection input')
->assertEnabled('tbody tr:nth-child(3) td.selection input')
->assertTip(
'tbody tr:nth-child(3) td.buttons button',
'Groupware functions like Calendar, Tasks, Notes, etc.'
)
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync')
->assertSeeIn('tbody tr:nth-child(4) td.price', '1,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(4) td.selection input')
->assertEnabled('tbody tr:nth-child(4) td.selection input')
->assertTip(
'tbody tr:nth-child(4) td.buttons button',
'Mobile synchronization'
)
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication')
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(5) td.selection input')
->assertEnabled('tbody tr:nth-child(5) td.selection input')
->assertTip(
'tbody tr:nth-child(5) td.buttons button',
'Two factor authentication for webmail and administration panel'
)
// Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(6) td.selection input')
->assertEnabled('tbody tr:nth-child(6) td.selection input')
->assertTip(
'tbody tr:nth-child(6) td.buttons button',
'Video conferencing tool'
)
->click('tbody tr:nth-child(4) td.selection input');
})
->assertMissing('@skus table + .hint')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage'];
$this->assertUserEntitlements($john, $expected);
// Test subscriptions interaction
$browser->with('@form', function (Browser $browser) {
$browser->with('@skus', function ($browser) {
// Uncheck 'groupware', expect activesync unchecked
$browser->click('#sku-input-groupware')
->assertNotChecked('#sku-input-groupware')
->assertNotChecked('#sku-input-activesync')
->assertEnabled('#sku-input-activesync')
->assertNotReadonly('#sku-input-activesync')
// Check 'activesync', expect an alert
->click('#sku-input-activesync')
->assertDialogOpened('Activesync requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-activesync')
// Check 'meet', expect an alert
->click('#sku-input-meet')
->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-meet')
// Check '2FA', expect 'activesync' unchecked and readonly
->click('#sku-input-2fa')
->assertChecked('#sku-input-2fa')
->assertNotChecked('#sku-input-activesync')
->assertReadonly('#sku-input-activesync')
// Uncheck '2FA'
->click('#sku-input-2fa')
->assertNotChecked('#sku-input-2fa')
->assertNotReadonly('#sku-input-activesync');
});
});
});
}
/**
* Test user adding page
*
* @depends testList
*/
public function testNewUser(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->assertSeeIn('button.create-user', 'Create user')
->click('button.create-user')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'New user account')
->with('@form', function (Browser $browser) {
// Assert form content
$browser->assertFocused('div.row:nth-child(1) input')
- ->assertSeeIn('div.row:nth-child(1) label', 'First name')
+ ->assertSeeIn('div.row:nth-child(1) label', 'First Name')
->assertValue('div.row:nth-child(1) input[type=text]', '')
- ->assertSeeIn('div.row:nth-child(2) label', 'Last name')
+ ->assertSeeIn('div.row:nth-child(2) label', 'Last Name')
->assertValue('div.row:nth-child(2) input[type=text]', '')
->assertSeeIn('div.row:nth-child(3) label', 'Organization')
->assertValue('div.row:nth-child(3) input[type=text]', '')
->assertSeeIn('div.row:nth-child(4) label', 'Email')
->assertValue('div.row:nth-child(4) input[type=text]', '')
->assertEnabled('div.row:nth-child(4) input[type=text]')
- ->assertSeeIn('div.row:nth-child(5) label', 'Email aliases')
+ ->assertSeeIn('div.row:nth-child(5) label', 'Email Aliases')
->assertVisible('div.row:nth-child(5) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue([])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(6) label', 'Password')
->assertValue('div.row:nth-child(6) input[type=password]', '')
- ->assertSeeIn('div.row:nth-child(7) label', 'Confirm password')
+ ->assertSeeIn('div.row:nth-child(7) label', 'Confirm Password')
->assertValue('div.row:nth-child(7) input[type=password]', '')
->assertSeeIn('div.row:nth-child(8) label', 'Package')
// assert packages list widget, select "Lite Account"
->with('@packages', function ($browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account')
->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account')
->assertSeeIn('tbody tr:nth-child(1) .price', '9,99 CHF/month')
->assertSeeIn('tbody tr:nth-child(2) .price', '4,44 CHF/month')
->assertChecked('tbody tr:nth-child(1) input')
->click('tbody tr:nth-child(2) input')
->assertNotChecked('tbody tr:nth-child(1) input')
->assertChecked('tbody tr:nth-child(2) input');
})
->assertMissing('@packages table + .hint')
->assertSeeIn('button[type=submit]', 'Submit');
// Test browser-side required fields and error handling
$browser->click('button[type=submit]')
->assertFocused('#email')
->type('#email', 'invalid email')
->click('button[type=submit]')
->assertFocused('#password')
->type('#password', 'simple123')
->click('button[type=submit]')
->assertFocused('#password_confirmation')
->type('#password_confirmation', 'simple')
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.');
});
// Test form error handling (aliases)
$browser->with('@form', function (Browser $browser) {
$browser->type('#email', 'julia.roberts@kolab.org')
->type('#password_confirmation', 'simple123')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(1, 'The specified alias is invalid.', false);
});
});
// Successful account creation
$browser->with('@form', function (Browser $browser) {
$browser->type('#first_name', 'Julia')
->type('#last_name', 'Roberts')
->type('#organization', 'Test Org')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(1)
->addListEntry('julia.roberts2@kolab.org');
})
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.')
// check redirection to users list
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 5)
->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first();
$this->assertTrue(!empty($alias));
$this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage']);
$this->assertSame('Julia', $julia->getSetting('first_name'));
$this->assertSame('Roberts', $julia->getSetting('last_name'));
$this->assertSame('Test Org', $julia->getSetting('organization'));
// Some additional tests for the list input widget
$browser->click('tbody tr:nth-child(4) a')
->on(new UserInfo())
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['julia.roberts2@kolab.org'])
->addListEntry('invalid address')
->type('.input-group:nth-child(2) input', '@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertVisible('.input-group:nth-child(2) input.is-invalid')
->assertVisible('.input-group:nth-child(3) input.is-invalid')
->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org')
->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all();
$this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases);
});
}
/**
* Test user delete
*
* @depends testNewUser
*/
public function testDeleteUser(): void
{
// First create a new user
$john = $this->getTestUser('john@kolab.org');
$julia = $this->getTestUser('julia.roberts@kolab.org');
$package_kolab = \App\Package::where('title', 'kolab')->first();
$john->assignPackage($package_kolab, $julia);
// Test deleting non-controller user
$this->browse(function (Browser $browser) use ($julia) {
$browser->visit('/user/' . $julia->id)
->on(new UserInfo())
->assertSeeIn('button.button-delete', 'Delete user')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org')
->assertFocused('@button-cancel')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Delete')
->click('@button-cancel');
})
->waitUntilMissing('#delete-warning')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->click('@button-action');
})
->waitUntilMissing('#delete-warning')
->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.')
->on(new UserList())
->with('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$this->assertTrue(empty($julia));
});
// Test that non-controller user cannot see/delete himself on the users list
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->visit('/users')
->assertErrorPage(403);
});
// Test that controller user (Ned) can see all the users
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('ned@kolab.org', 'simple123', true)
->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4);
});
// TODO: Test the delete action in details
});
// TODO: Test what happens with the logged in user session after he's been deleted by another user
}
/**
* Test discounted sku/package prices in the UI
*/
public function testDiscountedPrices(): void
{
// Add 10% discount
$discount = Discount::where('code', 'TEST')->first();
$john = User::where('email', 'john@kolab.org')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->save();
// SKUs on user edit page
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->visit(new UserList())
->waitFor('@table tr:nth-child(2)')
->click('@table tr:nth-child(2) a') // joe@kolab.org
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '3,99 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(100);
})
->assertSeeIn('tr:nth-child(2) td.price', '22,05 CHF/month¹')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,99 CHF/month¹')
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,90 CHF/month¹')
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Packages on new user page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->click('button.create-user')
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@packages', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) .price', '8,99 CHF/month¹') // Groupware
->assertSeeIn('tbody tr:nth-child(2) .price', '3,99 CHF/month¹'); // Lite
})
->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Test using entitlement cost instead of the SKU cost
$this->browse(function (Browser $browser) use ($wallet) {
$joe = User::where('email', 'joe@kolab.org')->first();
$beta_sku = Sku::where('title', 'beta')->first();
$storage_sku = Sku::where('title', 'storage')->first();
// Add an extra storage and beta entitlement with different prices
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $beta_sku->id,
'cost' => 5010,
'entitleable_id' => $joe->id,
'entitleable_type' => User::class
]);
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $storage_sku->id,
'cost' => 5000,
'entitleable_id' => $joe->id,
'entitleable_type' => User::class
]);
$browser->visit('/user/' . $joe->id)
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
// Beta SKU
->assertSeeIn('tbody tr:nth-child(7) td.price', '45,09 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(4);
})
->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(2);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
}
/**
* Test beta entitlements
*
* @depends testList
*/
public function testBetaEntitlements(): void
{
$this->browse(function (Browser $browser) {
$john = User::where('email', 'john@kolab.org')->first();
$sku = Sku::where('title', 'beta')->first();
$john->assignSku($sku);
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 8)
// Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(6) td.selection input')
->assertEnabled('tbody tr:nth-child(6) td.selection input')
->assertTip(
'tbody tr:nth-child(6) td.buttons button',
'Video conferencing tool'
)
// Beta SKU
->assertSeeIn('tbody tr:nth-child(7) td.name', 'Private Beta (invitation only)')
->assertSeeIn('tbody tr:nth-child(7) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(7) td.selection input')
->assertEnabled('tbody tr:nth-child(7) td.selection input')
->assertTip(
'tbody tr:nth-child(7) td.buttons button',
'Access to the private beta program subscriptions'
)
// Distlist SKU
->assertSeeIn('tbody tr:nth-child(8) td.name', 'Distribution lists')
->assertSeeIn('tr:nth-child(8) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(8) td.selection input')
->assertEnabled('tbody tr:nth-child(8) td.selection input')
->assertTip(
'tbody tr:nth-child(8) td.buttons button',
'Access to mail distribution lists'
)
// Check Distlist, Uncheck Beta, expect Distlist unchecked
->click('#sku-input-distlist')
->click('#sku-input-beta')
->assertNotChecked('#sku-input-beta')
->assertNotChecked('#sku-input-distlist')
// Click Distlist expect an alert
->click('#sku-input-distlist')
->assertDialogOpened('Distribution lists requires Private Beta (invitation only).')
->acceptDialog()
// Enable Beta and Distlist and submit
->click('#sku-input-beta')
->click('#sku-input-distlist');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$expected = ['beta', 'distlist', 'groupware', 'mailbox', 'storage', 'storage'];
$this->assertUserEntitlements($john, $expected);
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->click('#sku-input-beta')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$expected = ['groupware', 'mailbox', 'storage', 'storage'];
$this->assertUserEntitlements($john, $expected);
});
// TODO: Test that the Distlist SKU is not available for users that aren't a group account owners
// TODO: Test that entitlements change has immediate effect on the available items in dashboard
// i.e. does not require a page reload nor re-login.
}
}
diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php
index e327f1a2..8b8e84bf 100644
--- a/src/tests/Feature/Controller/WalletsTest.php
+++ b/src/tests/Feature/Controller/WalletsTest.php
@@ -1,353 +1,356 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\WalletsController;
use App\Payment;
use App\Providers\PaymentProvider;
use App\Transaction;
use Carbon\Carbon;
use Tests\TestCase;
class WalletsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('wallets-controller@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('wallets-controller@kolabnow.com');
parent::tearDown();
}
/**
* Test for getWalletNotice() method
*/
public function testGetWalletNotice(): void
{
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$package = \App\Package::where('title', 'kolab')->first();
$user->assignPackage($package);
$wallet = $user->wallets()->first();
$controller = new WalletsController();
$method = new \ReflectionMethod($controller, 'getWalletNotice');
$method->setAccessible(true);
// User/entitlements created today, balance=0
$notice = $method->invoke($controller, $wallet);
$this->assertSame('You are in your free trial period.', $notice);
$wallet->owner->created_at = Carbon::now()->subDays(15);
$wallet->owner->save();
$notice = $method->invoke($controller, $wallet);
$this->assertSame('Your free trial is about to end, top up to continue.', $notice);
// User/entitlements created today, balance=-10 CHF
$wallet->balance = -1000;
$notice = $method->invoke($controller, $wallet);
$this->assertSame('You are out of credit, top up your balance now.', $notice);
// User/entitlements created slightly more than a month ago, balance=9,99 CHF (monthly)
$wallet->owner->created_at = Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1);
$wallet->owner->save();
// test "1 month"
$wallet->balance = 999;
$notice = $method->invoke($controller, $wallet);
$this->assertRegExp('/\((1 month|4 weeks)\)/', $notice);
// test "2 months"
$wallet->balance = 999 * 2.6;
$notice = $method->invoke($controller, $wallet);
$this->assertRegExp('/\(2 months 2 weeks\)/', $notice);
+ // Change locale to make sure the text is localized by Carbon
+ \app()->setLocale('de');
+
// test "almost 2 years"
$wallet->balance = 999 * 23.5;
$notice = $method->invoke($controller, $wallet);
- $this->assertRegExp('/\(1 year 11 months\)/', $notice);
+ $this->assertRegExp('/\(1 Jahr 11 Monate\)/', $notice);
// Old entitlements, 100% discount
$this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40));
$discount = \App\Discount::where('discount', 100)->first();
$wallet->discount()->associate($discount);
$notice = $method->invoke($controller, $wallet->refresh());
$this->assertSame(null, $notice);
}
/**
* Test fetching pdf receipt
*/
public function testReceiptDownload(): void
{
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$john = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Unauth access not allowed
$response = $this->get("api/v4/wallets/{$wallet->id}/receipts/2020-05");
$response->assertStatus(401);
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts/2020-05");
$response->assertStatus(403);
// Invalid receipt id (current month)
$receiptId = date('Y-m');
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}");
$response->assertStatus(404);
// Invalid receipt id
$receiptId = '1000-03';
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}");
$response->assertStatus(404);
// Valid receipt id
$year = intval(date('Y')) - 1;
$receiptId = "$year-12";
$filename = \config('app.name') . " Receipt for $year-12";
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}");
$response->assertStatus(200);
$response->assertHeader('content-type', 'application/pdf');
$response->assertHeader('content-disposition', 'attachment; filename="' . $filename . '"');
$response->assertHeader('content-length');
$length = $response->headers->get('content-length');
$content = $response->content();
$this->assertStringStartsWith("%PDF-1.", $content);
$this->assertEquals(strlen($content), $length);
}
/**
* Test fetching list of receipts
*/
public function testReceipts(): void
{
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$john = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->payments()->delete();
// Unauth access not allowed
$response = $this->get("api/v4/wallets/{$wallet->id}/receipts");
$response->assertStatus(401);
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts");
$response->assertStatus(403);
// Empty list expected
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame([], $json['list']);
$this->assertSame(1, $json['page']);
$this->assertSame(0, $json['count']);
$this->assertSame(false, $json['hasMore']);
// Insert a payment to the database
$date = Carbon::create(intval(date('Y')) - 1, 4, 30);
$payment = Payment::create([
'id' => 'AAA1',
'status' => PaymentProvider::STATUS_PAID,
'type' => PaymentProvider::TYPE_ONEOFF,
'description' => 'Paid in April',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
'currency' => 'CHF',
'currency_amount' => 1111,
]);
$payment->updated_at = $date;
$payment->save();
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame([$date->format('Y-m')], $json['list']);
$this->assertSame(1, $json['page']);
$this->assertSame(1, $json['count']);
$this->assertSame(false, $json['hasMore']);
}
/**
* Test fetching a wallet (GET /api/v4/wallets/:id)
*/
public function testShow(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$wallet = $john->wallets()->first();
$wallet->balance = -100;
$wallet->save();
// Accessing a wallet of someone else
$response = $this->actingAs($jack)->get("api/v4/wallets/{$wallet->id}");
$response->assertStatus(403);
// Accessing non-existing wallet
$response = $this->actingAs($jack)->get("api/v4/wallets/aaa");
$response->assertStatus(404);
// Wallet owner
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($wallet->id, $json['id']);
$this->assertSame('CHF', $json['currency']);
$this->assertSame($wallet->balance, $json['balance']);
$this->assertTrue(empty($json['description']));
$this->assertTrue(!empty($json['notice']));
}
/**
* Test fetching wallet transactions
*/
public function testTransactions(): void
{
$package_kolab = \App\Package::where('title', 'kolab')->first();
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$user->assignPackage($package_kolab);
$john = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Unauth access not allowed
$response = $this->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(401);
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(403);
// Expect empty list
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame([], $json['list']);
$this->assertSame(1, $json['page']);
$this->assertSame(0, $json['count']);
$this->assertSame(false, $json['hasMore']);
// Create some sample transactions
$transactions = $this->createTestTransactions($wallet);
$transactions = array_reverse($transactions);
$pages = array_chunk($transactions, 10 /* page size*/);
// Get the first page
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(1, $json['page']);
$this->assertSame(10, $json['count']);
$this->assertSame(true, $json['hasMore']);
$this->assertCount(10, $json['list']);
foreach ($pages[0] as $idx => $transaction) {
$this->assertSame($transaction->id, $json['list'][$idx]['id']);
$this->assertSame($transaction->type, $json['list'][$idx]['type']);
$this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']);
$this->assertFalse($json['list'][$idx]['hasDetails']);
$this->assertFalse(array_key_exists('user', $json['list'][$idx]));
}
$search = null;
// Get the second page
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=2");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(2, $json['page']);
$this->assertSame(2, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(2, $json['list']);
foreach ($pages[1] as $idx => $transaction) {
$this->assertSame($transaction->id, $json['list'][$idx]['id']);
$this->assertSame($transaction->type, $json['list'][$idx]['type']);
$this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']);
$this->assertSame(
$transaction->type == Transaction::WALLET_DEBIT,
$json['list'][$idx]['hasDetails']
);
$this->assertFalse(array_key_exists('user', $json['list'][$idx]));
if ($transaction->type == Transaction::WALLET_DEBIT) {
$search = $transaction->id;
}
}
// Get a non-existing page
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(3, $json['page']);
$this->assertSame(0, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(0, $json['list']);
// Sub-transaction searching
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123");
$response->assertStatus(404);
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(1, $json['page']);
$this->assertSame(2, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(2, $json['list']);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']);
// Test that John gets 404 if he tries to access
// someone else's transaction ID on his wallet's endpoint
$wallet = $john->wallets()->first();
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}");
$response->assertStatus(404);
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Jan 31, 8:55 PM (1 d, 10 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426481
Default Alt Text
(467 KB)

Event Timeline