diff --git a/src/phpunit.xml b/src/phpunit.xml index 7a316a86..6edec58c 100644 --- a/src/phpunit.xml +++ b/src/phpunit.xml @@ -1,64 +1,47 @@ <?xml version="1.0" encoding="UTF-8"?> <phpunit backupGlobals="false" backupStaticAttributes="false" bootstrap="vendor/autoload.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false"> <testsuites> <testsuite name="Unit"> <directory suffix="Test.php">tests/Unit</directory> </testsuite> <testsuite name="Functional"> <directory suffix="Test.php">tests/Functional</directory> </testsuite> <testsuite name="Feature"> <directory suffix="Test.php">tests/Feature</directory> </testsuite> <testsuite name="Browser"> <directory suffix="Test.php">tests/Browser</directory> - <exclude>tests/Browser/Reseller/DashboardTest.php</exclude> - <exclude>tests/Browser/Reseller/DistlistTest.php</exclude> - <exclude>tests/Browser/Reseller/DomainTest.php</exclude> - <exclude>tests/Browser/Reseller/InvitationsTest.php</exclude> - <exclude>tests/Browser/Reseller/LogonTest.php</exclude> - <exclude>tests/Browser/Reseller/PaymentMollieTest.php</exclude> - <exclude>tests/Browser/Reseller/ResourceTest.php</exclude> - <exclude>tests/Browser/Reseller/SharedFolderTest.php</exclude> - <exclude>tests/Browser/Reseller/StatsTest.php</exclude> - <exclude>tests/Browser/Reseller/UserTest.php</exclude> - <exclude>tests/Browser/Reseller/WalletTest.php</exclude> - <exclude>tests/Browser/Reseller/UserFinancesTest.php</exclude> - <exclude>tests/Browser/LogonTest.php</exclude> - <exclude>tests/Browser/SignupTest.php</exclude> <exclude>tests/Browser/PaymentStripeTest.php</exclude> - <exclude>tests/Browser/Meet/RoomSetupTest.php</exclude> - <exclude>tests/Browser/Meet/RoomControlsTest.php</exclude> - <exclude>tests/Browser/Meet/RoomModeratorTest.php</exclude> </testsuite> </testsuites> <coverage processUncoveredFiles="true"> <include> <directory suffix=".php">./app</directory> </include> </coverage> <logging> <testdoxHtml outputFile="./tests/report/testdox.html" /> </logging> <php> <server name="APP_ENV" value="testing"/> <server name="APP_DEBUG" value="true"/> <server name="BCRYPT_ROUNDS" value="4"/> <server name="MAIL_MAILER" value="array"/> <server name="QUEUE_CONNECTION" value="sync"/> <server name="SESSION_DRIVER" value="array"/> <server name="SWOOLE_HTTP_ACCESS_LOG" value="false"/> <server name="PGP_LENGTH" value="1024"/> </php> </phpunit> diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 390d542f..4e857ba0 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,455 +1,454 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import SupportForm from '../vue/Widgets/SupportForm' -import { Tab } from 'bootstrap' import { loadLangAsync, i18n } from './locale' import { clearFormValidation, pick, startLoading, stopLoading } from './utils' const routerState = { afterLogin: null, isLoggedIn: !!localStorage.getItem('token') } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.meta.requiresAuth && !routerState.isLoggedIn) { // remember the original request, to use after login routerState.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() $('body').css('padding', 0) // remove padding added by unclosed modal // Close the mobile menu if ($('#header-menu .navbar-collapse.show').length) { $('#header-menu .navbar-toggler').click(); } }) const app = new Vue({ components: { AppComponent, MenuComponent, }, i18n, router: window.router, data() { return { authInfo: null, isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] } }, methods: { clearFormValidation, hasPermission(type) { const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) return !!(this.authInfo && this.authInfo.statusInfo[key]) }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { return this.authInfo.statusInfo.skus && this.authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && this.authInfo) { let i for (i = 0; i < this.authInfo.wallets.length; i++) { if (wallet_id == this.authInfo.wallets[i].id) { return true } } for (i = 0; i < this.authInfo.accounts.length; i++) { if (wallet_id == this.authInfo.accounts[i].id) { return true } } } return false }, isDegraded() { return this.authInfo && this.authInfo.isAccountDegraded }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { routerState.isLoggedIn = true this.authInfo = null } localStorage.setItem('token', response.access_token) localStorage.setItem('refreshToken', response.refresh_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { this.authInfo = response } if (dashboard !== false) { this.$router.push(routerState.afterLogin || { name: 'dashboard' }) } routerState.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('api/auth/refresh', { refresh_token: response.refresh_token }).then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { routerState.isLoggedIn = true this.authInfo = null localStorage.setItem('token', '') localStorage.setItem('refreshToken', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, logo(mode) { let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' return `<img src="${src}" alt="${this.appName}">` }, pick, startLoading, stopLoading, - tab(e) { - e.preventDefault() - new Tab(e.target).show() - }, errorPage(code, msg, hint) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown') if (!hint) hint = '' const error_page = '<div id="error-page" class="error-page">' + `<div class="code">${code}</div><div class="message">${msg}</div><div class="hint">${hint}</div>` + '</div>' $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { stopLoading() const status = error.response ? error.response.status : 500 const message = error.response ? error.response.statusText : '' if (status == 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { routerState.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { + if (!error.response) { + console.error(error) + } + this.errorPage(status, message) } }, price(price, currency) { // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, discount, currency) { let index = '' if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost, currency) + '/' + this.$t('wallet.month') + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { $(event.target).closest('tr').find('a').trigger('click') } }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, supportDialog(container) { let dialog = $('#support-dialog')[0] if (!dialog) { // FIXME: Find a nicer way of doing this SupportForm.i18n = i18n let form = new Vue(SupportForm) form.$mount($('<div>').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = form.$el } dialog.__vue__.show() }, statusClass(obj) { if (obj.isDeleted) { return 'text-muted' } if (obj.isDegraded || obj.isAccountDegraded || obj.isSuspended) { return 'text-warning' } if (obj.isImapReady === false || obj.isLdapReady === false || obj.isVerified === false || obj.isConfirmed === false) { return 'text-danger' } return 'text-success' }, statusText(obj) { if (obj.isDeleted) { return this.$t('status.deleted') } if (obj.isDegraded || obj.isAccountDegraded) { return this.$t('status.degraded') } if (obj.isSuspended) { return this.$t('status.suspended') } if (obj.isImapReady === false || obj.isLdapReady === false || obj.isVerified === false || obj.isConfirmed === false) { return this.$t('status.notready') } return this.$t('status.active') }, // Append some wallet properties to the object userWalletProps(object) { let wallet = this.authInfo.accounts[0] if (!wallet) { wallet = this.authInfo.wallets[0] } if (wallet) { object.currency = wallet.currency if (wallet.discount) { object.discount = wallet.discount object.discount_description = wallet.discount_description } } }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') } } }) // Fetch the locale file and the start the app loadLangAsync().then(() => app.$mount('#app')) // Add a axios request interceptor axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider let loader = config.loader if (loader) { startLoading(loader) } return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } let loader = response.config.loader if (loader) { stopLoading(loader) } return response }, error => { let loader = error.config.loader if (loader) { stopLoading(loader) } // Do not display the error in a toast message, pass the error as-is if (axios.isCancel(error) || error.config.ignoreErrors) { return Promise.reject(error) } if (error.config.onFinish) { error.config.onFinish() } let error_msg const status = error.response ? error.response.status : 200 const data = error.response ? error.response.data : {} if (status == 422 && data.errors) { error_msg = app.$t('error.form') const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(data.errors, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message // API responses can use a string, array or object let msg_text = '' if (typeof(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('<div class="invalid-feedback">').text(msg_text) if (input.is('.list-input')) { // List input widget let controls = input.children(':not(:first-child)') if (!controls.length && typeof msg == 'string') { // this is an empty list (the main input only) // and the error message is not an array input.find('.main-input').addClass('is-invalid') } else { controls.each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) } input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // a special case, e.g. the invitation policy widget if (input.is('select') && input.parent().is('.input-group-select.selected')) { input = input.next() } // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.list-input)').first().focus() }) } else if (data.status == 'error') { error_msg = data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || app.$t('error.server')) // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/js/bootstrap.js b/src/resources/js/bootstrap.js index a4436d72..0647aa15 100644 --- a/src/resources/js/bootstrap.js +++ b/src/resources/js/bootstrap.js @@ -1,105 +1,106 @@ /** * Import Cash (jQuery replacement) */ import $ from 'cash-dom' window.$ = $ $.fn.focus = function() { if (this.length && this[0].focus) { this[0].focus() } return this } $.fn.click = function() { if (this.length && this[0].click) { this[0].click() } return this } /** * Load Vue, VueRouter and global components */ import Vue from 'vue' import VueRouter from 'vue-router' import Btn from '../vue/Widgets/Btn' import BtnRouter from '../vue/Widgets/BtnRouter' +import Tabs from '../vue/Widgets/Tabs' import Toast from '../vue/Widgets/Toast' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { Tooltip } from 'bootstrap' window.Vue = Vue -Vue.component('SvgIcon', FontAwesomeIcon) Vue.component('Btn', Btn) Vue.component('BtnRouter', BtnRouter) +Vue.component('SvgIcon', FontAwesomeIcon) +Vue.component('Tabs', Tabs) const vTooltip = (el, binding) => { let t = [] if (binding.modifiers.focus) t.push('focus') if (binding.modifiers.hover) t.push('hover') if (binding.modifiers.click) t.push('click') if (!t.length) t.push('click') el.tooltip = new Tooltip(el, { title: binding.value, placement: binding.arg || 'top', trigger: t.join(' '), html: !!binding.modifiers.html }) } Vue.directive('tooltip', { bind: vTooltip, update: vTooltip, unbind (el) { el.tooltip.dispose() } }) Vue.use(Toast) - Vue.use(VueRouter) let vueRouterBase = '/' try { let url = new URL(window.config['app.url']) vueRouterBase = url.pathname } catch(e) { // ignore } window.router = new VueRouter({ base: vueRouterBase, mode: 'history', routes: window.routes, scrollBehavior (to, from, savedPosition) { // Scroll the page to top, but not on Back action return savedPosition || { x: 0, y: 0 } } }) /** * Load the axios HTTP library which allows us to easily issue requests * to our Laravel back-end. This library automatically handles sending the * CSRF token as a header based on the value of the "XSRF" token cookie. */ window.axios = require('axios') axios.defaults.baseURL = vueRouterBase axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' // Register a few most common icons import { library } from '@fortawesome/fontawesome-svg-core' library.add( require('@fortawesome/free-solid-svg-icons/faCheck').definition, require('@fortawesome/free-solid-svg-icons/faCircleInfo').definition, require('@fortawesome/free-solid-svg-icons/faPlus').definition, require('@fortawesome/free-solid-svg-icons/faMagnifyingGlass').definition, require('@fortawesome/free-solid-svg-icons/faTrashCan').definition, require('@fortawesome/free-solid-svg-icons/faUser').definition, ) diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 2784bf13..0cb827ad 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,534 +1,534 @@ <?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 [ 'app' => [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'copy' => "Copy", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", 'share' => "Share", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'companion' => [ 'title' => "Companion App", 'name' => "Name", 'description' => "Use the Companion App on your mobile phone for advanced two factor authentication.", 'pair-new' => "Pair new device", 'paired' => "Paired devices", 'pairing-instructions' => "Pair a new device using the following QR-Code:", 'deviceid' => "Device ID", 'list-empty' => "There are currently no devices", 'delete' => "Remove devices", 'remove-devices' => "Remove Devices", 'remove-devices-text' => "Do you really want to remove all devices permanently?" . " Please note that this action cannot be undone, and you can only remove all devices together." . " You may pair devices you would like to keep individually again.", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'companion' => "Companion app", 'domains' => "Domains", 'files' => "Files", 'invitations' => "Invitations", 'profile' => "Your profile", 'resources' => "Resources", 'settings' => "Settings", 'shared-folders' => "Shared folders", '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.", 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", 'sender-policy-text' => "With this list you can specify who can send mail to the distribution list." . " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to." . " If the list is empty, mail from anyone is allowed.", ], 'domain' => [ 'delete' => "Delete domain", 'delete-domain' => "Delete {domain}", 'delete-text' => "Do you really want to delete this domain permanently?" . " This is only possible if there are no users, aliases or other objects in this domain." . " Please note that this action cannot be undone.", 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'list-empty' => "There are no domains in this account.", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: <var>.ess.barracuda.com</var>.", '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.", 'create' => "Create domain", 'new' => "New domain", ], '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' => "Form validation error", ], 'file' => [ 'create' => "Create file", 'delete' => "Delete file", 'list-empty' => "There are no files in this account.", 'mimetype' => "Mimetype", 'mtime' => "Modified", 'new' => "New file", 'search' => "File name", 'sharing' => "Sharing", 'sharing-links-text' => "You can share the file with other users by giving them read-only access " . "to the file via a unique link.", ], 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", 'acl-read-only' => "Read-only", 'acl-read-write' => "Read-write", 'amount' => "Amount", 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'emails' => "Email Addresses", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", 'name' => "Name", 'months' => "months", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", 'shared-folder' => "Shared Folder", 'size' => "Size", 'status' => "Status", 'surname' => "Surname", 'type' => "Type", '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.", 'list-empty' => "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-title' => "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 & 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", 'uploading' => "Uploading...", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'link-invalid' => "The password reset code is expired or invalid.", '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.", ], 'resource' => [ 'create' => "Create resource", 'delete' => "Delete resource", 'invitation-policy' => "Invitation policy", 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" . " if there is no conflicting event on the requested time slot. Invitation policy allows" . " for rejecting such requests or to require a manual acceptance from a specified user.", 'ipolicy-manual' => "Manual (tentative)", 'ipolicy-accept' => "Accept", 'ipolicy-reject' => "Reject", 'list-title' => "Resource | Resources", 'list-empty' => "There are no resources in this account.", 'new' => "New resource", ], 'settings' => [ 'password-policy' => "Password Policy", 'password-retention' => "Password Retention", 'password-max-age' => "Require a password change every", ], 'shf' => [ 'aliases-none' => "This shared folder has no email aliases.", 'create' => "Create folder", 'delete' => "Delete folder", 'acl-text' => "Defines user permissions to access the shared folder.", 'list-title' => "Shared folder | Shared folders", 'list-empty' => "There are no shared folders in this account.", 'new' => "New shared folder", 'type-mail' => "Mail", 'type-event' => "Calendar", 'type-contact' => "Address Book", 'type-task' => "Tasks", 'type-note' => "Notes", 'type-file' => "Files", ], '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-resource' => "We are preparing the resource.", 'prepare-shared-folder' => "We are preparing the shared folder.", '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-resource' => "The resource is almost ready.", 'ready-shared-folder' => "The shared-folder is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'degraded' => "Degraded", '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.", 'add-beta' => "Enable beta program", '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}</b> when under <b>{balance}</b> using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'degraded-warning' => "The account is degraded. Some features have been disabled.", 'degraded-hint' => "Please, make a payment.", 'delete' => "Delete user", 'delete-account' => "Delete this account?", '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", 'domains' => "Domains", 'ext-email' => "External Email", + 'email-aliases' => "Email Aliases", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'list-title' => "User accounts", 'list-empty' => "There are no users in this account.", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'pass-input' => "Enter password", 'pass-link' => "Set via link", 'pass-link-label' => "Link:", 'pass-link-hint' => "Press Submit to activate the link", 'passwordpolicy' => "Password Policy", '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", 'resources' => "Resources", 'title' => "User account", 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", ], '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}</b> every time your account balance gets under <b>{balance}</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", 'month' => "month", '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/lang/fr/ui.php b/src/resources/lang/fr/ui.php index 74ba9a51..2a6db372 100644 --- a/src/resources/lang/fr/ui.php +++ b/src/resources/lang/fr/ui.php @@ -1,482 +1,482 @@ <?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 [ 'app' => [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Ajouter", 'accept' => "Accepter", 'back' => "Back", 'cancel' => "Annuler", 'close' => "Fermer", 'continue' => "Continuer", 'delete' => "Supprimer", 'deny' => "Refuser", 'download' => "Télécharger", 'edit' => "Modifier", 'file' => "Choisir le ficher...", 'moreinfo' => "Plus d'information", 'refresh' => "Actualiser", 'reset' => "Réinitialiser", 'resend' => "Envoyer à nouveau", 'save' => "Sauvegarder", 'search' => "Chercher", 'signup' => "S'inscrire", 'submit' => "Soumettre", 'suspend' => "Suspendre", 'unsuspend' => "Débloquer", 'verify' => "Vérifier", ], 'dashboard' => [ 'beta' => "bêta", 'distlists' => "Listes de distribution", 'chat' => "Chat Vidéo", 'domains' => "Domaines", 'invitations' => "Invitations", 'profile' => "Votre profil", 'resources' => "Ressources", 'users' => "D'utilisateurs", 'wallet' => "Portefeuille", 'webmail' => "Webmail", 'stats' => "Statistiques", ], 'distlist' => [ 'list-title' => "Liste de distribution | Listes de Distribution", 'create' => "Créer une liste", 'delete' => "Suprimmer une list", 'email' => "Courriel", 'list-empty' => "il n'y a pas de listes de distribution dans ce compte.", 'name' => "Nom", 'new' => "Nouvelle liste de distribution", 'recipients' => "Destinataires", 'sender-policy' => "Liste d'Accès d'Expéditeur", 'sender-policy-text' => "Cette liste vous permet de spécifier qui peut envoyer du courrier à la liste de distribution." . " Vous pouvez mettre une adresse e-mail complète (jane@kolab.org), un domaine (kolab.org) ou un suffixe (.org)" . " auquel l'adresse électronique de l'expéditeur est assimilée." . " Si la liste est vide, le courriels de quiconque est autorisé." ], 'domain' => [ 'dns-verify' => "Exemple de vérification du DNS d'un domaine:", 'dns-config' => "Exemple de configuration du DNS d'un domaine:", 'list-empty' => "Il y a pas de domaines dans ce compte.", 'namespace' => "Espace de noms", 'verify' => "Vérification du domaine", 'verify-intro' => "Afin de confirmer que vous êtes bien le titulaire du domaine, nous devons exécuter un processus de vérification avant de l'activer définitivement pour la livraison d'e-mails.", 'verify-dns' => "Le domaine <b>doit avoir l'une des entrées suivantes</b> dans le DNS:", 'verify-dns-txt' => "Entrée TXT avec valeur:", 'verify-dns-cname' => "ou entrée CNAME:", 'verify-outro' => "Lorsque cela est fait, appuyez sur le bouton ci-dessous pour lancer la vérification.", 'verify-sample' => "Voici un fichier de zone simple pour votre domaine:", 'config' => "Configuration du domaine", 'config-intro' => "Afin de permettre à {app} de recevoir le trafic de messagerie pour votre domaine, vous devez ajuster les paramètres DNS, plus précisément les entrées MX, en conséquence.", 'config-sample' => "Modifiez le fichier de zone de votre domaine et remplacez les entrées MX existantes par les valeurs suivantes:", 'config-hint' => "Si vous ne savez pas comment définir les entrées DNS pour votre domaine, veuillez contacter le service d'enregistrement auprès duquel vous avez enregistré le domaine ou votre fournisseur d'hébergement Web.", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "Le Sender Policy Framework permet à un domaine expéditeur de dévoiler, par le biais de DNS," . " quels systèmes sont autorisés à envoyer des e-mails avec une adresse d'expéditeur d'enveloppe dans le domaine en question.", 'spf-whitelist-ex' => "Vous pouvez ici spécifier une liste de serveurs autorisés, par exemple: <var>.ess.barracuda.com</var>.", 'create' => "Créer domaine", 'new' => "Nouveau domaine", 'delete' => "Supprimer domaine", 'delete-domain' => "Supprimer {domain}", 'delete-text' => "Voulez-vous vraiment supprimer ce domaine de façon permanente?" . " Ceci n'est possible que s'il n'y a pas d'utilisateurs, d'alias ou d'autres objets dans ce domaine." . " Veuillez noter que cette action ne peut pas être inversée.", ], 'error' => [ '400' => "Mauvaide demande", '401' => "Non autorisé", '403' => "Accès refusé", '404' => "Pas trouvé", '405' => "Méthode non autorisée", '500' => "Erreur de serveur interne", 'unknown' => "Erreur inconnu", 'server' => "Erreur de serveur", 'form' => "Erreur de validation du formulaire", ], 'form' => [ 'acl' => "Droits d'accès", 'acl-full' => "Tout", 'acl-read-only' => "Lecture seulement", 'acl-read-write' => "Lecture-écriture", 'amount' => "Montant", 'anyone' => "Chacun", 'code' => "Le code de confirmation", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Détails", 'domain' => "Domaine", 'email' => "Adresse e-mail", 'firstname' => "Prénom", 'lastname' => "Nom de famille", 'none' => "aucun", 'or' => "ou", 'password' => "Mot de passe", 'password-confirm' => "Confirmer le mot de passe", 'phone' => "Téléphone", 'shared-folder' => "Dossier partagé", 'status' => "État", 'surname' => "Nom de famille", 'type' => "Type", 'user' => "Utilisateur", 'primary-email' => "Email principal", 'id' => "ID", 'created' => "Créé", 'deleted' => "Supprimé", 'disabled' => "Désactivé", 'enabled' => "Activé", 'general' => "Général", 'settings' => "Paramètres", ], 'invitation' => [ 'create' => "Créez des invitation(s)", 'create-title' => "Invitation à une inscription", 'create-email' => "Saisissez l'adresse électronique de la personne que vous souhaitez inviter.", 'create-csv' => "Pour envoyer plusieurs invitations à la fois, fournissez un fichier CSV (séparé par des virgules) ou un fichier en texte brut, contenant une adresse e-mail par ligne.", 'list-empty' => "Il y a aucune invitation dans la mémoire de données.", 'title' => "Invitation d'inscription", 'search' => "Adresse E-mail ou domaine", 'send' => "Envoyer invitation(s)", 'status-completed' => "Utilisateur s'est inscrit", 'status-failed' => "L'envoi a échoué", 'status-sent' => "Envoyé", 'status-new' => "Pas encore envoyé", ], 'lang' => [ 'en' => "Anglais", 'de' => "Allemand", 'fr' => "Français", 'it' => "Italien", ], 'login' => [ '2fa' => "Code du 2ème facteur", '2fa_desc' => "Le code du 2ème facteur est facultatif pour les utilisateurs qui n'ont pas configuré l'authentification à deux facteurs.", 'forgot_password' => "Mot de passe oublié?", 'header' => "Veuillez vous connecter", 'sign_in' => "Se connecter", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voix et vidéo-conférence", 'welcome' => "Bienvenue dans notre programme bêta pour les conférences vocales et vidéo.", 'url' => "Vous disposez d'une salle avec l'URL ci-dessous. Cette salle ouvre uniquement quand vous y êtes vous-même. Utilisez cette URL pour inviter des personnes à vous rejoindre.", 'notice' => "Il s'agit d'un travail en évolution et d'autres fonctions seront ajoutées au fil du temps. Les fonctions actuelles sont les suivantes:", 'sharing' => "Partage d'écran", 'sharing-text' => "Partagez votre écran pour des présentations ou des exposés.", 'security' => "sécurité de chambre", 'security-text' => "Renforcez la sécurité de la salle en définissant un mot de passe que les participants devront connaître." . " avant de pouvoir entrer, ou verrouiller la porte afin que les participants doivent frapper, et un modérateur peut accepter ou refuser ces demandes.", 'qa-title' => "Lever la main (Q&A)", 'qa-text' => "Les membres du public silencieux peuvent lever la main pour animer une séance de questions-réponses avec les membres du panel.", 'moderation' => "Délégation des Modérateurs", 'moderation-text' => "Déléguer l'autorité du modérateur pour la séance, afin qu'un orateur ne soit pas inutilement" . " interrompu par l'arrivée des participants et d'autres tâches du modérateur.", 'eject' => "Éjecter les participants", 'eject-text' => "Éjectez les participants de la session afin de les obliger à se reconnecter ou de remédier aux violations des règles." . " Cliquez sur l'icône de l'utilisateur pour un renvoi effectif.", 'silent' => "Membres du Public en Silence", 'silent-text' => "Pour une séance de type webinaire, configurez la salle pour obliger tous les nouveaux participants à être des spectateurs silencieux.", 'interpreters' => "Canaux d'Audio Spécifiques de Langues", 'interpreters-text' => "Désignez un participant pour interpréter l'audio original dans une langue cible, pour les sessions avec des participants multilingues." . " L'interprète doit être capable de relayer l'audio original et de le remplacer.", 'beta-notice' => "Rappelez-vous qu'il s'agit d'une version bêta et pourrait entraîner des problèmes." . " Au cas où vous rencontreriez des problèmes, n'hésitez pas à nous en faire part en contactant le support.", // Room options dialog 'options' => "Options de salle", 'password' => "Mot de passe", 'password-none' => "aucun", 'password-clear' => "Effacer mot de passe", 'password-set' => "Définir le mot de passe", 'password-text' => "Vous pouvez ajouter un mot de passe à votre session. Les participants devront fournir le mot de passe avant d'être autorisés à rejoindre la session.", 'lock' => "Salle verrouillée", 'lock-text' => "Lorsque la salle est verrouillée, les participants doivent être approuvés par un modérateur avant de pouvoir rejoindre la réunion.", 'nomedia' => "Réservé aux abonnés", 'nomedia-text' => "Force tous les participants à se joindre en tant qu'abonnés (avec caméra et microphone désactivés)" . "Les modérateurs pourront les promouvoir en tant qu'éditeurs tout au long de la session.", // Room menu 'partcnt' => "Nombres de participants", 'menu-audio-mute' => "Désactiver le son", 'menu-audio-unmute' => "Activer le son", 'menu-video-mute' => "Désactiver la vidéo", 'menu-video-unmute' => "Activer la vidéo", 'menu-screen' => "Partager l'écran", 'menu-hand-lower' => "Baisser la main", 'menu-hand-raise' => "Lever la main", 'menu-channel' => "Canal de langue interprétée", 'menu-chat' => "Le Chat", 'menu-fullscreen' => "Plein écran", 'menu-fullscreen-exit' => "Sortir en plein écran", 'menu-leave' => "Quitter la session", // Room setup screen 'setup-title' => "Préparez votre session", 'mic' => "Microphone", 'cam' => "Caméra", 'nick' => "Surnom", 'nick-placeholder' => "Votre nom", 'join' => "JOINDRE", 'joinnow' => "JOINDRE MAINTENANT", 'imaowner' => "Je suis le propriétaire", // Room 'qa' => "Q & A", 'leave-title' => "Salle fermée", 'leave-body' => "La session a été fermée par le propriétaire de la salle.", 'media-title' => "Configuration des médias", 'join-request' => "Demande de rejoindre", 'join-requested' => "{user} demandé à rejoindre.", // Status messages 'status-init' => "Vérification de la salle...", 'status-323' => "La salle est fermée. Veuillez attendre le démarrage de la session par le propriétaire.", 'status-324' => "La salle est fermée. Elle sera ouverte aux autres participants après votre adhésion.", 'status-325' => "La salle est prête. Veuillez entrer un mot de passe valide.", 'status-326' => "La salle est fermée. Veuillez entrer votre nom et réessayer.", 'status-327' => "En attendant la permission de joindre la salle.", 'status-404' => "La salle n'existe pas.", 'status-429' => "Trop de demande. Veuillez, patienter.", 'status-500' => "La connexion à la salle a échoué. Erreur de serveur.", // Other menus 'media-setup' => "configuration des médias", 'perm' => "Permissions", 'perm-av' => "Publication d'audio et vidéo", 'perm-mod' => "Modération", 'lang-int' => "Interprète de langue", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Connecter", 'logout' => "Deconnecter", 'signup' => "S'inscrire", 'toggle' => "Basculer la navigation", ], 'msg' => [ 'initializing' => "Initialisation...", 'loading' => "Chargement...", 'loading-failed' => "Échec du chargement des données.", 'notfound' => "Resource introuvable.", 'info' => "Information", 'error' => "Erreur", 'warning' => "Avertissement", 'success' => "Succès", ], 'nav' => [ 'more' => "Charger plus", 'step' => "Étape {i}/{n}", ], 'password' => [ 'reset' => "Réinitialiser le mot de passe", 'reset-step1' => "Entrez votre adresse e-mail pour réinitialiser votre mot de passe.", 'reset-step1-hint' => "Veuillez vérifier votre dossier de spam ou débloquer {email}.", 'reset-step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail externe." . " Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.", ], 'resource' => [ 'create' => "Créer une ressource", 'delete' => "Supprimer une ressource", 'invitation-policy' => "Procédure d'invitation", 'invitation-policy-text' => "Les invitations à des événements pour une ressource sont généralement acceptées automatiquement" . " si aucun événement n'est en conflit avec le temps demandé. La procédure d'invitation le permet" . " de rejeter ces demandes ou d'exiger une acceptation manuelle d'un utilisateur spécifique.", 'ipolicy-manual' => "Manuel (provisoire)", 'ipolicy-accept' => "Accepter", 'ipolicy-reject' => "Rejecter", 'list-title' => "Ressource | Ressources", 'list-empty' => "Il y a aucune ressource sur ce compte.", 'new' => "Nouvelle ressource", ], 'shf' => [ 'create' => "Créer un dossier", 'delete' => "Supprimer un dossier", 'acl-text' => "Permet de définir les droits d'accès des utilisateurs au dossier partagé..", 'list-title' => "Dossier partagé | Dossiers partagés", 'list-empty' => "Il y a aucun dossier partagé dans ce compte.", 'new' => "Nouvelle dossier", 'type-mail' => "Courriel", 'type-event' => "Calendrier", 'type-contact' => "Carnet d'Adresses", 'type-task' => "Tâches", 'type-note' => "Notes", 'type-file' => "Fichiers", ], 'signup' => [ 'email' => "Adresse e-mail actuelle", 'login' => "connecter", 'title' => "S'inscrire", 'step1' => "Inscrivez-vous pour commencer votre mois gratuit.", 'step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail. Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.", 'step3' => "Créez votre identité Kolab (vous pourrez choisir des adresses supplémentaires plus tard).", 'voucher' => "Coupon Code", ], 'status' => [ 'prepare-account' => "Votre compte est en cours de préparation.", 'prepare-domain' => "Le domain est en cours de préparation.", 'prepare-distlist' => "La liste de distribution est en cours de préparation.", 'prepare-shared-folder' => "Le dossier portagé est en cours de préparation.", 'prepare-user' => "Le compte d'utilisateur est en cours de préparation.", 'prepare-hint' => "Certaines fonctionnalités peuvent être manquantes ou en lecture seule pour le moment.", 'prepare-refresh' => "Le processus ne se termine jamais? Appuyez sur le bouton \"Refresh\", s'il vous plaît.", 'prepare-resource' => "Nous préparons la ressource.", 'ready-account' => "Votre compte est presque prêt.", 'ready-domain' => "Le domaine est presque prêt.", 'ready-distlist' => "La liste de distribution est presque prête.", 'ready-resource' => "La ressource est presque prête.", 'ready-shared-folder' => "Le dossier partagé est presque prêt.", 'ready-user' => "Le compte d'utilisateur est presque prêt.", 'verify' => "Veuillez vérifier votre domaine pour terminer le processus de configuration.", 'verify-domain' => "Vérifier domaine", 'degraded' => "Dégradé", 'deleted' => "Supprimé", 'suspended' => "Suspendu", 'notready' => "Pas Prêt", 'active' => "Actif", ], 'support' => [ 'title' => "Contacter Support", 'id' => "Numéro de client ou adresse é-mail que vous avez chez nous.", 'id-pl' => "e.g. 12345678 ou john@kolab.org", 'id-hint' => "Laissez vide si vous n'êtes pas encore client", 'name' => "Nom", 'name-pl' => "comment nous devons vous adresser dans notre réponse", 'email' => "adresse e-mail qui fonctionne", 'email-pl' => "assurez-vous que nous pouvons vous atteindre à cette adresse", 'summary' => "Résumé du problème", 'summary-pl' => "une phrase qui résume votre situation", 'expl' => "Analyse du problème", ], 'user' => [ '2fa-hint1' => "Cela éliminera le droit à l'authentification à 2-Facteurs ainsi que les éléments configurés par l'utilisateur.", '2fa-hint2' => "Veuillez vous assurer que l'identité de l'utilisateur est correctement confirmée.", 'add-beta' => "Activer le programme bêta", 'address' => "Adresse", 'aliases' => "Alias", - 'aliases-email' => "Alias E-mail", 'aliases-none' => "Cet utilisateur n'aucune alias e-mail.", 'add-bonus' => "Ajouter un bonus", 'add-bonus-title' => "Ajouter un bonus au portefeuille", 'add-penalty' => "Ajouter une pénalité", 'add-penalty-title' => "Ajouter une pénalité au portefeuille", 'auto-payment' => "Auto-paiement", 'auto-payment-text' => "Recharger par <b>{amount}</b> quand le montant est inférieur à <b>{balance}</b> utilisant {method}", 'country' => "Pays", 'create' => "Créer un utilisateur", 'custno' => "No. de Client.", 'degraded-warning' => "Le compte est dégradé. Certaines fonctionnalités ont été désactivées.", 'degraded-hint' => "Veuillez effectuer un paiement.", 'delete' => "Supprimer Utilisateur", 'delete-email' => "Supprimer {email}", 'delete-text' => "Voulez-vous vraiment supprimer cet utilisateur de façon permanente?" . " Cela supprimera toutes les données du compte et retirera la permission d'accéder au compte d'e-email." . " Veuillez noter que cette action ne peut pas être révoquée.", 'discount' => "Rabais", 'discount-hint' => "rabais appliqué", 'discount-title' => "Rabais de compte", 'distlists' => "Listes de Distribution", 'domains' => "Domaines", + 'email-aliases' => "Alias E-mail", 'ext-email' => "E-mail externe", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "La greylisting est une méthode de défense des utilisateurs contre le spam." . " Tout e-mail entrant provenant d'un expéditeur non reconnu est temporairement rejeté." . " Le serveur d'origine doit réessayer après un délai cette fois-ci, le mail sera accepté." . " Les spammeurs ne réessayent généralement pas de remettre le mail.", 'list-title' => "Comptes d'utilisateur", 'list-empty' => "Il n'y a aucun utilisateur dans ce compte.", 'managed-by' => "Géré par", 'new' => "Nouveau compte d'utilisateur", 'org' => "Organisation", 'package' => "Paquet", 'price' => "Prix", 'profile-title' => "Votre profile", 'profile-delete' => "Supprimer compte", 'profile-delete-title' => "Supprimer ce compte?", 'profile-delete-text1' => "Cela supprimera le compte ainsi que tous les domaines, utilisateurs et alias associés à ce compte.", 'profile-delete-warning' => "Cette opération est irrévocable", 'profile-delete-text2' => "Comme vous ne pourrez plus rien récupérer après ce point, assurez-vous d'avoir migré toutes les données avant de poursuivre.", 'profile-delete-support' => "Étant donné que nous nous attachons à toujours nous améliorer, nous aimerions vous demander 2 minutes de votre temps. " . "Le meilleur moyen de nous améliorer est le feedback des utilisateurs, et nous voudrions vous demander" . "quelques mots sur les raisons pour lesquelles vous avez quitté notre service. Veuillez envoyer vos commentaires au <a href=\"{href}\">{email}</a>.", 'profile-delete-contact' => "Par ailleurs, n'hésitez pas à contacter le support de {app} pour toute question ou souci que vous pourriez avoir dans ce contexte.", 'reset-2fa' => "Réinitialiser l'authentification à 2-Facteurs.", 'reset-2fa-title' => "Réinitialisation de l'Authentification à 2-Facteurs", 'resources' => "Ressources", 'title' => "Compte d'utilisateur", 'search' => "Adresse e-mail ou nom de l'utilisateur", 'search-pl' => "ID utilisateur, e-mail ou domamine", 'skureq' => "{sku} demande {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "Cet utilisateur n'a pas de subscriptions.", 'users' => "Utilisateurs", ], 'wallet' => [ 'add-credit' => "Ajouter un crédit", 'auto-payment-cancel' => "Annuler l'auto-paiement", 'auto-payment-change' => "Changer l'auto-paiement", 'auto-payment-failed' => "La configuration des paiements automatiques a échoué. Redémarrer le processus pour activer les top-ups automatiques.", 'auto-payment-hint' => "Cela fonctionne de la manière suivante: Chaque fois que votre compte est épuisé, nous débiterons votre méthode de paiement préférée d'un montant que vous aurez défini." . " Vous pouvez annuler ou modifier l'option de paiement automatique à tout moment.", 'auto-payment-setup' => "configurer l'auto-paiement", 'auto-payment-disabled' => "L'auto-paiement configuré a été désactivé. Rechargez votre porte-monnaie ou augmentez le montant d'auto-paiement.", 'auto-payment-info' => "L'auto-paiement est <b>set</b> pour recharger votre compte par <b>{amount}</b> lorsque le solde de votre compte devient inférieur à <b>{balance}</b>.", 'auto-payment-inprogress' => "La configuration d'auto-paiement est toujours en cours.", 'auto-payment-next' => "Ensuite, vous serez redirigé vers la page de paiement, où vous pourrez fournir les coordonnées de votre carte de crédit.", 'auto-payment-disabled-next' => "L'auto-paiement est désactivé. Dès que vous aurez soumis de nouveaux paramètres, nous l'activerons et essaierons de recharger votre portefeuille.", 'auto-payment-update' => "Mise à jour de l'auto-paiement.", 'banktransfer-hint' => "Veuillez noter qu'un virement bancaire peut nécessiter plusieurs jours avant d'être effectué.", 'currency-conv' => "Le principe est le suivant: Vous spécifiez le montant dont vous voulez recharger votre portefeuille en {wc}." . " Nous convertirons ensuite ce montant en {pc}, et sur la page suivante, vous obtiendrez les coordonnées bancaires pour transférer le montant en {pc}.", 'fill-up' => "Recharger par", 'history' => "Histoire", 'month' => "mois", 'noperm' => "Seuls les propriétaires de compte peuvent accéder à un portefeuille.", 'payment-amount-hint' => "Choisissez le montant dont vous voulez recharger votre portefeuille.", 'payment-method' => "Mode de paiement: {method}", 'payment-warning' => "Vous serez facturé pour {price}.", 'pending-payments' => "Paiements en attente", 'pending-payments-warning' => "Vous avez des paiements qui sont encore en cours. Voir l'onglet \"Paiements en attente\" ci-dessous.", 'pending-payments-none' => "Il y a aucun paiement en attente pour ce compte.", 'receipts' => "Reçus", 'receipts-hint' => "Vous pouvez télécharger ici les reçus (au format PDF) pour les paiements de la période spécifiée. Sélectionnez la période et appuyez sur le bouton Télécharger.", 'receipts-none' => "Il y a aucun reçu pour les paiements de ce compte. Veuillez noter que vous pouvez télécharger les reçus après la fin du mois.", 'title' => "Solde du compte", 'top-up' => "Rechargez votre portefeuille", 'transactions' => "Transactions", 'transactions-none' => "Il y a aucun transaction pour ce compte.", 'when-below' => "lorsque le solde du compte est inférieur à", ], ]; diff --git a/src/resources/vue/Admin/Distlist.vue b/src/resources/vue/Admin/Distlist.vue index 76477f43..f7721947 100644 --- a/src/resources/vue/Admin/Distlist.vue +++ b/src/resources/vue/Admin/Distlist.vue @@ -1,113 +1,107 @@ <template> <div v-if="list.id" class="container"> <div class="card" id="distlist-info"> <div class="card-body"> <div class="card-title">{{ list.email }}</div> <div class="card-text"> <form class="read-only short"> <div class="row plaintext"> <label for="distlistid" class="col-sm-4 col-form-label"> {{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span> </label> <div class="col-sm-8"> <span class="form-control-plaintext" id="distlistid"> {{ list.id }} <span class="text-muted">({{ list.created_at }})</span> </span> </div> </div> <div class="row plaintext"> <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label> <div class="col-sm-8"> <span :class="$root.statusClass(list) + ' form-control-plaintext'" id="status">{{ $root.statusText(list) }}</span> </div> </div> <div class="row plaintext"> <label for="name" class="col-sm-4 col-form-label">{{ $t('distlist.name') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="name">{{ list.name }}</span> </div> </div> <div class="row plaintext"> <label for="members" class="col-sm-4 col-form-label">{{ $t('distlist.recipients') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="members"> <span v-for="member in list.members" :key="member">{{ member }}<br></span> </span> </div> </div> </form> <div class="mt-2 buttons"> <button v-if="!list.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendList"> {{ $t('btn.suspend') }} </button> <button v-if="list.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendList"> {{ $t('btn.unsuspend') }} </button> </div> </div> </div> </div> - <ul class="nav nav-tabs mt-3" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-settings" href="#distlist-settings" role="tab" aria-controls="distlist-settings" aria-selected="false" @click="$root.tab"> - {{ $t('form.settings') }} - </a> - </li> - </ul> + <tabs class="mt-3" :tabs="['form.settings']"></tabs> <div class="tab-content"> - <div class="tab-pane show active" id="distlist-settings" role="tabpanel" aria-labelledby="tab-settings"> + <div class="tab-pane show active" id="settings" role="tabpanel" aria-labelledby="tab-settings"> <div class="card-body"> <div class="card-text"> <form class="read-only short"> <div class="row plaintext"> <label for="sender_policy" class="col-sm-4 col-form-label">{{ $t('distlist.sender-policy') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="sender_policy"> {{ list.config.sender_policy && list.config.sender_policy.length ? list.config.sender_policy.join(', ') : $t('form.none') }} </span> </div> </div> </form> </div> </div> </div> </div> </div> </template> <script> export default { data() { return { list: { members: [], config: {} } } }, created() { axios.get('/api/v4/groups/' + this.$route.params.list, { loader: true }) .then(response => { 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 5bdf8803..5be2d9e7 100644 --- a/src/resources/vue/Admin/Domain.vue +++ b/src/resources/vue/Admin/Domain.vue @@ -1,118 +1,107 @@ <template> <div v-if="domain" class="container"> <div class="card" id="domain-info"> <div class="card-body"> <div class="card-title">{{ domain.namespace }}</div> <div class="card-text"> <form class="read-only short"> <div class="row plaintext"> <label for="domainid" class="col-sm-4 col-form-label"> {{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span> </label> <div class="col-sm-8"> <span class="form-control-plaintext" id="domainid"> {{ domain.id }} <span class="text-muted">({{ domain.created_at }})</span> </span> </div> </div> <div class="row plaintext"> <label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="status"> <span :class="$root.statusClass(domain)">{{ $root.statusText(domain) }}</span> </span> </div> </div> </form> <div class="mt-2 buttons"> <btn v-if="!domain.isSuspended" id="button-suspend" class="btn-warning" @click="suspendDomain"> {{ $t('btn.suspend') }} </btn> <btn v-if="domain.isSuspended" id="button-unsuspend" class="btn-warning" @click="unsuspendDomain"> {{ $t('btn.unsuspend') }} </btn> </div> </div> </div> </div> - <ul class="nav nav-tabs mt-3" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-config" href="#domain-config" role="tab" aria-controls="domain-config" aria-selected="true" @click="$root.tab"> - {{ $t('form.config') }} - </a> - </li> - <li class="nav-item"> - <a class="nav-link" id="tab-settings" href="#domain-settings" role="tab" aria-controls="domain-settings" aria-selected="false" @click="$root.tab"> - {{ $t('form.settings') }} - </a> - </li> - </ul> + <tabs class="mt-3" :tabs="['form.config', 'form.settings']"></tabs> <div class="tab-content"> - <div class="tab-pane show active" id="domain-config" role="tabpanel" aria-labelledby="tab-config"> + <div class="tab-pane show active" id="config" role="tabpanel" aria-labelledby="tab-config"> <div class="card-body"> <div class="card-text"> <p>{{ $t('domain.dns-verify') }}</p> <p><pre id="dns-verify">{{ domain.dns.join("\n") }}</pre></p> <p>{{ $t('domain.dns-config') }}</p> <p><pre id="dns-config">{{ domain.mx.join("\n") }}</pre></p> </div> </div> </div> - <div class="tab-pane" id="domain-settings" role="tabpanel" aria-labelledby="tab-settings"> + <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings"> <div class="card-body"> <div class="card-text"> <form class="read-only short"> <div class="row plaintext"> <label for="spf_whitelist" class="col-sm-4 col-form-label">{{ $t('domain.spf-whitelist') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="spf_whitelist"> {{ domain.config && domain.config.spf_whitelist.length ? domain.config.spf_whitelist.join(', ') : $t('form.none') }} </span> </div> </div> </form> </div> </div> </div> </div> </div> </template> <script> export default { data() { return { domain: null } }, created() { const domain_id = this.$route.params.domain; axios.get('/api/v4/domains/' + domain_id) .then(response => { this.domain = response.data }) .catch(this.$root.errorHandler) }, methods: { suspendDomain() { axios.post('/api/v4/domains/' + this.domain.id + '/suspend') .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.domain = Object.assign({}, this.domain, { isSuspended: true }) } }) }, unsuspendDomain() { axios.post('/api/v4/domains/' + this.domain.id + '/unsuspend') .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.domain = Object.assign({}, this.domain, { isSuspended: false }) } }) } } } </script> diff --git a/src/resources/vue/Admin/Resource.vue b/src/resources/vue/Admin/Resource.vue index 7ddc0846..cef90e9f 100644 --- a/src/resources/vue/Admin/Resource.vue +++ b/src/resources/vue/Admin/Resource.vue @@ -1,77 +1,71 @@ <template> <div v-if="resource.id" class="container"> <div class="card" id="resource-info"> <div class="card-body"> <div class="card-title">{{ resource.email }}</div> <div class="card-text"> <form class="read-only short"> <div class="row plaintext"> <label for="resourceid" class="col-sm-4 col-form-label"> {{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span> </label> <div class="col-sm-8"> <span class="form-control-plaintext" id="resourceid"> {{ resource.id }} <span class="text-muted">({{ resource.created_at }})</span> </span> </div> </div> <div class="row plaintext"> <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label> <div class="col-sm-8"> <span :class="$root.statusClass(resource) + ' form-control-plaintext'" id="status">{{ $root.statusText(resource) }}</span> </div> </div> <div class="row plaintext"> <label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="name">{{ resource.name }}</span> </div> </div> </form> </div> </div> </div> - <ul class="nav nav-tabs mt-3" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-settings" href="#resource-settings" role="tab" aria-controls="resource-settings" aria-selected="false" @click="$root.tab"> - {{ $t('form.settings') }} - </a> - </li> - </ul> + <tabs class="mt-3" :tabs="['form.settings']"></tabs> <div class="tab-content"> - <div class="tab-pane show active" id="resource-settings" role="tabpanel" aria-labelledby="tab-settings"> + <div class="tab-pane show active" id="settings" role="tabpanel" aria-labelledby="tab-settings"> <div class="card-body"> <div class="card-text"> <form class="read-only short"> <div class="row plaintext"> <label for="invitation_policy" class="col-sm-4 col-form-label">{{ $t('resource.invitation-policy') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="invitation_policy"> {{ resource.config.invitation_policy || $t('form.none') }} </span> </div> </div> </form> </div> </div> </div> </div> </div> </template> <script> export default { data() { return { resource: { config: {} } } }, created() { axios.get('/api/v4/resources/' + this.$route.params.resource, { loader: true }) .then(response => { this.resource = response.data }) .catch(this.$root.errorHandler) } } </script> diff --git a/src/resources/vue/Admin/SharedFolder.vue b/src/resources/vue/Admin/SharedFolder.vue index bb268157..38ced256 100644 --- a/src/resources/vue/Admin/SharedFolder.vue +++ b/src/resources/vue/Admin/SharedFolder.vue @@ -1,114 +1,108 @@ <template> <div v-if="folder.id" class="container"> <div class="card" id="folder-info"> <div class="card-body"> <div class="card-title">{{ folder.email }}</div> <div class="card-text"> <form class="read-only short"> <div class="row plaintext"> <label for="folderid" class="col-sm-4 col-form-label"> {{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span> </label> <div class="col-sm-8"> <span class="form-control-plaintext" id="folderid"> {{ folder.id }} <span class="text-muted">({{ folder.created_at }})</span> </span> </div> </div> <div class="row plaintext"> <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label> <div class="col-sm-8"> <span :class="$root.statusClass(folder) + ' form-control-plaintext'" id="status">{{ $root.statusText(folder) }}</span> </div> </div> <div class="row plaintext"> <label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="name">{{ folder.name }}</span> </div> </div> <div class="row plaintext"> <label for="type" class="col-sm-4 col-form-label">{{ $t('form.type') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="type">{{ $t('shf.type-' + folder.type) }}</span> </div> </div> </form> </div> </div> </div> - <ul class="nav nav-tabs mt-3" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-settings" href="#folder-settings" role="tab" aria-controls="folder-settings" aria-selected="false" @click="$root.tab"> - {{ $t('form.settings') }} - </a> - </li> - <li class="nav-item"> - <a class="nav-link" id="tab-aliases" href="#folder-aliases" role="tab" aria-controls="folder-aliases" aria-selected="false" @click="$root.tab"> - {{ $t('user.aliases-email') }} ({{ folder.aliases.length }}) - </a> - </li> - </ul> + <tabs class="mt-3" :tabs="tabs" ref="tabs"></tabs> <div class="tab-content"> - <div class="tab-pane show active" id="folder-settings" role="tabpanel" aria-labelledby="tab-settings"> + <div class="tab-pane show active" id="settings" role="tabpanel" aria-labelledby="tab-settings"> <div class="card-body"> <div class="card-text"> <form class="read-only short"> <div class="row plaintext"> <label for="acl" class="col-sm-4 col-form-label">{{ $t('form.acl') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="acl"> <span v-if="folder.config.acl.length"> <span v-for="(entry, index) in folder.config.acl" :key="index"> {{ entry.replace(',', ':') }}<br> </span> </span> <span v-else>{{ $t('form.none') }}</span> </span> </div> </div> </form> </div> </div> </div> - <div class="tab-pane" id="folder-aliases" role="tabpanel" aria-labelledby="tab-aliases"> + <div class="tab-pane" id="aliases" role="tabpanel" aria-labelledby="tab-aliases"> <div class="card-body"> <div class="card-text"> <list-table :list="folder.aliases" :setup="aliasesListSetup" class="mb-0"></list-table> </div> </div> </div> </div> </div> </template> <script> import { ListTable } from '../Widgets/ListTools' export default { components: { ListTable }, data() { return { aliasesListSetup: { columns: [ { prop: 'email', content: item => item }, ], footLabel: 'shf.aliases-none' }, - folder: { config: {}, aliases: [] } + folder: { config: {}, aliases: [] }, + tabs: [ + { label: 'form.settings' }, + { label: 'user.email-aliases', count: 0 } + ] } }, created() { axios.get('/api/v4/shared-folders/' + this.$route.params.folder, { loader: true }) .then(response => { this.folder = response.data + this.tabs[1].count = this.folder.aliases.length }) .catch(this.$root.errorHandler) } } </script> diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index 75c160f1..09197126 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,644 +1,619 @@ <template> <div class="container"> <div class="card" id="user-info"> <div class="card-body"> <h1 class="card-title">{{ user.email }}</h1> <div class="card-text"> <form class="read-only short"> <div v-if="user.wallet.user_id != user.id" class="row plaintext"> <label for="manager" class="col-sm-4 col-form-label">{{ $t('user.managed-by') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="manager"> <router-link :to="{ path: '/user/' + user.wallet.user_id }">{{ user.wallet.user_email }}</router-link> </span> </div> </div> <div class="row plaintext"> <label for="userid" class="col-sm-4 col-form-label">ID <span class="text-muted">({{ $t('form.created') }})</span></label> <div class="col-sm-8"> <span class="form-control-plaintext" id="userid"> {{ user.id }} <span class="text-muted">({{ user.created_at }})</span> </span> </div> </div> <div class="row plaintext"> <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="status"> <span :class="$root.statusClass(user)">{{ $root.statusText(user) }}</span> </span> </div> </div> <div class="row plaintext" v-if="user.first_name"> <label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="first_name">{{ user.first_name }}</span> </div> </div> <div class="row plaintext" v-if="user.last_name"> <label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="last_name">{{ user.last_name }}</span> </div> </div> <div class="row plaintext" v-if="user.organization"> <label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="organization">{{ user.organization }}</span> </div> </div> <div class="row plaintext" v-if="user.phone"> <label for="phone" class="col-sm-4 col-form-label">{{ $t('form.phone') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="phone">{{ user.phone }}</span> </div> </div> <div class="row plaintext"> <label for="external_email" class="col-sm-4 col-form-label">{{ $t('user.ext-email') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="external_email"> <a v-if="user.external_email" :href="'mailto:' + user.external_email">{{ user.external_email }}</a> <btn class="btn-secondary btn-sm ms-2" @click="emailEdit">{{ $t('btn.edit') }}</btn> </span> </div> </div> <div class="row plaintext" v-if="user.billing_address"> <label for="billing_address" class="col-sm-4 col-form-label">{{ $t('user.address') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" style="white-space:pre" id="billing_address">{{ user.billing_address }}</span> </div> </div> <div class="row plaintext"> <label for="country" class="col-sm-4 col-form-label">{{ $t('user.country') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="country">{{ user.country }}</span> </div> </div> </form> <div class="mt-2 buttons"> <btn v-if="!user.isSuspended" id="button-suspend" class="btn-warning" @click="suspendUser"> {{ $t('btn.suspend') }} </btn> <btn v-if="user.isSuspended" id="button-unsuspend" class="btn-warning" @click="unsuspendUser"> {{ $t('btn.unsuspend') }} </btn> </div> </div> </div> </div> - <ul class="nav nav-tabs mt-3" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-finances" href="#user-finances" role="tab" aria-controls="user-finances" aria-selected="true"> - {{ $t('user.finances') }} - </a> - </li> - <li class="nav-item"> - <a class="nav-link" id="tab-aliases" href="#user-aliases" role="tab" aria-controls="user-aliases" aria-selected="false"> - {{ $t('user.aliases') }} ({{ user.aliases.length }}) - </a> - </li> - <li class="nav-item"> - <a class="nav-link" id="tab-subscriptions" href="#user-subscriptions" role="tab" aria-controls="user-subscriptions" aria-selected="false"> - {{ $t('user.subscriptions') }} ({{ skus.length }}) - </a> - </li> - <li class="nav-item"> - <a class="nav-link" id="tab-domains" href="#user-domains" role="tab" aria-controls="user-domains" aria-selected="false"> - {{ $t('user.domains') }} ({{ domains.length }}) - </a> - </li> - <li class="nav-item"> - <a class="nav-link" id="tab-users" href="#user-users" role="tab" aria-controls="user-users" aria-selected="false"> - {{ $t('user.users') }} ({{ users.length }}) - </a> - </li> - <li class="nav-item"> - <a class="nav-link" id="tab-distlists" href="#user-distlists" role="tab" aria-controls="user-distlists" aria-selected="false"> - {{ $t('user.distlists') }} ({{ distlists.length }}) - </a> - </li> - <li class="nav-item"> - <a class="nav-link" id="tab-resources" href="#user-resources" role="tab" aria-controls="user-resources" aria-selected="false"> - {{ $t('user.resources') }} ({{ resources.length }}) - </a> - </li> - <li class="nav-item"> - <a class="nav-link" id="tab-shared-folders" href="#user-shared-folders" role="tab" aria-controls="user-shared-folders" aria-selected="false"> - {{ $t('dashboard.shared-folders') }} ({{ folders.length }}) - </a> - </li> - <li class="nav-item"> - <a class="nav-link" id="tab-settings" href="#user-settings" role="tab" aria-controls="user-settings" aria-selected="false"> - {{ $t('form.settings') }} - </a> - </li> - </ul> + <tabs class="mt-3" :tabs="tabs" ref="tabs"></tabs> <div class="tab-content"> - <div class="tab-pane show active" id="user-finances" role="tabpanel" aria-labelledby="tab-finances"> + <div class="tab-pane show active" id="finances" role="tabpanel" aria-labelledby="tab-finances"> <div class="card-body"> <h2 class="card-title"> {{ $t('wallet.title') }} <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(wallet.balance, wallet.currency) }}</strong></span> </h2> <div class="card-text"> <form class="read-only short"> <div class="row"> <label class="col-sm-4 col-form-label">{{ $t('user.discount') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="discount"> <span>{{ wallet.discount ? (wallet.discount + '% - ' + wallet.discount_description) : 'none' }}</span> <btn class="btn-secondary btn-sm ms-2" @click="discountEdit">{{ $t('btn.edit') }}</btn> </span> </div> </div> <div class="row" v-if="wallet.mandate && wallet.mandate.id"> <label class="col-sm-4 col-form-label">{{ $t('user.auto-payment') }}</label> <div class="col-sm-8"> <span id="autopayment" :class="'form-control-plaintext' + (wallet.mandateState ? ' text-danger' : '')" v-html="$t('user.auto-payment-text', { amount: wallet.mandate.amount + ' ' + wallet.currency, balance: wallet.mandate.balance + ' ' + wallet.currency, method: wallet.mandate.method })" > <span v-if="wallet.mandateState">({{ wallet.mandateState }})</span>. </span> </div> </div> <div class="row" v-if="wallet.providerLink"> <label class="col-sm-4 col-form-label">{{ capitalize(wallet.provider) }} {{ $t('form.id') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" v-html="wallet.providerLink"></span> </div> </div> </form> <div class="mt-2 buttons"> <btn id="button-award" class="btn-success" @click="awardDialog">{{ $t('user.add-bonus') }}</btn> <btn id="button-penalty" class="btn-danger" @click="penalizeDialog">{{ $t('user.add-penalty') }}</btn> </div> </div> <h2 class="card-title mt-4">{{ $t('wallet.transactions') }}</h2> <transaction-log v-if="wallet.id && !walletReload" class="card-text" :wallet-id="wallet.id" :is-admin="true"></transaction-log> </div> </div> - <div class="tab-pane" id="user-aliases" role="tabpanel" aria-labelledby="tab-aliases"> + <div class="tab-pane" id="aliases" role="tabpanel" aria-labelledby="tab-aliases"> <div class="card-body"> <div class="card-text"> <list-table :list="user.aliases" :setup="aliasesListSetup" class="mb-0"></list-table> </div> </div> </div> - <div class="tab-pane" id="user-subscriptions" role="tabpanel" aria-labelledby="tab-subscriptions"> + <div class="tab-pane" id="subscriptions" role="tabpanel" aria-labelledby="tab-subscriptions"> <div class="card-body"> <div class="card-text"> <list-table :list="skus" :setup="skusListSetup" class="mb-0"></list-table> <small v-if="discount > 0" class="hint"> <hr class="m-0"> ¹ {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }} </small> <div class="mt-2 buttons"> <btn class="btn-danger" id="reset2fa" v-if="has2FA" @click="$refs.reset2faDialog.show()">{{ $t('user.reset-2fa') }}</btn> <btn class="btn-secondary" id="addbetasku" v-if="!hasBeta" @click="addBetaSku">{{ $t('user.add-beta') }}</btn> </div> </div> </div> </div> - <div class="tab-pane" id="user-domains" role="tabpanel" aria-labelledby="tab-domains"> + <div class="tab-pane" id="domains" role="tabpanel" aria-labelledby="tab-domains"> <div class="card-body"> <div class="card-text"> <domain-list :list="domains" class="mb-0"></domain-list> </div> </div> </div> - <div class="tab-pane" id="user-users" role="tabpanel" aria-labelledby="tab-users"> + <div class="tab-pane" id="users" role="tabpanel" aria-labelledby="tab-users"> <div class="card-body"> <div class="card-text"> <user-list :list="users" :current="user" class="mb-0"></user-list> </div> </div> </div> - <div class="tab-pane" id="user-distlists" role="tabpanel" aria-labelledby="tab-distlists"> + <div class="tab-pane" id="distlists" role="tabpanel" aria-labelledby="tab-distlists"> <div class="card-body"> <div class="card-text"> <distlist-list :list="distlists" class="mb-0"></distlist-list> </div> </div> </div> - <div class="tab-pane" id="user-resources" role="tabpanel" aria-labelledby="tab-resources"> + <div class="tab-pane" id="resources" role="tabpanel" aria-labelledby="tab-resources"> <div class="card-body"> <div class="card-text"> <resource-list :list="resources" class="mb-0"></resource-list> </div> </div> </div> - <div class="tab-pane" id="user-shared-folders" role="tabpanel" aria-labelledby="tab-shared-folders"> + <div class="tab-pane" id="folders" role="tabpanel" aria-labelledby="tab-folders"> <div class="card-body"> <div class="card-text"> <shared-folder-list :list="folders" :with-email="true" class="mb-0"></shared-folder-list> </div> </div> </div> - <div class="tab-pane" id="user-settings" role="tabpanel" aria-labelledby="tab-settings"> + <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings"> <div class="card-body"> <div class="card-text"> <form class="read-only short"> <div class="row plaintext"> <label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="greylist_enabled"> <span v-if="user.config.greylist_enabled" class="text-success">{{ $t('form.enabled') }}</span> <span v-else class="text-danger">{{ $t('form.disabled') }}</span> </span> </div> </div> </form> </div> </div> </div> </div> <modal-dialog id="discount-dialog" ref="discountDialog" :title="$t('user.discount-title')" @click="submitDiscount()" :buttons="['submit']"> <div> <select v-model="wallet.discount_id" class="form-select"> <option value="">- {{ $t('form.none') }} -</option> <option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option> </select> </div> </modal-dialog> <modal-dialog id="email-dialog" ref="emailDialog" :title="$t('user.ext-email')" @click="submitEmail()" :buttons="['submit']"> <div> <input v-model="external_email" name="external_email" class="form-control"> </div> </modal-dialog> <modal-dialog id="oneoff-dialog" ref="oneoffDialog" @click="submitOneOff()" :buttons="['submit']" :title="$t(oneoff_negative ? 'user.add-penalty-title' : 'user.add-bonus-title')" > <form data-validation-prefix="oneoff_"> <div class="mb-3"> <label for="oneoff_amount" class="form-label">{{ $t('form.amount') }}</label> <div class="input-group"> <input type="text" class="form-control" id="oneoff_amount" v-model="oneoff_amount" required> <span class="input-group-text">{{ wallet.currency }}</span> </div> </div> <div> <label for="oneoff_description" class="form-label">{{ $t('form.description') }}</label> <input class="form-control" id="oneoff_description" v-model="oneoff_description" required> </div> </form> </modal-dialog> <modal-dialog id="reset-2fa-dialog" ref="reset2faDialog" :title="$t('user.reset-2fa-title')" @click="reset2FA()" :buttons="[{className: 'btn-danger modal-action', label: 'btn.reset'}]" > <p>{{ $t('user.2fa-hint1') }}</p> <p>{{ $t('user.2fa-hint2') }}</p> </modal-dialog> </div> </template> <script> import ModalDialog from '../Widgets/ModalDialog' import TransactionLog from '../Widgets/TransactionLog' import { ListTable } from '../Widgets/ListTools' import { default as DistlistList } from '../Distlist/ListWidget' import { default as DomainList } from '../Domain/ListWidget' import { default as ResourceList } from '../Resource/ListWidget' import { default as SharedFolderList } from '../SharedFolder/ListWidget' import { default as UserList } from '../User/ListWidget' import { library } from '@fortawesome/fontawesome-svg-core' library.add( require('@fortawesome/free-solid-svg-icons/faFolderOpen').definition, require('@fortawesome/free-solid-svg-icons/faGear').definition, require('@fortawesome/free-solid-svg-icons/faGlobe').definition, require('@fortawesome/free-solid-svg-icons/faUsers').definition, ) export default { components: { DistlistList, DomainList, ListTable, ModalDialog, ResourceList, SharedFolderList, TransactionLog, UserList }, beforeRouteUpdate (to, from, next) { // An event called when the route that renders this component has changed, // but this component is reused in the new route. // Required to handle links from /user/XXX to /user/YYY next() this.$parent.routerReload() }, data() { return { aliasesListSetup: { columns: [ { prop: 'email', content: item => item }, ], footLabel: 'user.aliases-none' }, oneoff_amount: '', oneoff_description: '', oneoff_negative: false, discount: 0, discount_description: '', discounts: [], external_email: '', folders: [], has2FA: false, hasBeta: false, wallet: {}, walletReload: false, distlists: [], domains: [], resources: [], sku2FA: null, skus: [], skusListSetup: { columns: [ { prop: 'name', label: 'user.subscription' }, { prop: 'price', className: 'price', label: 'user.price' } ], footLabel: 'user.subscriptions-none', model: 'sku' }, + tabs: [ + { label: 'user.finances' }, + { label: 'user.aliases', count: 0 }, + { label: 'user.subscriptions', count: 0 }, + { label: 'user.domains', count: 0 }, + { label: 'user.users', count: 0 }, + { label: 'user.distlists', count: 0 }, + { label: 'user.resources', count: 0 }, + { label: 'dashboard.shared-folders', count: 0 }, + { label: 'form.settings' } + ], users: [], user: { aliases: [], config: {}, wallet: {}, skus: {}, } } }, created() { const user_id = this.$route.params.user axios.get('/api/v4/users/' + user_id, { loader: true }) .then(response => { this.user = response.data - const loader = '#user-finances' + const loader = '#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 + this.$refs.tabs.updateCounter('aliases', this.user.aliases.length) + // TODO: currencies, multi-wallets, accounts // Get more info about the wallet (e.g. payment provider related) axios.get('/api/v4/wallets/' + this.user.wallets[0].id, { loader }) .then(response => { this.wallet = response.data this.setMandateState() }) // Create subscriptions list axios.get('/api/v4/users/' + user_id + '/skus') .then(response => { // "merge" SKUs with user entitlement-SKUs response.data.forEach(sku => { const userSku = this.user.skus[sku.id] if (userSku) { let cost = userSku.costs.reduce((sum, current) => sum + current) let item = { id: sku.id, name: sku.name, cost: cost, price: this.$root.priceLabel(cost, this.discount) } if (sku.range) { item.name += ' ' + userSku.count + ' ' + sku.range.unit } this.skus.push(item) if (sku.handler == 'Auth2F') { this.has2FA = true this.sku2FA = sku.id } else if (sku.handler == 'Beta') { this.hasBeta = true } } }) + + this.$refs.tabs.updateCounter('subscriptions', this.skus.length) }) // Fetch users // TODO: Multiple wallets axios.get('/api/v4/users?owner=' + user_id) .then(response => { this.users = response.data.list; + this.$refs.tabs.updateCounter('users', this.users.length) }) // Fetch domains axios.get('/api/v4/domains?owner=' + user_id) .then(response => { this.domains = response.data.list + this.$refs.tabs.updateCounter('domains', this.domains.length) }) // Fetch distribution lists axios.get('/api/v4/groups?owner=' + user_id) .then(response => { this.distlists = response.data.list + this.$refs.tabs.updateCounter('distlists', this.distlists.length) }) // Fetch resources lists axios.get('/api/v4/resources?owner=' + user_id) .then(response => { this.resources = response.data.list + this.$refs.tabs.updateCounter('resources', this.resources.length) }) // Fetch shared folders lists axios.get('/api/v4/shared-folders?owner=' + user_id) .then(response => { this.folders = response.data.list + this.$refs.tabs.updateCounter('folders', this.folders.length) }) }) .catch(this.$root.errorHandler) }, mounted() { - $(this.$el).find('ul.nav-tabs a').on('click', this.$root.tab) - this.$refs.discountDialog.events({ shown: () => { // Note: Vue v-model is strict, convert null to a string this.wallet.discount_id = this.wallet.discount_id || '' } }) }, methods: { addBetaSku() { axios.post('/api/v4/users/' + this.user.id + '/skus/beta') .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.hasBeta = true const sku = response.data.sku this.skus.push({ id: sku.id, name: sku.name, cost: sku.cost, price: this.$root.priceLabel(sku.cost, this.discount) }) + + this.$refs.tabs.updateCounter('subscriptions', this.skus.length) } }) }, capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1) }, awardDialog() { this.oneOffDialog(false) }, discountEdit() { this.$refs.discountDialog.show() if (!this.discounts.length) { // Fetch discounts axios.get('/api/v4/users/' + this.user.id + '/discounts') .then(response => { this.discounts = response.data.list }) } }, emailEdit() { this.external_email = this.user.external_email this.$root.clearFormValidation($('#email-dialog')) this.$refs.emailDialog.show() }, setMandateState() { let mandate = this.wallet.mandate if (mandate && mandate.id) { if (!mandate.isValid) { this.wallet.mandateState = mandate.isPending ? 'pending' : 'invalid' } else if (mandate.isDisabled) { this.wallet.mandateState = 'disabled' } } }, oneOffDialog(negative) { this.oneoff_negative = negative this.$refs.oneoffDialog.show() }, penalizeDialog() { this.oneOffDialog(true) }, reload() { // this is to reload transaction log this.walletReload = true this.$nextTick(() => { this.walletReload = false }) }, reset2FA() { this.$refs.reset2faDialog.hide() axios.post('/api/v4/users/' + this.user.id + '/reset2FA') .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.skus = this.skus.filter(sku => sku.id != this.sku2FA) this.has2FA = false + this.$refs.tabs.updateCounter('subscriptions', this.skus.length) } }) }, submitDiscount() { this.$refs.discountDialog.hide() axios.put('/api/v4/wallets/' + this.user.wallets[0].id, { discount: this.wallet.discount_id }) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.wallet = Object.assign({}, this.wallet, response.data) // Update prices in Subscriptions tab if (this.user.wallet.id == response.data.id) { this.discount = this.wallet.discount this.discount_description = this.wallet.discount_description this.skus.forEach(sku => { sku.price = this.$root.priceLabel(sku.cost, this.discount) }) } } }) }, submitEmail() { axios.put('/api/v4/users/' + this.user.id, { external_email: this.external_email }) .then(response => { if (response.data.status == 'success') { this.$refs.emailDialog.hide() this.$toast.success(response.data.message) this.user.external_email = this.external_email this.external_email = null // required because of Vue } }) }, submitOneOff() { let wallet_id = this.user.wallets[0].id let post = { amount: this.oneoff_amount, description: this.oneoff_description } if (this.oneoff_negative && /^\d+(\.?\d+)?$/.test(post.amount)) { post.amount *= -1 } this.$root.clearFormValidation('#oneoff-dialog') axios.post('/api/v4/wallets/' + wallet_id + '/one-off', post) .then(response => { if (response.data.status == 'success') { this.$refs.oneoffDialog.hide() this.$toast.success(response.data.message) this.wallet = Object.assign({}, this.wallet, {balance: response.data.balance}) this.oneoff_amount = '' this.oneoff_description = '' this.reload() } }) }, suspendUser() { axios.post('/api/v4/users/' + this.user.id + '/suspend') .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.user = Object.assign({}, this.user, { isSuspended: true }) } }) }, unsuspendUser() { axios.post('/api/v4/users/' + this.user.id + '/unsuspend') .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.user = Object.assign({}, this.user, { isSuspended: false }) } }) } } } </script> diff --git a/src/resources/vue/CompanionApp.vue b/src/resources/vue/CompanionApp.vue index 211692de..ac8dc514 100644 --- a/src/resources/vue/CompanionApp.vue +++ b/src/resources/vue/CompanionApp.vue @@ -1,73 +1,59 @@ <template> <div class="container" dusk="companionapp-component"> <div class="card"> <div class="card-body"> <div class="card-title"> {{ $t('companion.title') }} <small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small> </div> <div class="card-text"> <p> {{ $t('companion.description') }} </p> </div> </div> </div> - - <ul class="nav nav-tabs mt-2" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-qrcode" href="#companion-qrcode" role="tab" aria-controls="companion-qrcode" aria-selected="true" @click="$root.tab"> - {{ $t('companion.pair-new') }} - </a> - </li> - <li class="nav-item"> - <a class="nav-link" id="tab-list" href="#companion-list" role="tab" aria-controls="companion-list" aria-selected="false" @click="$root.tab"> - {{ $t('companion.paired') }} - </a> - </li> - </ul> - + <tabs class="mt-3" :tabs="['companion.pair-new','companion.paired']"></tabs> <div class="tab-content"> - <div class="tab-pane active" id="companion-qrcode" role="tabpanel" aria-labelledby="tab-qrcode"> + <div class="tab-pane active" id="new" role="tabpanel" aria-labelledby="tab-new"> <div class="card-body"> <div class="card-text"> <p> {{ $t('companion.pairing-instructions') }} </p> <p> <img :src="qrcode" /> </p> </div> </div> </div> - <div class="tab-pane" id="companion-list" role="tabpanel" aria-labelledby="tab-list"> + <div class="tab-pane" id="paired" role="tabpanel" aria-labelledby="tab-paired"> <div class="card-body"> <companionapp-list class="card-text"></companionapp-list> </div> </div> </div> - </div> </template> <script> import CompanionappList from './Widgets/CompanionappList' export default { components: { CompanionappList }, data() { return { qrcode: "" } }, mounted() { axios.get('/api/v4/companion/pairing', { loading: true }) .then(response => { this.qrcode = response.data.qrcode }) .catch(this.$root.errorHandler) } } </script> diff --git a/src/resources/vue/Distlist/Info.vue b/src/resources/vue/Distlist/Info.vue index 55e2f1cd..9b7609e5 100644 --- a/src/resources/vue/Distlist/Info.vue +++ b/src/resources/vue/Distlist/Info.vue @@ -1,148 +1,137 @@ <template> <div class="container"> <status-component v-if="list_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component> <div class="card" id="distlist-info"> <div class="card-body"> <div class="card-title" v-if="list_id !== 'new'"> {{ $tc('distlist.list-title', 1) }} <btn class="btn-outline-danger button-delete float-end" @click="deleteList()" icon="trash-can">{{ $t('distlist.delete') }}</btn> </div> - <div class="card-title" v-if="list_id === 'new'">{{ $t('distlist.new') }}</div> + <div class="card-title" v-else>{{ $t('distlist.new') }}</div> <div class="card-text"> - <ul class="nav nav-tabs mt-3" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab"> - {{ $t('form.general') }} - </a> - </li> - <li v-if="list_id !== 'new'" class="nav-item"> - <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab"> - {{ $t('form.settings') }} - </a> - </li> - </ul> + <tabs class="mt-3" :tabs="list_id === 'new' ? ['form.general'] : ['form.general','form.settings']"></tabs> <div class="tab-content"> <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general"> <form @submit.prevent="submit" class="card-body"> <div v-if="list_id !== 'new'" class="row plaintext mb-3"> <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label> <div class="col-sm-8"> <span :class="$root.statusClass(list) + ' form-control-plaintext'" id="status">{{ $root.statusText(list) }}</span> </div> </div> <div class="row mb-3"> <label for="name" class="col-sm-4 col-form-label">{{ $t('distlist.name') }}</label> <div class="col-sm-8"> <input type="text" class="form-control" id="name" required v-model="list.name"> </div> </div> <div class="row mb-3"> <label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label> <div class="col-sm-8"> <input type="text" class="form-control" id="email" :disabled="list_id !== 'new'" required v-model="list.email"> </div> </div> <div class="row mb-3"> <label for="members-input" class="col-sm-4 col-form-label">{{ $t('distlist.recipients') }}</label> <div class="col-sm-8"> <list-input id="members" :list="list.members"></list-input> </div> </div> <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn> </form> </div> <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings"> <form @submit.prevent="submitSettings" class="card-body"> <div class="row mb-3"> <label for="sender-policy-input" class="col-sm-4 col-form-label">{{ $t('distlist.sender-policy') }}</label> <div class="col-sm-8 pt-2"> <list-input id="sender-policy" :list="list.config.sender_policy" class="mb-1"></list-input> <small id="sender-policy-hint" class="text-muted"> {{ $t('distlist.sender-policy-text') }} </small> </div> </div> <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn> </form> </div> </div> </div> </div> </div> </div> </template> <script> import ListInput from '../Widgets/ListInput' import StatusComponent from '../Widgets/Status' export default { components: { ListInput, StatusComponent }, data() { return { list_id: null, list: { members: [], config: {} }, status: {} } }, created() { this.list_id = this.$route.params.list if (this.list_id != 'new') { axios.get('/api/v4/groups/' + this.list_id, { loader: true }) .then(response => { this.list = response.data this.status = response.data.statusInfo }) .catch(this.$root.errorHandler) } }, mounted() { $('#name').focus() }, methods: { deleteList() { axios.delete('/api/v4/groups/' + this.list_id) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.$router.push({ name: 'distlists' }) } }) }, statusUpdate(list) { this.list = Object.assign({}, this.list, list) }, submit() { this.$root.clearFormValidation($('#list-info form')) let method = 'post' let location = '/api/v4/groups' if (this.list_id !== 'new') { method = 'put' location += '/' + this.list_id } axios[method](location, this.list) .then(response => { this.$toast.success(response.data.message) this.$router.push({ name: 'distlists' }) }) }, submitSettings() { this.$root.clearFormValidation($('#settings form')) let post = this.list.config axios.post('/api/v4/groups/' + this.list_id + '/config', post) .then(response => { this.$toast.success(response.data.message) }) } } } </script> diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue index 1fa54420..56b6c245 100644 --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -1,207 +1,196 @@ <template> <div class="container"> <status-component v-if="domain_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component> <div class="card"> <div class="card-body"> <div class="card-title" v-if="domain_id === 'new'">{{ $t('domain.new') }}</div> <div class="card-title" v-else>{{ $t('form.domain') }} <btn class="btn-outline-danger button-delete float-end" @click="$refs.deleteDialog.show()" icon="trash-can">{{ $t('domain.delete') }}</btn> </div> <div class="card-text"> - <ul class="nav nav-tabs mt-3" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab"> - {{ $t('form.general') }} - </a> - </li> - <li class="nav-item" v-if="domain.id"> - <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab"> - {{ $t('form.settings') }} - </a> - </li> - </ul> + <tabs class="mt-3" :tabs="domain_id === 'new' ? ['form.general'] : ['form.general','form.settings']"></tabs> <div class="tab-content"> <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general"> <form @submit.prevent="submit" class="card-body"> <div v-if="domain.id" class="row plaintext mb-3"> <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label> <div class="col-sm-8"> <span :class="$root.statusClass(domain) + ' form-control-plaintext'" id="status">{{ $root.statusText(domain) }}</span> </div> </div> <div class="row mb-3"> <label for="name" class="col-sm-4 col-form-label">{{ $t('domain.namespace') }}</label> <div class="col-sm-8"> <input type="text" class="form-control" id="namespace" v-model="domain.namespace" :disabled="domain.id"> </div> </div> <div v-if="!domain.id" id="domain-packages" class="row"> <label class="col-sm-4 col-form-label">{{ $t('user.package') }}</label> <package-select class="col-sm-8 pt-sm-1" type="domain"></package-select> </div> <div v-if="domain.id" id="domain-skus" class="row"> <label class="col-sm-4 col-form-label">{{ $t('user.subscriptions') }}</label> <subscription-select v-if="domain.id" class="col-sm-8 pt-sm-1" type="domain" :object="domain" :readonly="true"></subscription-select> </div> <btn v-if="!domain.id" class="btn-primary mt-3" type="submit" icon="check">{{ $t('btn.submit') }}</btn> </form> <hr class="m-0" v-if="domain.id"> <div v-if="domain.id && !domain.isConfirmed" class="card-body" id="domain-verify"> <h5 class="mb-3">{{ $t('domain.verify') }}</h5> <div class="card-text"> <p>{{ $t('domain.verify-intro') }}</p> <p> <span v-html="$t('domain.verify-dns')"></span> <ul> <li>{{ $t('domain.verify-dns-txt') }} <code>{{ domain.hash_text }}</code></li> <li>{{ $t('domain.verify-dns-cname') }} <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li> </ul> <span>{{ $t('domain.verify-outro') }}</span> </p> <p>{{ $t('domain.verify-sample') }} <pre>{{ domain.dns.join("\n") }}</pre></p> <btn class="btn-primary" @click="confirm" icon="rotate">{{ $t('btn.verify') }}</btn> </div> </div> <div v-if="domain.isConfirmed" class="card-body" id="domain-config"> <h5 class="mb-3">{{ $t('domain.config') }}</h5> <div class="card-text"> <p>{{ $t('domain.config-intro', { app: $root.appName }) }}</p> <p>{{ $t('domain.config-sample') }} <pre>{{ domain.mx.join("\n") }}</pre></p> <p>{{ $t('domain.config-hint') }}</p> </div> </div> </div> <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings"> <div class="card-body"> <form @submit.prevent="submitSettings"> <div class="row mb-3"> <label for="spf_whitelist" class="col-sm-4 col-form-label">{{ $t('domain.spf-whitelist') }}</label> <div class="col-sm-8"> <list-input id="spf_whitelist" name="spf_whitelist" :list="spf_whitelist"></list-input> <small id="spf-hint" class="text-muted d-block mt-2"> {{ $t('domain.spf-whitelist-text') }} <span class="d-block" v-html="$t('domain.spf-whitelist-ex')"></span> </small> </div> </div> <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn> </form> </div> </div> </div> </div> </div> </div> <modal-dialog id="delete-warning" ref="deleteDialog" @click="deleteDomain()" :buttons="['delete']" :cancel-focus="true" :title="$t('domain.delete-domain', { domain: domain.namespace })" > <p>{{ $t('domain.delete-text') }}</p> </modal-dialog> </div> </template> <script> import ListInput from '../Widgets/ListInput' import ModalDialog from '../Widgets/ModalDialog' import PackageSelect from '../Widgets/PackageSelect' import StatusComponent from '../Widgets/Status' import SubscriptionSelect from '../Widgets/SubscriptionSelect' import { library } from '@fortawesome/fontawesome-svg-core' library.add( require('@fortawesome/free-solid-svg-icons/faRotate').definition, ) export default { components: { ListInput, ModalDialog, PackageSelect, StatusComponent, SubscriptionSelect }, data() { return { domain_id: null, domain: {}, spf_whitelist: [], status: {} } }, created() { this.domain_id = this.$route.params.domain if (this.domain_id !== 'new') { axios.get('/api/v4/domains/' + this.domain_id, { loader: true }) .then(response => { this.domain = response.data this.spf_whitelist = this.domain.config.spf_whitelist || [] if (!this.domain.isConfirmed) { $('#domain-verify button').focus() } this.status = response.data.statusInfo }) .catch(this.$root.errorHandler) } }, mounted() { $('#namespace').focus() }, methods: { confirm() { axios.get('/api/v4/domains/' + this.domain_id + '/confirm') .then(response => { if (response.data.status == 'success') { this.domain.isConfirmed = true this.status = response.data.statusInfo } if (response.data.message) { this.$toast[response.data.status](response.data.message) } }) }, deleteDomain() { // Delete the domain from the confirm dialog axios.delete('/api/v4/domains/' + this.domain_id) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.$router.push({ name: 'domains' }) } }) }, statusUpdate(domain) { this.domain = Object.assign({}, this.domain, domain) }, submit() { this.$root.clearFormValidation($('#general form')) let post = this.$root.pick(this.domain, ['namespace']) post.package = $('#domain-packages input:checked').val() axios.post('/api/v4/domains', post) .then(response => { this.$toast.success(response.data.message) this.$router.push({ name: 'domains' }) }) }, submitSettings() { this.$root.clearFormValidation($('#settings form')) const post = this.$root.pick(this, ['spf_whitelist']) axios.post('/api/v4/domains/' + this.domain_id + '/config', post) .then(response => { this.$toast.success(response.data.message) }) } } } </script> diff --git a/src/resources/vue/File/Info.vue b/src/resources/vue/File/Info.vue index 3b8c6c1d..ed56ed72 100644 --- a/src/resources/vue/File/Info.vue +++ b/src/resources/vue/File/Info.vue @@ -1,162 +1,151 @@ <template> <div class="container"> <div class="card" id="file-info"> <div class="card-body"> <div class="card-title"> {{ file.name }} <btn v-if="file.canDelete" class="btn-outline-danger button-delete float-end" @click="fileDelete" icon="trash-can">{{ $t('file.delete') }}</btn> </div> <div class="card-text"> - <ul class="nav nav-tabs mt-3" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab"> - {{ $t('form.general') }} - </a> - </li> - <li class="nav-item" v-if="file.isOwner"> - <a class="nav-link" id="tab-sharing" href="#sharing" role="tab" aria-controls="sharing" aria-selected="false" @click="$root.tab"> - {{ $t('file.sharing') }} - </a> - </li> - </ul> + <tabs class="mt-3" :tabs="file.isOwner ? ['form.general','file.sharing'] : ['form.general']"></tabs> <div class="tab-content"> <form class="tab-pane show active card-body read-only short" id="general" role="tabpanel" aria-labelledby="tab-general"> <div class="row plaintext"> <label for="mimetype" class="col-sm-4 col-form-label">{{ $t('file.mimetype') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="mimetype">{{ file.mimetype }}</span> </div> </div> <div class="row plaintext"> <label for="size" class="col-sm-4 col-form-label">{{ $t('form.size') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="size">{{ api.sizeText(file.size) }}</span> </div> </div> <div class="row plaintext mb-3"> <label for="mtime" class="col-sm-4 col-form-label">{{ $t('file.mtime') }}</label> <div class="col-sm-8"> <span class="form-control-plaintext" id="mtime">{{ file.mtime }}</span> </div> </div> <btn class="btn-primary" icon="download" @click="fileDownload">{{ $t('btn.download') }}</btn> </form> <div v-if="file.isOwner" class="tab-pane card-body" id="sharing" role="tabpanel" aria-labelledby="tab-sharing"> <div id="share-form" class="mb-3"> <div class="row"> <small id="share-links-hint" class="text-muted mb-2"> {{ $t('file.sharing-links-text') }} </small> <div class="input-group"> <input type="text" class="form-control" id="user" :placeholder="$t('form.email')"> <a href="#" class="btn btn-outline-secondary" @click.prevent="shareAdd"> <svg-icon icon="plus"></svg-icon><span class="visually-hidden">{{ $t('btn.add') }}</span> </a> </div> </div> </div> <div id="share-links" class="row m-0" v-if="shares.length"> <div class="list-group p-0"> <div v-for="item in shares" :key="item.id" class="list-group-item"> <div class="d-flex w-100 justify-content-between"> <span class="user lh-lg"> <svg-icon icon="user"></svg-icon> {{ item.user }} </span> <span class="d-inline-block"> <btn class="btn-link p-1" :icon="['far', 'clipboard']" :title="$t('btn.copy')" @click="copyLink(item.link)"></btn> <btn class="btn-link text-danger p-1" icon="trash-can" :title="$t('btn.delete')" @click="shareDelete(item.id)"></btn> </span> </div> <code>{{ item.link }}</code> </div> </div> </div> </div> </div> </div> </div> </div> </div> </template> <script> import FileAPI from '../../js/files.js' import { library } from '@fortawesome/fontawesome-svg-core' library.add( require('@fortawesome/free-regular-svg-icons/faClipboard').definition, require('@fortawesome/free-solid-svg-icons/faDownload').definition, ) export default { data() { return { file: {}, fileId: null, shares: [] } }, created() { this.api = new FileAPI({}) this.fileId = this.$route.params.file axios.get('/api/v4/files/' + this.fileId, { loader: true }) .then(response => { this.file = response.data if (this.file.isOwner) { axios.get('api/v4/files/' + this.fileId + '/permissions') .then(response => { if (response.data.list) { this.shares = response.data.list } }) } }) .catch(this.$root.errorHandler) }, methods: { copyLink(link) { navigator.clipboard.writeText(link); }, fileDelete() { axios.delete('api/v4/files/' + this.fileId) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.$router.push({ name: 'files' }) } }) }, fileDownload() { this.api.fileDownload(this.fileId) }, shareAdd() { let post = { permissions: 'read-only', user: $('#user').val() } if (!post.user) { return } axios.post('api/v4/files/' + this.fileId + '/permissions', post) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.shares.push(response.data) } }) }, shareDelete(id) { axios.delete('api/v4/files/' + this.fileId + '/permissions/' + id) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.$delete(this.shares, this.shares.findIndex(element => element.id == id)) } }) } } } </script> diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/Resource/Info.vue index 1858d648..c266500d 100644 --- a/src/resources/vue/Resource/Info.vue +++ b/src/resources/vue/Resource/Info.vue @@ -1,182 +1,171 @@ <template> <div class="container"> <status-component v-if="resource_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component> <div class="card" id="resource-info"> <div class="card-body"> <div class="card-title" v-if="resource_id !== 'new'"> {{ $tc('resource.list-title', 1) }} <btn class="btn-outline-danger button-delete float-end" @click="deleteResource()" icon="trash-can">{{ $t('resource.delete') }}</btn> </div> <div class="card-title" v-if="resource_id === 'new'">{{ $t('resource.new') }}</div> <div class="card-text"> - <ul class="nav nav-tabs mt-3" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab"> - {{ $t('form.general') }} - </a> - </li> - <li v-if="resource_id !== 'new'" class="nav-item"> - <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab"> - {{ $t('form.settings') }} - </a> - </li> - </ul> + <tabs class="mt-3" :tabs="resource_id === 'new' ? ['form.general'] : ['form.general','form.settings']"></tabs> <div class="tab-content"> <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general"> <form @submit.prevent="submit" class="card-body"> <div v-if="resource_id !== 'new'" class="row plaintext mb-3"> <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label> <div class="col-sm-8"> <span :class="$root.statusClass(resource) + ' form-control-plaintext'" id="status">{{ $root.statusText(resource) }}</span> </div> </div> <div class="row mb-3"> <label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label> <div class="col-sm-8"> <input type="text" class="form-control" id="name" v-model="resource.name"> </div> </div> <div v-if="domains.length" class="row mb-3"> <label for="domain" class="col-sm-4 col-form-label">{{ $t('form.domain') }}</label> <div class="col-sm-8"> <select class="form-select" v-model="resource.domain"> <option v-for="_domain in domains" :key="_domain.id" :value="_domain.namespace">{{ _domain.namespace }}</option> </select> </div> </div> <div v-if="resource.email" class="row mb-3"> <label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label> <div class="col-sm-8"> <input type="text" class="form-control" id="email" disabled v-model="resource.email"> </div> </div> <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn> </form> </div> <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings"> <form @submit.prevent="submitSettings" class="card-body"> <div class="row mb-3"> <label for="invitation_policy" class="col-sm-4 col-form-label">{{ $t('resource.invitation-policy') }}</label> <div class="col-sm-8"> <div class="input-group input-group-select mb-1"> <select class="form-select" id="invitation_policy" v-model="resource.config.invitation_policy" @change="policyChange"> <option value="accept">{{ $t('resource.ipolicy-accept') }}</option> <option value="manual">{{ $t('resource.ipolicy-manual') }}</option> <option value="reject">{{ $t('resource.ipolicy-reject') }}</option> </select> <input type="text" class="form-control" id="owner" v-model="resource.config.owner" :placeholder="$t('form.email')"> </div> <small id="invitation-policy-hint" class="text-muted"> {{ $t('resource.invitation-policy-text') }} </small> </div> </div> <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn> </form> </div> </div> </div> </div> </div> </div> </template> <script> import StatusComponent from '../Widgets/Status' export default { components: { StatusComponent }, data() { return { domains: [], resource_id: null, resource: { config: {} }, status: {} } }, created() { this.resource_id = this.$route.params.resource if (this.resource_id != 'new') { axios.get('/api/v4/resources/' + this.resource_id, { loader: true }) .then(response => { this.resource = response.data this.status = response.data.statusInfo if (this.resource.config.invitation_policy.match(/^manual:(.+)$/)) { this.resource.config.owner = RegExp.$1 this.resource.config.invitation_policy = 'manual' } this.$nextTick().then(() => { this.policyChange() }) }) .catch(this.$root.errorHandler) } else { axios.get('/api/v4/domains', { loader: true }) .then(response => { this.domains = response.data.list this.resource.domain = this.domains[0].namespace }) .catch(this.$root.errorHandler) } }, mounted() { $('#name').focus() }, methods: { deleteResource() { axios.delete('/api/v4/resources/' + this.resource_id) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.$router.push({ name: 'resources' }) } }) }, policyChange() { let select = $('#invitation_policy') select.parent()[select.val() == 'manual' ? 'addClass' : 'removeClass']('selected') }, statusUpdate(resource) { this.resource = Object.assign({}, this.resource, resource) }, submit() { this.$root.clearFormValidation($('#resource-info form')) let method = 'post' let location = '/api/v4/resources' if (this.resource_id !== 'new') { method = 'put' location += '/' + this.resource_id } const post = this.$root.pick(this.resource, ['id', 'name', 'domain']) axios[method](location, post) .then(response => { this.$toast.success(response.data.message) this.$router.push({ name: 'resources' }) }) }, submitSettings() { this.$root.clearFormValidation($('#settings form')) let post = this.$root.pick(this.resource.config, ['invitation_policy', 'owner']) if (post.invitation_policy == 'manual') { post.invitation_policy += ':' + post.owner } delete post.owner axios.post('/api/v4/resources/' + this.resource_id + '/config', post) .then(response => { this.$toast.success(response.data.message) }) } } } </script> diff --git a/src/resources/vue/SharedFolder/Info.vue b/src/resources/vue/SharedFolder/Info.vue index 69c33895..b52a8cc9 100644 --- a/src/resources/vue/SharedFolder/Info.vue +++ b/src/resources/vue/SharedFolder/Info.vue @@ -1,176 +1,165 @@ <template> <div class="container"> <status-component v-if="folder_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component> <div class="card" id="folder-info"> <div class="card-body"> <div class="card-title" v-if="folder_id !== 'new'"> {{ $tc('shf.list-title', 1) }} <btn class="btn-outline-danger button-delete float-end" @click="deleteFolder()" icon="trash-can">{{ $t('shf.delete') }}</btn> </div> <div class="card-title" v-if="folder_id === 'new'">{{ $t('shf.new') }}</div> <div class="card-text"> - <ul class="nav nav-tabs mt-3" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab"> - {{ $t('form.general') }} - </a> - </li> - <li v-if="folder_id !== 'new'" class="nav-item"> - <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab"> - {{ $t('form.settings') }} - </a> - </li> - </ul> + <tabs class="mt-3" :tabs="folder_id === 'new' ? ['form.general'] : ['form.general', 'form.settings']"></tabs> <div class="tab-content"> <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general"> <form @submit.prevent="submit" class="card-body"> <div v-if="folder_id !== 'new'" class="row plaintext mb-3"> <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label> <div class="col-sm-8"> <span :class="$root.statusClass(folder) + ' form-control-plaintext'" id="status">{{ $root.statusText(folder) }}</span> </div> </div> <div class="row mb-3"> <label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label> <div class="col-sm-8"> <input type="text" class="form-control" id="name" v-model="folder.name"> </div> </div> <div class="row mb-3"> <label for="type" class="col-sm-4 col-form-label">{{ $t('form.type') }}</label> <div class="col-sm-8"> <select id="type" class="form-select" v-model="folder.type" :disabled="folder_id !== 'new'"> <option v-for="type in types" :key="type" :value="type">{{ $t('shf.type-' + type) }}</option> </select> </div> </div> <div v-if="domains.length" class="row mb-3"> <label for="domain" class="col-sm-4 col-form-label">{{ $t('form.domain') }}</label> <div v-if="domains.length" class="col-sm-8"> <select class="form-select" v-model="folder.domain"> <option v-for="_domain in domains" :key="_domain.id" :value="_domain.namespace">{{ _domain.namespace }}</option> </select> </div> </div> <div class="row mb-3" v-if="folder.type == 'mail'"> <label for="aliases-input" class="col-sm-4 col-form-label">{{ $t('form.emails') }}</label> <div class="col-sm-8"> <list-input id="aliases" :list="folder.aliases"></list-input> </div> </div> <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn> </form> </div> <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings"> <form @submit.prevent="submitSettings" class="card-body"> <div class="row mb-3"> <label for="acl-input" class="col-sm-4 col-form-label">{{ $t('form.acl') }}</label> <div class="col-sm-8"> <acl-input id="acl" v-model="folder.config.acl" :list="folder.config.acl" class="mb-1"></acl-input> <small id="acl-hint" class="text-muted"> {{ $t('shf.acl-text') }} </small> </div> </div> <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn> </form> </div> </div> </div> </div> </div> </div> </template> <script> import AclInput from '../Widgets/AclInput' import ListInput from '../Widgets/ListInput' import StatusComponent from '../Widgets/Status' export default { components: { AclInput, ListInput, StatusComponent }, data() { return { domains: [], folder_id: null, folder: { type: 'mail', config: {}, aliases: [] }, status: {}, types: [ 'mail', 'event', 'task', 'contact', 'note', 'file' ] } }, created() { this.folder_id = this.$route.params.folder if (this.folder_id != 'new') { axios.get('/api/v4/shared-folders/' + this.folder_id, { loader: true }) .then(response => { this.folder = response.data this.status = response.data.statusInfo }) .catch(this.$root.errorHandler) } else { axios.get('/api/v4/domains', { loader: true }) .then(response => { this.domains = response.data.list this.folder.domain = this.domains[0].namespace }) .catch(this.$root.errorHandler) } }, mounted() { $('#name').focus() }, methods: { deleteFolder() { axios.delete('/api/v4/shared-folders/' + this.folder_id) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.$router.push({ name: 'shared-folders' }) } }) }, statusUpdate(folder) { this.folder = Object.assign({}, this.folder, folder) }, submit() { this.$root.clearFormValidation($('#folder-info form')) let method = 'post' let location = '/api/v4/shared-folders' if (this.folder_id !== 'new') { method = 'put' location += '/' + this.folder_id } const post = this.$root.pick(this.folder, ['id', 'name', 'domain', 'type', 'aliases']) if (post.type != 'mail') { delete post.aliases } axios[method](location, post) .then(response => { this.$toast.success(response.data.message) this.$router.push({ name: 'shared-folders' }) }) }, submitSettings() { this.$root.clearFormValidation($('#settings form')) let post = this.$root.pick(this.folder.config, ['acl']) axios.post('/api/v4/shared-folders/' + this.folder_id + '/config', post) .then(response => { this.$toast.success(response.data.message) }) } } } </script> diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index 1ea85ed6..ea340ece 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,308 +1,297 @@ <template> <div class="container"> <status-component v-if="user_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component> <div class="card" id="user-info"> <div class="card-body"> <div class="card-title" v-if="user_id !== 'new'">{{ $t('user.title') }} <btn icon="trash-can" class="btn-outline-danger button-delete float-end" @click="showDeleteConfirmation()"> {{ $t('user.delete') }} </btn> </div> <div class="card-title" v-if="user_id === 'new'">{{ $t('user.new') }}</div> <div class="card-text"> - <ul class="nav nav-tabs mt-3" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab"> - {{ $t('form.general') }} - </a> - </li> - <li v-if="user_id !== 'new'" class="nav-item"> - <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab"> - {{ $t('form.settings') }} - </a> - </li> - </ul> + <tabs class="mt-3" :tabs="user_id === 'new' ? ['form.general'] : ['form.general','form.settings']"></tabs> <div class="tab-content"> <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general"> <form @submit.prevent="submit" class="card-body"> <div v-if="user_id !== 'new'" class="row plaintext mb-3"> <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label> <div class="col-sm-8"> <span :class="$root.statusClass(user) + ' form-control-plaintext'" id="status">{{ $root.statusText(user) }}</span> </div> </div> <div class="row mb-3"> <label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label> <div class="col-sm-8"> <input type="text" class="form-control" id="first_name" v-model="user.first_name"> </div> </div> <div class="row mb-3"> <label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label> <div class="col-sm-8"> <input type="text" class="form-control" id="last_name" v-model="user.last_name"> </div> </div> <div class="row mb-3"> <label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label> <div class="col-sm-8"> <input type="text" class="form-control" id="organization" v-model="user.organization"> </div> </div> <div class="row mb-3"> <label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label> <div class="col-sm-8"> <input type="text" class="form-control" id="email" :disabled="user_id !== 'new'" required v-model="user.email"> </div> </div> <div class="row mb-3"> - <label for="aliases-input" class="col-sm-4 col-form-label">{{ $t('user.aliases-email') }}</label> + <label for="aliases-input" class="col-sm-4 col-form-label">{{ $t('user.email-aliases') }}</label> <div class="col-sm-8"> <list-input id="aliases" :list="user.aliases"></list-input> </div> </div> <div class="row mb-3"> <label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label> <div class="col-sm-8"> <div v-if="!isSelf" class="btn-group w-100" role="group"> <input type="checkbox" id="pass-mode-input" value="input" class="btn-check" @change="setPasswordMode" :checked="passwordMode == 'input'"> <label class="btn btn-outline-secondary" for="pass-mode-input">{{ $t('user.pass-input') }}</label> <input type="checkbox" id="pass-mode-link" value="link" class="btn-check" @change="setPasswordMode"> <label class="btn btn-outline-secondary" for="pass-mode-link">{{ $t('user.pass-link') }}</label> </div> <password-input v-if="passwordMode == 'input'" :class="isSelf ? '' : 'mt-2'" v-model="user"></password-input> <div id="password-link" v-if="passwordMode == 'link' || user.passwordLinkCode" class="mt-2"> <span>{{ $t('user.pass-link-label') }}</span> <code>{{ passwordLink }}</code> <span class="d-inline-block"> <btn class="btn-link p-1" :icon="['far', 'clipboard']" :title="$t('btn.copy')" @click="passwordLinkCopy"></btn> <btn v-if="user.passwordLinkCode" class="btn-link text-danger p-1" icon="trash-can" :title="$t('btn.delete')" @click="passwordLinkDelete"></btn> </span> <div v-if="!user.passwordLinkCode" class="form-text m-0">{{ $t('user.pass-link-hint') }}</div> </div> </div> </div> <div v-if="user_id === 'new'" id="user-packages" class="row mb-3"> <label class="col-sm-4 col-form-label">{{ $t('user.package') }}</label> <package-select class="col-sm-8 pt-sm-1"></package-select> </div> <div v-if="user_id !== 'new'" id="user-skus" class="row mb-3"> <label class="col-sm-4 col-form-label">{{ $t('user.subscriptions') }}</label> <subscription-select v-if="user.id" class="col-sm-8 pt-sm-1" :object="user"></subscription-select> </div> <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn> </form> </div> <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings"> <form @submit.prevent="submitSettings" class="card-body"> <div class="row checkbox mb-3"> <label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label> <div class="col-sm-8 pt-2"> <input type="checkbox" id="greylist_enabled" name="greylist_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.greylist_enabled"> <small id="greylisting-hint" class="text-muted"> {{ $t('user.greylisting-text') }} </small> </div> </div> <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn> </form> </div> </div> </div> </div> </div> <modal-dialog id="delete-warning" ref="deleteWarning" :buttons="['delete']" :cancel-focus="true" @click="deleteUser()" :title="$t('user.delete-email', { email: user.email })" > <p>{{ $t('user.delete-text') }}</p> </modal-dialog> </div> </template> <script> import ListInput from '../Widgets/ListInput' import ModalDialog from '../Widgets/ModalDialog' import PackageSelect from '../Widgets/PackageSelect' import PasswordInput from '../Widgets/PasswordInput' import StatusComponent from '../Widgets/Status' import SubscriptionSelect from '../Widgets/SubscriptionSelect' import { library } from '@fortawesome/fontawesome-svg-core' library.add( require('@fortawesome/free-regular-svg-icons/faClipboard').definition, ) export default { components: { ListInput, ModalDialog, PackageSelect, PasswordInput, StatusComponent, SubscriptionSelect }, data() { return { passwordLinkCode: '', passwordMode: '', user_id: null, user: { aliases: [], config: [] }, status: {} } }, computed: { isSelf: function () { return this.user_id == this.$root.authInfo.id }, passwordLink: function () { return this.$root.appUrl + '/password-reset/' + this.passwordLinkCode } }, created() { this.user_id = this.$route.params.user if (this.user_id !== 'new') { axios.get('/api/v4/users/' + this.user_id, { loader: true }) .then(response => { this.user = response.data this.user.first_name = response.data.settings.first_name this.user.last_name = response.data.settings.last_name this.user.organization = response.data.settings.organization this.status = response.data.statusInfo this.passwordLinkCode = this.user.passwordLinkCode }) .catch(this.$root.errorHandler) if (this.isSelf) { this.passwordMode = 'input' } } else { this.passwordMode = 'input' } }, mounted() { $('#first_name').focus() }, methods: { passwordLinkCopy() { navigator.clipboard.writeText($('#password-link code').text()); }, passwordLinkDelete() { this.passwordMode = '' $('#pass-mode-link')[0].checked = false // Delete the code for real axios.delete('/api/v4/password-reset/code/' + this.passwordLinkCode) .then(response => { this.passwordLinkCode = '' this.user.passwordLinkCode = '' if (response.data.status == 'success') { this.$toast.success(response.data.message) } }) }, setPasswordMode(event) { const mode = event.target.checked ? event.target.value : '' // In the "new user" mode the password mode cannot be unchecked if (!mode && this.user_id === 'new') { event.target.checked = true return } this.passwordMode = mode if (!event.target.checked) { return } $('#pass-mode-' + (mode == 'link' ? 'input' : 'link'))[0].checked = false // Note: we use $nextTick() because we have to wait for the HTML elements to exist this.$nextTick().then(() => { if (mode == 'link' && !this.passwordLinkCode) { axios.post('/api/v4/password-reset/code', {}, { loader: '#password-link' }) .then(response => { this.passwordLinkCode = response.data.short_code + '-' + response.data.code }) } else if (mode == 'input') { $('#password').focus(); } }) }, submit() { this.$root.clearFormValidation($('#general form')) let method = 'post' let location = '/api/v4/users' let post = this.$root.pick(this.user, ['aliases', 'email', 'first_name', 'last_name', 'organization']) if (this.user_id !== 'new') { method = 'put' location += '/' + this.user_id let skus = {} $('#user-skus input[type=checkbox]:checked').each((idx, input) => { let id = $(input).val() let range = $(input).parents('tr').first().find('input[type=range]').val() skus[id] = range || 1 }) post.skus = skus } else { post.package = $('#user-packages input:checked').val() } if (this.passwordMode == 'link' && this.passwordLinkCode) { post.passwordLinkCode = this.passwordLinkCode } else if (this.passwordMode == 'input') { post.password = this.user.password post.password_confirmation = this.user.password_confirmation } axios[method](location, post) .then(response => { if (response.data.statusInfo) { this.$root.authInfo.statusInfo = response.data.statusInfo } this.$toast.success(response.data.message) this.$router.push({ name: 'users' }) }) }, submitSettings() { this.$root.clearFormValidation($('#settings form')) let post = { greylist_enabled: $('#greylist_enabled').prop('checked') ? 1 : 0 } axios.post('/api/v4/users/' + this.user_id + '/config', post) .then(response => { this.$toast.success(response.data.message) }) }, statusUpdate(user) { this.user = Object.assign({}, this.user, user) }, deleteUser() { // Delete the user from the confirm dialog axios.delete('/api/v4/users/' + this.user_id) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.$router.push({ name: 'users' }) } }) }, showDeleteConfirmation() { if (this.user_id == this.$root.authInfo.id) { // Deleting self, redirect to /profile/delete page this.$router.push({ name: 'profile-delete' }) } else { // Display the warning this.$refs.deleteWarning.show() } } } } </script> diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue index abc7ecb5..8f798197 100644 --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -1,426 +1,410 @@ <template> <div class="container" dusk="wallet-component"> <div v-if="wallet.id" id="wallet" class="card"> <div class="card-body"> <div class="card-title">{{ $t('wallet.title') }} <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'">{{ $root.price(wallet.balance, wallet.currency) }}</span></div> <div class="card-text"> <p v-if="wallet.notice" id="wallet-notice">{{ wallet.notice }}</p> <div v-if="showPendingPayments" class="alert alert-warning"> {{ $t('wallet.pending-payments-warning') }} </div> <p> <btn class="btn-primary" @click="paymentMethodForm('manual')">{{ $t('wallet.add-credit') }}</btn> </p> <div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending"> <template v-if="mandate.id && !mandate.isValid"> <div class="alert alert-danger"> {{ $t('wallet.auto-payment-failed') }} </div> <btn class="btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</btn> </template> <btn class="btn-primary" @click="paymentMethodForm('auto')">{{ $t('wallet.auto-payment-setup') }}</btn> </div> <div id="mandate-info" v-else> <div v-if="mandate.isDisabled" class="disabled-mandate alert alert-danger"> {{ $t('wallet.auto-payment-disabled') }} </div> <template v-else> <p v-html="$t('wallet.auto-payment-info', { amount: mandate.amount + ' ' + wallet.currency, balance: mandate.balance + ' ' + wallet.currency})"></p> <p>{{ $t('wallet.payment-method', { method: mandate.method }) }}</p> </template> <div v-if="mandate.isPending" class="alert alert-warning"> {{ $t('wallet.auto-payment-inprogress') }} </div> <p class="buttons"> <btn class="btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</btn> <btn class="btn-primary" @click="autoPaymentChange">{{ $t('wallet.auto-payment-change') }}</btn> </p> </div> </div> </div> </div> - <ul class="nav nav-tabs mt-3" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="tab-receipts" href="#wallet-receipts" role="tab" aria-controls="wallet-receipts" aria-selected="true"> - {{ $t('wallet.receipts') }} - </a> - </li> - <li class="nav-item"> - <a class="nav-link" id="tab-history" href="#wallet-history" role="tab" aria-controls="wallet-history" aria-selected="false"> - {{ $t('wallet.history') }} - </a> - </li> - <li v-if="showPendingPayments" class="nav-item"> - <a class="nav-link" id="tab-payments" href="#wallet-payments" role="tab" aria-controls="wallet-payments" aria-selected="false"> - {{ $t('wallet.pending-payments') }} - </a> - </li> - </ul> + <tabs class="mt-3" ref="tabs" :tabs="tabs"></tabs> + <div class="tab-content"> - <div class="tab-pane active" id="wallet-receipts" role="tabpanel" aria-labelledby="tab-receipts"> + <div class="tab-pane active" id="receipts" role="tabpanel" aria-labelledby="tab-receipts"> <div class="card-body"> <div class="card-text"> <p v-if="receipts.length"> {{ $t('wallet.receipts-hint') }} </p> <div v-if="receipts.length" class="input-group"> <select id="receipt-id" class="form-control"> <option v-for="(receipt, index) in receipts" :key="index" :value="receipt">{{ receipt }}</option> </select> <btn class="btn-secondary" @click="receiptDownload" icon="download">{{ $t('btn.download') }}</btn> </div> <p v-if="!receipts.length"> {{ $t('wallet.receipts-none') }} </p> </div> </div> </div> - <div class="tab-pane" id="wallet-history" role="tabpanel" aria-labelledby="tab-history"> + <div class="tab-pane" id="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="tab-pane" id="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> <modal-dialog id="payment-dialog" ref="paymentDialog" :title="paymentDialogTitle" @click="payment" :buttons="dialogButtons"> <div id="payment-method" v-if="paymentForm == 'method'"> <form data-validation-prefix="mandate_"> <div id="payment-method-selection"> <a v-for="method in paymentMethods" :key="method.id" @click="selectPaymentMethod(method)" href="#" :class="'card link-' + method.id"> <svg-icon v-if="method.icon" :icon="[method.icon.prefix, method.icon.name]" /> <img v-if="method.image" :src="method.image" /> <span class="name">{{ method.name }}</span> </a> </div> </form> </div> <div id="manual-payment" v-if="paymentForm == 'manual'"> <p v-if="wallet.currency != selectedPaymentMethod.currency"> {{ $t('wallet.currency-conv', { wc: wallet.currency, pc: selectedPaymentMethod.currency }) }} </p> <p v-if="selectedPaymentMethod.id == 'banktransfer'"> {{ $t('wallet.banktransfer-hint') }} </p> <p> {{ $t('wallet.payment-amount-hint') }} </p> <form id="payment-form" @submit.prevent="payment"> <div class="input-group"> <input type="text" class="form-control" id="amount" v-model="amount" required> <span class="input-group-text">{{ wallet.currency }}</span> </div> <div v-if="wallet.currency != selectedPaymentMethod.currency && !isNaN(amount)" class="alert alert-warning m-0 mt-3"> {{ $t('wallet.payment-warning', { price: $root.price(amount * selectedPaymentMethod.exchangeRate * 100, selectedPaymentMethod.currency) }) }} </div> </form> </div> <div id="auto-payment" v-if="paymentForm == 'auto'"> <form data-validation-prefix="mandate_"> <p> {{ $t('wallet.auto-payment-hint') }} </p> <div class="row mb-3"> <label for="mandate_amount" class="col-sm-6 col-form-label">{{ $t('wallet.fill-up') }}</label> <div class="col-sm-6"> <div class="input-group"> <input type="text" class="form-control" id="mandate_amount" v-model="mandate.amount" required> <span class="input-group-text">{{ wallet.currency }}</span> </div> </div> </div> <div class="row mb-3"> <label for="mandate_balance" class="col-sm-6 col-form-label">{{ $t('wallet.when-below') }}</label> <div class="col-sm-6"> <div class="input-group"> <input type="text" class="form-control" id="mandate_balance" v-model="mandate.balance" required> <span class="input-group-text">{{ wallet.currency }}</span> </div> </div> </div> <p v-if="!mandate.isValid"> {{ $t('wallet.auto-payment-next') }} </p> <div v-if="mandate.isValid && mandate.isDisabled" class="disabled-mandate alert alert-danger m-0"> {{ $t('wallet.auto-payment-disabled-next') }} </div> </form> </div> </modal-dialog> </div> </template> <script> import ModalDialog from './Widgets/ModalDialog' import TransactionLog from './Widgets/TransactionLog' import PaymentLog from './Widgets/PaymentLog' import { downloadFile } from '../js/utils' import { library } from '@fortawesome/fontawesome-svg-core' library.add( require('@fortawesome/free-brands-svg-icons/faPaypal').definition, require('@fortawesome/free-regular-svg-icons/faCreditCard').definition, require('@fortawesome/free-solid-svg-icons/faBuildingColumns').definition, ) export default { components: { ModalDialog, TransactionLog, PaymentLog }, data() { return { amount: '', mandate: { amount: 10, balance: 0, method: null }, paymentDialogTitle: null, paymentForm: null, nextForm: null, receipts: [], stripe: null, loadTransactions: false, loadPayments: false, showPendingPayments: false, wallet: {}, walletId: null, paymentMethods: [], selectedPaymentMethod: null } }, computed: { dialogButtons() { if (this.paymentForm == 'method') { return [] } const button = { className: 'btn-primary modal-action', icon: 'check', label: 'btn.submit' } if (this.paymentForm == 'manual' || (this.paymentForm == 'auto' && !this.mandate.isValid && !this.mandate.isPending) ) { button.label = 'btn.continue' } return [ button ] + }, + tabs() { + let tabs = [ 'wallet.receipts', 'wallet.history' ] + if (this.showPendingPayments) { + tabs.push('wallet.pending-payments') + } + return tabs } }, mounted() { $('#wallet button').focus() this.walletId = this.$root.authInfo.wallets[0].id axios.get('/api/v4/wallets/' + this.walletId, { loader: true }) .then(response => { this.wallet = response.data - axios.get('/api/v4/wallets/' + this.walletId + '/receipts', { loader: '#wallet-receipts' }) + axios.get('/api/v4/wallets/' + this.walletId + '/receipts', { loader: '#receipts' }) .then(response => { this.receipts = response.data.list }) if (this.wallet.provider == 'stripe') { this.stripeInit() } }) .catch(this.$root.errorHandler) this.loadMandate() axios.get('/api/v4/payments/has-pending') .then(response => { this.showPendingPayments = response.data.hasPending }) - }, - updated() { - $(this.$el).find('ul.nav-tabs a').on('click', e => { - this.$root.tab(e) - - if ($(e.target).is('#tab-history')) { - this.loadTransactions = true - } else if ($(e.target).is('#tab-payments')) { - this.loadPayments = true - } - }) + + this.$refs.tabs.clickHandler('history', () => { this.loadTransactions = true }) + this.$refs.tabs.clickHandler('payments', () => { this.loadPayments = true }) }, methods: { loadMandate() { const loader = '#mandate-form' this.$root.stopLoading(loader) if (!this.mandate.id || this.mandate.isPending) { axios.get('/api/v4/payments/mandate', { loader }) .then(response => { this.mandate = response.data }) } }, selectPaymentMethod(method) { this.formLock = false this.selectedPaymentMethod = method this.paymentForm = this.nextForm setTimeout(() => { $('#payment-dialog').find('#amount,#mandate_amount').focus() }, 10) }, payment() { if (this.paymentForm == 'auto') { return this.autoPayment() } if (this.formLock) { return } // Lock the form to prevent from double submission this.formLock = true let onFinish = () => { this.formLock = false } this.$root.clearFormValidation($('#payment-form')) const post = { amount: this.amount, methodId: this.selectedPaymentMethod.id, currency: this.selectedPaymentMethod.currency } axios.post('/api/v4/payments', post, { onFinish }) .then(response => { if (response.data.redirectUrl) { location.href = response.data.redirectUrl } else { this.stripeCheckout(response.data) } }) }, autoPayment() { if (this.formLock) { return } // Lock the form to prevent from double submission this.formLock = true let onFinish = () => { this.formLock = false } const method = this.mandate.id && (this.mandate.isValid || this.mandate.isPending) ? 'put' : 'post' let post = { amount: this.mandate.amount, balance: this.mandate.balance, } // Modifications can't change the method of payment if (this.selectedPaymentMethod) { post.methodId = this.selectedPaymentMethod.id post.currency = this.selectedPaymentMethod.currency } this.$root.clearFormValidation($('#auto-payment form')) axios[method]('/api/v4/payments/mandate', post, { onFinish }) .then(response => { if (method == 'post') { this.mandate.id = null // a new mandate, redirect to the chackout page if (response.data.redirectUrl) { location.href = response.data.redirectUrl } else if (response.data.id) { this.stripeCheckout(response.data) } } else { // an update if (response.data.status == 'success') { this.$refs.paymentDialog.hide(); this.mandate = response.data this.$toast.success(response.data.message) } } }) }, autoPaymentChange(event) { this.autoPaymentForm(event, this.$t('wallet.auto-payment-update')) }, autoPaymentDelete() { axios.delete('/api/v4/payments/mandate') .then(response => { this.mandate = { amount: 10, balance: 0 } if (response.data.status == 'success') { this.$toast.success(response.data.message) } }) }, paymentMethodForm(nextForm) { this.formLock = false this.paymentMethods = [] this.paymentForm = 'method' this.nextForm = nextForm this.paymentDialogTitle = this.$t(nextForm == 'auto' ? 'wallet.auto-payment-setup' : 'wallet.top-up') this.$refs.paymentDialog.show() this.$nextTick().then(() => { const type = nextForm == 'manual' ? 'oneoff' : 'recurring' const loader = ['#payment-method', { 'min-height': '10em', small: false }] axios.get('/api/v4/payments/methods', { params: { type }, loader }) .then(response => { this.paymentMethods = response.data }) }) }, autoPaymentForm(event, title) { this.paymentForm = 'auto' this.paymentDialogTitle = title this.formLock = false this.$refs.paymentDialog.show() }, receiptDownload() { const receipt = $('#receipt-id').val() downloadFile('/api/v4/wallets/' + this.walletId + '/receipts/' + receipt) }, stripeInit() { let script = $('#stripe-script') if (!script.length) { script = document.createElement('script') script.onload = () => { this.stripe = Stripe(window.config.stripePK) } script.id = 'stripe-script' script.src = 'https://js.stripe.com/v3/' document.getElementsByTagName('head')[0].appendChild(script) } else { this.stripe = Stripe(window.config.stripePK) } }, stripeCheckout(data) { if (!this.stripe) { return } this.stripe.redirectToCheckout({ sessionId: data.id }).then(result => { // If it fails due to a browser or network error, // display the localized error message to the user if (result.error) { this.$toast.error(result.error.message) } }) } } } </script> diff --git a/src/resources/vue/Widgets/Tabs.vue b/src/resources/vue/Widgets/Tabs.vue new file mode 100644 index 00000000..5ca7fd3a --- /dev/null +++ b/src/resources/vue/Widgets/Tabs.vue @@ -0,0 +1,65 @@ +<template> + <ul class="nav nav-tabs" role="tablist"> + <li v-for="(tab, index) in tabs" :key="index" class="nav-item"> + <a role="tab" :aria-controls="tabKey(tab)" :aria-selected="!index" @click="tabClick" + :class="'nav-link' + (!index ? ' active' : '')" + :id="'tab-' + tabKey(tab)" + :href="'#' + tabKey(tab)" + > + {{ $t(tabLabel(tab)) + (typeof tab != 'string' && 'count' in tab ? ` (${tab.count})` : '') }} + </a> + </li> + </ul> +</template> + +<script> + import { Tab } from 'bootstrap' + + export default { + props: { + tabs: { type: Array, default: () => [] } + }, + data() { + return { + clickHandlers: {} + } + }, + methods: { + tabClick(event) { + event.preventDefault() + + new Tab(event.target).show() + + const key = event.target.id.replace('tab-', '') + + if (key in this.clickHandlers) { + this.clickHandlers[key](event) + } + }, + tabKey(tab) { + return this.tabLabel(tab).split(/[.-]/).slice(-1) + }, + tabIndex(key) { + return this.tabs.findIndex(tab => this.tabKey(tab) == key) + }, + tabLabel(tab) { + return typeof tab == 'string' ? tab : tab.label + }, + updateCounter(key, count) { + const index = this.tabIndex(key) + let tab = this.tabs[index] + + if (typeof tab == 'string') { + tab = { label: tab, count } + } else { + tab.count = count + } + + this.$set(this.tabs, index, tab) + }, + clickHandler(key, callback) { + this.clickHandlers[key] = callback + } + } + } +</script> diff --git a/src/tests/Browser/Admin/SharedFolderTest.php b/src/tests/Browser/Admin/SharedFolderTest.php index e836c482..7a8ea651 100644 --- a/src/tests/Browser/Admin/SharedFolderTest.php +++ b/src/tests/Browser/Admin/SharedFolderTest.php @@ -1,109 +1,109 @@ <?php namespace Tests\Browser\Admin; use App\SharedFolder; use Illuminate\Support\Facades\Queue; use Tests\Browser; use Tests\Browser\Components\Toast; use Tests\Browser\Pages\Admin\SharedFolder as SharedFolderPage; use Tests\Browser\Pages\Admin\User as UserPage; use Tests\Browser\Pages\Dashboard; use Tests\Browser\Pages\Home; use Tests\TestCaseDusk; class SharedFolderTest extends TestCaseDusk { /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); self::useAdminUrl(); } /** * {@inheritDoc} */ public function tearDown(): void { parent::tearDown(); } /** * Test shared folder info page (unauthenticated) */ public function testSharedFolderUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $browser->visit('/shared-folder/' . $folder->id)->on(new Home()); }); } /** * Test shared folder info page */ public function testInfo(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]); $folder->setAliases(['folder-alias1@kolab.org', 'folder-alias2@kolab.org']); $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; $folder->save(); $folder_page = new SharedFolderPage($folder->id); $user_page = new UserPage($user->id); // Goto the folder page $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) - ->click('@nav #tab-shared-folders') + ->click('@nav #tab-folders') ->pause(1000) - ->click('@user-shared-folders table tbody tr:first-child td:first-child a') + ->click('@user-folders table tbody tr:first-child td:first-child a') ->on($folder_page) ->assertSeeIn('@folder-info .card-title', $folder->email) ->with('@folder-info form', function (Browser $browser) use ($folder) { $browser->assertElementsCount('.row', 4) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #folderid', "{$folder->id} ({$folder->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'Name') ->assertSeeIn('.row:nth-child(3) #name', $folder->name) ->assertSeeIn('.row:nth-child(4) label', 'Type') ->assertSeeIn('.row:nth-child(4) #type', 'Calendar'); }) ->assertElementsCount('ul.nav-tabs .nav-item', 2) ->assertSeeIn('ul.nav-tabs .nav-item:nth-child(1) .nav-link', 'Settings') ->with('@folder-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Access rights') ->assertSeeIn('.row:nth-child(1) #acl', 'anyone: read-only') ->assertSeeIn('.row:nth-child(1) #acl', 'jack@kolab.org: read-write'); }) ->assertSeeIn('ul.nav-tabs .nav-item:nth-child(2) .nav-link', 'Email Aliases (2)') ->click('ul.nav-tabs .nav-item:nth-child(2) .nav-link') ->with('@folder-aliases table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) td', 'folder-alias1@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) td', 'folder-alias2@kolab.org'); }); // Test invalid shared folder identifier $browser->visit('/shared-folder/abc')->assertErrorPage(404); }); } } diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php index 310b2a4c..9bc0aa0b 100644 --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -1,604 +1,604 @@ <?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', \App\Utils::generatePassphrase(), 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)') ->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) #first_name', 'Jack') ->assertSeeIn('.row:nth-child(5) label', 'Last Name') ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') ->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', 9); // 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', '5,00 CHF') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 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', '4,90 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.'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); // Assert Shared folders tab - $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)') - ->click('@nav #tab-shared-folders') - ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertSeeIn('@nav #tab-folders', 'Shared folders (0)') + ->click('@nav #tab-folders') + ->with('@user-folders', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-success', 'enabled'); }); }); } /** * 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', ['name' => 'Test Group']); $group->assignToWallet($john->wallets->first()); $john->setSetting('greylist_enabled', null); // 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)') ->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) #first_name', 'John') ->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) #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', 9); // 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', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 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,41 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 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'); }); // 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', 'Test Group') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger') ->assertSeeIn('tbody tr:nth-child(1) td:last-child a', 'group-test@kolab.org') ->assertMissing('tfoot'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (2)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 2) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Conference Room #1') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'resource-test1@kolab.org') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Conference Room #2') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'resource-test2@kolab.org') ->assertMissing('table tfoot'); }); // Assert Shared folders tab - $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (2)') - ->click('@nav #tab-shared-folders') - ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertSeeIn('@nav #tab-folders', 'Shared folders (2)') + ->click('@nav #tab-folders') + ->with('@user-folders', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 2) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar') ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts') ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org') ->assertMissing('table 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::withEnvTenantContext()->where('title', 'beta')->first(); $storage_sku = Sku::withEnvTenantContext()->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); $ned->setSetting('greylist_enabled', 'false'); $browser->click('@nav #tab-users') ->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)') ->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', 9); // 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', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 6 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,41 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 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') ->assertMissing('#addbetasku'); }); // 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.'); }); // We don't expect John's resources here $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); // We don't expect John's folders here - $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)') - ->click('@nav #tab-shared-folders') - ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertSeeIn('@nav #tab-folders', 'Shared folders (0)') + ->click('@nav #tab-folders') + ->with('@user-folders', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-danger', 'disabled'); }); }); } /** * 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') ->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::withEnvTenantContext()->where('title', '2fa')->first(); $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)'); }); } /** * Test adding the beta SKU for the user */ public function testAddBetaSku(): void { $this->browse(function (Browser $browser) { $this->deleteTestUser('userstest1@kolabnow.com'); $user = $this->getTestUser('userstest1@kolabnow.com'); $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $browser->visit(new UserPage($user->id)) ->click('@nav #tab-subscriptions') ->waitFor('@user-subscriptions #addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)') ->assertSeeIn('#addbetasku', 'Enable beta program') ->click('#addbetasku') ->assertToast(Toast::TYPE_SUCCESS, 'The subscription added successfully.') ->waitFor('#sku' . $sku->id) ->assertSeeIn("#sku{$sku->id} td:first-child", 'Private Beta (invitation only)') ->assertSeeIn("#sku{$sku->id} td:last-child", '0,00 CHF/month') ->assertMissing('#addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)'); }); } } diff --git a/src/tests/Browser/Pages/Admin/Distlist.php b/src/tests/Browser/Pages/Admin/Distlist.php index e1edd794..ecec0484 100644 --- a/src/tests/Browser/Pages/Admin/Distlist.php +++ b/src/tests/Browser/Pages/Admin/Distlist.php @@ -1,57 +1,57 @@ <?php namespace Tests\Browser\Pages\Admin; use Laravel\Dusk\Page; class Distlist extends Page { protected $listid; /** * Object constructor. * * @param int $listid Distribution list Id */ public function __construct($listid) { $this->listid = $listid; } /** * Get the URL for the page. * * @return string */ public function url(): string { return '/distlist/' . $this->listid; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser): void { $browser->waitForLocation($this->url()) ->waitFor('@distlist-info'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@distlist-info' => '#distlist-info', - '@distlist-settings' => '#distlist-settings', + '@distlist-settings' => '#settings', ]; } } diff --git a/src/tests/Browser/Pages/Admin/Domain.php b/src/tests/Browser/Pages/Admin/Domain.php index a7f9a20a..c8d81ee7 100644 --- a/src/tests/Browser/Pages/Admin/Domain.php +++ b/src/tests/Browser/Pages/Admin/Domain.php @@ -1,59 +1,59 @@ <?php namespace Tests\Browser\Pages\Admin; use Laravel\Dusk\Page; class Domain extends Page { protected $domainid; /** * Object constructor. * * @param int $domainid Domain Id */ public function __construct($domainid) { $this->domainid = $domainid; } /** * Get the URL for the page. * * @return string */ public function url(): string { return '/domain/' . $this->domainid; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser): void { $browser->waitForLocation($this->url()) ->waitFor('@domain-info'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@domain-info' => '#domain-info', '@nav' => 'ul.nav-tabs', - '@domain-config' => '#domain-config', - '@domain-settings' => '#domain-settings', + '@domain-config' => '#config', + '@domain-settings' => '#settings', ]; } } diff --git a/src/tests/Browser/Pages/Admin/Resource.php b/src/tests/Browser/Pages/Admin/Resource.php index cfe6269f..4123ac11 100644 --- a/src/tests/Browser/Pages/Admin/Resource.php +++ b/src/tests/Browser/Pages/Admin/Resource.php @@ -1,57 +1,57 @@ <?php namespace Tests\Browser\Pages\Admin; use Laravel\Dusk\Page; class Resource extends Page { protected $resourceId; /** * Object constructor. * * @param int $id Resource Id */ public function __construct($id) { $this->resourceId = $id; } /** * Get the URL for the page. * * @return string */ public function url(): string { return '/resource/' . $this->resourceId; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser): void { $browser->waitForLocation($this->url()) ->waitFor('@resource-info'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@resource-info' => '#resource-info', - '@resource-settings' => '#resource-settings', + '@resource-settings' => '#settings', ]; } } diff --git a/src/tests/Browser/Pages/Admin/SharedFolder.php b/src/tests/Browser/Pages/Admin/SharedFolder.php index 9aed148a..3080f8f7 100644 --- a/src/tests/Browser/Pages/Admin/SharedFolder.php +++ b/src/tests/Browser/Pages/Admin/SharedFolder.php @@ -1,58 +1,58 @@ <?php namespace Tests\Browser\Pages\Admin; use Laravel\Dusk\Page; class SharedFolder extends Page { protected $folderId; /** * Object constructor. * * @param int $id Shared folder Id */ public function __construct($id) { $this->folderId = $id; } /** * Get the URL for the page. * * @return string */ public function url(): string { return '/shared-folder/' . $this->folderId; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser): void { $browser->waitForLocation($this->url()) ->waitFor('@folder-info'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@folder-info' => '#folder-info', - '@folder-settings' => '#folder-settings', - '@folder-aliases' => '#folder-aliases', + '@folder-settings' => '#settings', + '@folder-aliases' => '#aliases', ]; } } diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php index c58b774b..08ebb762 100644 --- a/src/tests/Browser/Pages/Admin/User.php +++ b/src/tests/Browser/Pages/Admin/User.php @@ -1,67 +1,67 @@ <?php namespace Tests\Browser\Pages\Admin; use Laravel\Dusk\Page; class User extends Page { protected $userid; /** * Object constructor. * * @param int $userid User Id */ public function __construct($userid) { $this->userid = $userid; } /** * Get the URL for the page. * * @return string */ public function url(): string { return '/user/' . $this->userid; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser): void { $browser->waitForLocation($this->url()) ->waitUntilMissing('@app .app-loader') ->waitFor('@user-info'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@user-info' => '#user-info', '@nav' => 'ul.nav-tabs', - '@user-finances' => '#user-finances', - '@user-aliases' => '#user-aliases', - '@user-subscriptions' => '#user-subscriptions', - '@user-distlists' => '#user-distlists', - '@user-domains' => '#user-domains', - '@user-resources' => '#user-resources', - '@user-shared-folders' => '#user-shared-folders', - '@user-users' => '#user-users', - '@user-settings' => '#user-settings', + '@user-finances' => '#finances', + '@user-aliases' => '#aliases', + '@user-subscriptions' => '#subscriptions', + '@user-distlists' => '#distlists', + '@user-domains' => '#domains', + '@user-resources' => '#resources', + '@user-folders' => '#folders', + '@user-users' => '#users', + '@user-settings' => '#settings', ]; } } diff --git a/src/tests/Browser/Pages/Wallet.php b/src/tests/Browser/Pages/Wallet.php index 4f28786d..62aaf253 100644 --- a/src/tests/Browser/Pages/Wallet.php +++ b/src/tests/Browser/Pages/Wallet.php @@ -1,49 +1,50 @@ <?php namespace Tests\Browser\Pages; use Laravel\Dusk\Page; class Wallet extends Page { /** * Get the URL for the page. * * @return string */ public function url(): string { return '/wallet'; } /** * 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('#wallet .card-title', 'Account balance'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@main' => '#wallet', '@payment-dialog' => '#payment-dialog', '@nav' => 'ul.nav-tabs', - '@history-tab' => '#wallet-history', - '@receipts-tab' => '#wallet-receipts', + '@history-tab' => '#history', + '@receipts-tab' => '#receipts', + '@payments-tab' => '#payments', ]; } } diff --git a/src/tests/Browser/Reseller/PaymentMollieTest.php b/src/tests/Browser/Reseller/PaymentMollieTest.php index 5859fe94..bdfb7efc 100644 --- a/src/tests/Browser/Reseller/PaymentMollieTest.php +++ b/src/tests/Browser/Reseller/PaymentMollieTest.php @@ -1,116 +1,116 @@ <?php namespace Tests\Browser\Reseller; 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(); self::useResellerUrl(); } /** * {@inheritDoc} */ public function tearDown(): void { $user = $this->getTestUser('reseller@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); $wallet->balance = 0; $wallet->save(); parent::tearDown(); } /** * Test the payment process * * @group mollie */ public function testPayment(): void { $this->browse(function (Browser $browser) { $user = $this->getTestUser('reseller@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); $wallet->balance = 0; $wallet->save(); $browser->visit(new Home()) ->submitLogon($user->email, \App\Utils::generatePassphrase(), 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') - ->waitFor('#payment-method-selection #banktransfer') - ->click('#creditcard'); + ->waitFor('#payment-method-selection .link-creditcard svg') + ->waitFor('#payment-method-selection .link-paypal svg') + ->waitFor('#payment-method-selection .link-banktransfer svg') + ->click('#payment-method-selection .link-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', $user->tenant->title . ' Payment') ->assertSeeIn('@amount', 'CHF 12.34'); $this->assertSame(1, $wallet->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'); }); } } diff --git a/src/tests/Browser/Reseller/SharedFolderTest.php b/src/tests/Browser/Reseller/SharedFolderTest.php index 5d1bed71..555915a1 100644 --- a/src/tests/Browser/Reseller/SharedFolderTest.php +++ b/src/tests/Browser/Reseller/SharedFolderTest.php @@ -1,109 +1,109 @@ <?php namespace Tests\Browser\Reseller; use App\SharedFolder; use Illuminate\Support\Facades\Queue; use Tests\Browser; use Tests\Browser\Components\Toast; use Tests\Browser\Pages\Admin\SharedFolder as SharedFolderPage; use Tests\Browser\Pages\Admin\User as UserPage; use Tests\Browser\Pages\Dashboard; use Tests\Browser\Pages\Home; use Tests\TestCaseDusk; class SharedFolderTest extends TestCaseDusk { /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); self::useResellerUrl(); } /** * {@inheritDoc} */ public function tearDown(): void { parent::tearDown(); } /** * Test shared folder info page (unauthenticated) */ public function testSharedFolderUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $browser->visit('/shared-folder/' . $folder->id)->on(new Home()); }); } /** * Test shared folder info page */ public function testInfo(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]); $folder->setAliases(['folder-alias1@kolab.org', 'folder-alias2@kolab.org']); $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; $folder->save(); $folder_page = new SharedFolderPage($folder->id); $user_page = new UserPage($user->id); // Goto the folder page $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) - ->click('@nav #tab-shared-folders') + ->click('@nav #tab-folders') ->pause(1000) - ->click('@user-shared-folders table tbody tr:first-child td:first-child a') + ->click('@user-folders table tbody tr:first-child td:first-child a') ->on($folder_page) ->assertSeeIn('@folder-info .card-title', $folder->email) ->with('@folder-info form', function (Browser $browser) use ($folder) { $browser->assertElementsCount('.row', 4) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #folderid', "{$folder->id} ({$folder->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'Name') ->assertSeeIn('.row:nth-child(3) #name', $folder->name) ->assertSeeIn('.row:nth-child(4) label', 'Type') ->assertSeeIn('.row:nth-child(4) #type', 'Calendar'); }) ->assertElementsCount('ul.nav-tabs .nav-item', 2) ->assertSeeIn('ul.nav-tabs .nav-item:nth-child(1) .nav-link', 'Settings') ->with('@folder-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Access rights') ->assertSeeIn('.row:nth-child(1) #acl', 'anyone: read-only') ->assertSeeIn('.row:nth-child(1) #acl', 'jack@kolab.org: read-write'); }) ->assertSeeIn('ul.nav-tabs .nav-item:nth-child(2) .nav-link', 'Email Aliases (2)') ->click('ul.nav-tabs .nav-item:nth-child(2) .nav-link') ->with('@folder-aliases table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) td', 'folder-alias1@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) td', 'folder-alias2@kolab.org'); }); // Test invalid shared folder identifier $browser->visit('/shared-folder/abc')->assertErrorPage(404); }); } } diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php index c891975e..46b4dd0a 100644 --- a/src/tests/Browser/Reseller/UserTest.php +++ b/src/tests/Browser/Reseller/UserTest.php @@ -1,577 +1,577 @@ <?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@' . \config('app.domain'), \App\Utils::generatePassphrase(), 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)') ->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) #first_name', 'Jack') ->assertSeeIn('.row:nth-child(5) label', 'Last Name') ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') ->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', 9); // 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', '5,00 CHF/month') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 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,90 CHF/month') ->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.'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); // Assert Shared folders tab - $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)') - ->click('@nav #tab-shared-folders') - ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertSeeIn('@nav #tab-folders', 'Shared folders (0)') + ->click('@nav #tab-folders') + ->with('@user-folders', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-success', 'enabled'); }); }); } /** * 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', ['name' => 'Test Group']); $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)') ->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) #first_name', 'John') ->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) #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', 9); // 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', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 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,41 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }); // 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'); }); // 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', 'Test Group') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger') ->assertSeeIn('tbody tr:nth-child(1) td:last-child a', 'group-test@kolab.org') ->assertMissing('tfoot'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (2)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 2) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Conference Room #1') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'resource-test1@kolab.org') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Conference Room #2') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'resource-test2@kolab.org') ->assertMissing('table tfoot'); }); // Assert Shared folders tab - $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (2)') - ->click('@nav #tab-shared-folders') - ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertSeeIn('@nav #tab-folders', 'Shared folders (2)') + ->click('@nav #tab-folders') + ->with('@user-folders', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 2) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar') ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts') ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org') ->assertMissing('table 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'); $ned->setSetting('greylist_enabled', 'false'); $page = new UserPage($ned->id); $browser->click('@nav #tab-users') ->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)') ->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', 9); // 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', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 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,41 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 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.'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); // Assert Shared folders tab - $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)') - ->click('@nav #tab-shared-folders') - ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertSeeIn('@nav #tab-folders', 'Shared folders (0)') + ->click('@nav #tab-folders') + ->with('@user-folders', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-danger', 'disabled'); }); }); } /** * 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') ->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::withEnvTenantContext()->where(['title' => '2fa'])->first(); $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)'); }); } /** * Test adding the beta SKU for the user */ public function testAddBetaSku(): void { $this->browse(function (Browser $browser) { $this->deleteTestUser('userstest1@kolabnow.com'); $user = $this->getTestUser('userstest1@kolabnow.com'); $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $browser->visit(new UserPage($user->id)) ->click('@nav #tab-subscriptions') ->waitFor('@user-subscriptions #addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)') ->assertSeeIn('#addbetasku', 'Enable beta program') ->click('#addbetasku') ->assertToast(Toast::TYPE_SUCCESS, 'The subscription added successfully.') ->waitFor('#sku' . $sku->id) ->assertSeeIn("#sku{$sku->id} td:first-child", 'Private Beta (invitation only)') ->assertSeeIn("#sku{$sku->id} td:last-child", '0,00 CHF/month') ->assertMissing('#addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)'); }); } }