diff --git a/src/app/Utils.php b/src/app/Utils.php index 50fa6341..3eb059f2 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,164 +1,164 @@ toString(); } private static function combine($input, $r, $index, $data, $i, &$output): void { $n = count($input); // Current cobination is ready if ($index == $r) { $output[] = array_slice($data, 0, $r); return; } // When no more elements are there to put in data[] if ($i >= $n) { return; } // current is included, put next at next location $data[$index] = $input[$i]; self::combine($input, $r, $index + 1, $data, $i + 1, $output); // current is excluded, replace it with next (Note that i+1 // is passed, but index is not changed) self::combine($input, $r, $index, $data, $i + 1, $output); } /** * Create a configuration/environment data to be passed to * the UI * * @todo For a lack of better place this is put here for now * * @return array Configuration data */ public static function uiEnv(): array { $opts = ['app.name', 'app.url', 'app.domain']; $env = \app('config')->getMany($opts); $countries = include resource_path('countries.php'); $env['countries'] = $countries ?: []; - $env['isAdmin'] = strpos(request()->getHttpHost(), 'admin.') === 0; - $env['jsapp'] = $env['isAdmin'] ? 'admin.js' : 'user.js'; + $isAdmin = strpos(request()->getHttpHost(), 'admin.') === 0; + $env['jsapp'] = $isAdmin ? 'admin.js' : 'user.js'; return $env; } /** * Email address (login or alias) validation * * @param string $email Email address * @param \App\User $user The account owner * @param bool $is_alias The email is an alias * * @return string Error message on validation error */ public static function validateEmail( string $email, \App\User $user, bool $is_alias = false ): ?string { $attribute = $is_alias ? 'alias' : 'email'; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => $attribute]); } list($login, $domain) = explode('@', $email); // Check if domain exists $domain = Domain::where('namespace', Str::lower($domain))->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( [$attribute => $login], [$attribute => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()[$attribute][0]; } // Check if it is one of domains available to the user // TODO: We should have a helper that returns "flat" array with domain names // I guess we could use pluck() somehow $domains = array_map( function ($domain) { return $domain->namespace; }, $user->domains() ); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if user with specified address already exists if (User::findByEmail($email)) { return \trans('validation.entryexists', ['attribute' => $attribute]); } return null; } } diff --git a/src/resources/js/admin.js b/src/resources/js/admin.js index caf680dc..2ef6047d 100644 --- a/src/resources/js/admin.js +++ b/src/resources/js/admin.js @@ -1,9 +1,10 @@ /** * Application code for the admin UI */ -import router from './routes-admin' +import routes from './routes-admin.js' -window.router = router +window.routes = routes +window.isAdmin = true require('./app') diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 951cc640..e03c518b 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,289 +1,266 @@ /** * 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/Menu' import store from './store' -import FontAwesomeIcon from './fontawesome' -import VueToastr from '@deveodk/vue-toastr' - -window.Vue = require('vue') - -Vue.component('svg-icon', FontAwesomeIcon) - -Vue.use(VueToastr, { - defaultPosition: 'toast-bottom-right', - defaultTimeout: 5000 -}) - -const vTooltip = (el, binding) => { - const 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('hover') - - $(el).tooltip({ - 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') - } -}) - -// Add a response interceptor for general/validation error handler -// This have to be before Vue and Router setup. Otherwise we would -// not be able to handle axios responses initiated from inside -// components created/mounted handlers (e.g. signup code verification link) -window.axios.interceptors.response.use( - response => { - // Do nothing - return response - }, - error => { - let error_msg - let status = error.response ? error.response.status : 200 - - if (error.response && status == 422) { - error_msg = "Form validation error" - - $.each(error.response.data.errors || {}, (idx, msg) => { - $('form').each((i, form) => { - const input_name = ($(form).data('validation-prefix') || '') + idx - const input = $('#' + input_name) - - if (input.length) { - // Create an error message\ - // API responses can use a string, array or object - let msg_text = '' - if ($.type(msg) !== 'string') { - $.each(msg, (index, str) => { - msg_text += str + ' ' - }) - } - else { - msg_text = msg - } - - let feedback = $('
').text(msg_text) - - if (input.is('.list-input')) { - // List input widget - input.children(':not(:first-child)').each((index, element) => { - if (msg[index]) { - $(element).find('input').addClass('is-invalid') - } - }) - - input.addClass('is-invalid').next('.invalid-feedback').remove() - input.after(feedback) - } - else { - // Standard form element - input.addClass('is-invalid') - input.parent().find('.invalid-feedback').remove() - input.parent().append(feedback) - } - - return false - } - }); - }) - - $('form .is-invalid:not(.listinput-widget)').first().focus() - } - else if (error.response && error.response.data) { - error_msg = error.response.data.message - } - else { - error_msg = error.request ? error.request.statusText : error.message - } - - app.$toastr('error', error_msg || "Server Error", 'Error') - - // Pass the error as-is - return Promise.reject(error) - } -) const app = new Vue({ el: '#app', components: { 'app-component': AppComponent, 'menu-component': MenuComponent }, store, router: window.router, data() { return { - isLoading: true + isLoading: true, + isAdmin: window.isAdmin } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(token, dashboard) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') localStorage.setItem('token', token) axios.defaults.headers.common.Authorization = 'Bearer ' + token if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null }, // Set user state to "not logged in" logoutUser() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization this.$router.push({ name: 'login' }) }, // Display "loading" overlay (to be used by route components) startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element let loading = $('#app > .app-loader').show() if (!loading.length) { $('#app').append($('
Loading
')) } }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').fadeOut() this.isLoading = false }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#app').children(':not(nav)').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { this.logoutUser() } else { this.errorPage(error.response.status, error.response.statusText) } }, price(price) { return (price/100).toLocaleString('de-DE', { style: 'currency', currency: 'CHF' }) }, + priceLabel(cost, units = 1, discount) { + let index = '' + + if (units < 0) { + units = 1 + } + + if (discount) { + cost = Math.floor(cost * ((100 - discount) / 100)) + index = '\u00B9' + } + + return this.price(cost * units) + '/month' + index + }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return 'Deleted' } if (domain.isSuspended) { return 'Suspended' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'Not Ready' } return 'Active' }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return 'Deleted' } if (user.isSuspended) { return 'Suspended' } if (!user.isImapReady || !user.isLdapReady) { return 'Not Ready' } return 'Active' } } }) + +// Add a axios response interceptor for general/validation error handler +window.axios.interceptors.response.use( + response => { + // Do nothing + return response + }, + error => { + let error_msg + let status = error.response ? error.response.status : 200 + + if (error.response && status == 422) { + error_msg = "Form validation error" + + $.each(error.response.data.errors || {}, (idx, msg) => { + $('form').each((i, form) => { + const input_name = ($(form).data('validation-prefix') || '') + idx + const input = $('#' + input_name) + + if (input.length) { + // Create an error message\ + // API responses can use a string, array or object + let msg_text = '' + if ($.type(msg) !== 'string') { + $.each(msg, (index, str) => { + msg_text += str + ' ' + }) + } + else { + msg_text = msg + } + + let feedback = $('
').text(msg_text) + + if (input.is('.list-input')) { + // List input widget + input.children(':not(:first-child)').each((index, element) => { + if (msg[index]) { + $(element).find('input').addClass('is-invalid') + } + }) + + input.addClass('is-invalid').next('.invalid-feedback').remove() + input.after(feedback) + } + else { + // Standard form element + input.addClass('is-invalid') + input.parent().find('.invalid-feedback').remove() + input.parent().append(feedback) + } + + return false + } + }); + }) + + $('form .is-invalid:not(.listinput-widget)').first().focus() + } + else if (error.response && error.response.data) { + error_msg = error.response.data.message + } + else { + error_msg = error.request ? error.request.statusText : error.message + } + + app.$toastr('error', error_msg || "Server Error", 'Error') + + // Pass the error as-is + return Promise.reject(error) + } +) diff --git a/src/resources/js/bootstrap.js b/src/resources/js/bootstrap.js index a1b0631d..f5648f25 100644 --- a/src/resources/js/bootstrap.js +++ b/src/resources/js/bootstrap.js @@ -1,41 +1,89 @@ window._ = require('lodash') /** * We'll load jQuery and the Bootstrap jQuery plugin which provides support * for JavaScript based Bootstrap features such as modals and tabs. This * code may be modified to fit the specific needs of your application. */ try { window.Popper = require('popper.js').default window.$ = window.jQuery = require('jquery') require('bootstrap') } catch (e) {} /** - * We'll 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. + * We'll load Vue, VueRouter and global components */ -window.axios = require('axios') +import FontAwesomeIcon from './fontawesome' +import VueRouter from 'vue-router' +import VueToastr from '@deveodk/vue-toastr' +import store from './store' + +window.Vue = require('vue') + +Vue.component('svg-icon', FontAwesomeIcon) + +Vue.use(VueToastr, { + defaultPosition: 'toast-bottom-right', + defaultTimeout: 5000 +}) + +const vTooltip = (el, binding) => { + const 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('hover') + + $(el).tooltip({ + 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') + } +}) -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' + +Vue.use(VueRouter) + +window.router = new VueRouter({ + mode: 'history', + routes: window.routes +}) + +router.beforeEach((to, from, next) => { + // check if the route requires authentication and user is not logged in + if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) { + // remember the original request, to use after login + store.state.afterLogin = to; + + // redirect to login page + next({ name: 'login' }) + + return + } + + next() +}) /** - * Echo exposes an expressive API for subscribing to channels and listening - * for events that are broadcast by Laravel. Echo and event broadcasting - * allows your team to easily build robust real-time web applications. + * We'll 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. */ -// import Echo from 'laravel-echo'; - -// window.Pusher = require('pusher-js'); +window.axios = require('axios') -// window.Echo = new Echo({ -// broadcaster: 'pusher', -// key: process.env.MIX_PUSHER_APP_KEY, -// cluster: process.env.MIX_PUSHER_APP_CLUSTER, -// encrypted: true -// }); +axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' diff --git a/src/resources/js/routes-admin.js b/src/resources/js/routes-admin.js index 6ee3ce4b..27665db2 100644 --- a/src/resources/js/routes-admin.js +++ b/src/resources/js/routes-admin.js @@ -1,75 +1,48 @@ -import Vue from 'vue' -import VueRouter from 'vue-router' - -Vue.use(VueRouter) - import DashboardComponent from '../vue/Admin/Dashboard' import DomainComponent from '../vue/Admin/Domain' import Error404Component from '../vue/404' import LoginComponent from '../vue/Login' import LogoutComponent from '../vue/Logout' import UserComponent from '../vue/Admin/User' -import store from './store' - const routes = [ { path: '/', redirect: { name: 'dashboard' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/domain/:domain', name: 'domain', component: DomainComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/user/:user', name: 'user', component: UserComponent, meta: { requiresAuth: true } }, { name: '404', path: '*', component: Error404Component } ] -const router = new VueRouter({ - mode: 'history', - routes -}) - -router.beforeEach((to, from, next) => { - // check if the route requires authentication and user is not logged in - if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) { - // remember the original request, to use after login - store.state.afterLogin = to; - - // redirect to login page - next({ name: 'login' }) - - return - } - - next() -}) - -export default router +export default routes diff --git a/src/resources/js/routes-user.js b/src/resources/js/routes-user.js index a9935b11..1612aaac 100644 --- a/src/resources/js/routes-user.js +++ b/src/resources/js/routes-user.js @@ -1,123 +1,96 @@ -import Vue from 'vue' -import VueRouter from 'vue-router' - -Vue.use(VueRouter) - import DashboardComponent from '../vue/Dashboard' import DomainInfoComponent from '../vue/Domain/Info' import DomainListComponent from '../vue/Domain/List' import Error404Component from '../vue/404' import LoginComponent from '../vue/Login' import LogoutComponent from '../vue/Logout' import PasswordResetComponent from '../vue/PasswordReset' import SignupComponent from '../vue/Signup' import UserInfoComponent from '../vue/User/Info' import UserListComponent from '../vue/User/List' import UserProfileComponent from '../vue/User/Profile' import UserProfileDeleteComponent from '../vue/User/ProfileDelete' import WalletComponent from '../vue/Wallet' -import store from './store' - const routes = [ { path: '/', redirect: { name: 'dashboard' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/domain/:domain', name: 'domain', component: DomainInfoComponent, meta: { requiresAuth: true } }, { path: '/domains', name: 'domains', component: DomainListComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/password-reset/:code?', name: 'password-reset', component: PasswordResetComponent }, { path: '/profile', name: 'profile', component: UserProfileComponent, meta: { requiresAuth: true } }, { path: '/profile/delete', name: 'profile-delete', component: UserProfileDeleteComponent, meta: { requiresAuth: true } }, { path: '/signup/:param?', alias: '/signup/voucher/:param', name: 'signup', component: SignupComponent }, { path: '/user/:user', name: 'user', component: UserInfoComponent, meta: { requiresAuth: true } }, { path: '/users', name: 'users', component: UserListComponent, meta: { requiresAuth: true } }, { path: '/wallet', name: 'wallet', component: WalletComponent, meta: { requiresAuth: true } }, { name: '404', path: '*', component: Error404Component } ] -const router = new VueRouter({ - mode: 'history', - routes -}) - -router.beforeEach((to, from, next) => { - // check if the route requires authentication and user is not logged in - if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) { - // remember the original request, to use after login - store.state.afterLogin = to; - - // redirect to login page - next({ name: 'login' }) - - return - } - - next() -}) - -export default router +export default routes diff --git a/src/resources/js/user.js b/src/resources/js/user.js index d99e318d..99bf191a 100644 --- a/src/resources/js/user.js +++ b/src/resources/js/user.js @@ -1,9 +1,10 @@ /** * Application code for the user UI */ -import router from './routes-user' +import routes from './routes-user.js' -window.router = router +window.routes = routes +window.isAdmin = false require('./app') diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index 2a3ae14b..b6fd1423 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,418 +1,404 @@ diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue index 8e1cc965..00014a78 100644 --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -1,81 +1,80 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index fe807cf9..4692117c 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,360 +1,350 @@