diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ec31bbe..6ddf9845a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,36 @@ # Changelog Fab-manager +## v4.4.0 2020 May 12 + +- Manager: a new role between the member and the administrator +- The invoices list displays the operator in case of offline payment +- Interface to manage partners +- Ability to define, per availability, a custom duration for the reservation slots +- Ability to promote a user to a higher role (member > manager > admin) +- Ask for confirmation before booking a slot for a member without the required tag +- Corrected the documentation about BOOK_SLOT_AT_SAME_TIME +- Auto-adjusts text colors based on the selected theme colors +- Check password length during installation +- Fix a bug: accounting periods totals are wrong for periods closed after 2019-08-01 +- Fix a bug: unable to change group if the previous was deactivated +- Fix a bug: unable to create events or trainings that are not multiples of SLOT_DURATION +- Fix a bug: unable to delete an unreserved event +- Fix a bug: "Free entry" label for events without reservation +- Fix a bug: updating a setting without any changes triggers an error +- Fix a bug: plan edition does not show the associated group +- Fix a bug: subscription page shows the groups without any active plans +- Fix a bug: cart price inconsistently updated after a subscription +- Fix a bug: background image of the profile is not shown and wrong menu hover color +- Fix a bug: do not show disabled groups and plans during availability creation +- Fix a security issue: updated jquery to fix [CVE-2020-11023](https://nvd.nist.gov/vuln/detail/CVE-2020-11023) +- [TODO DEPLOY] `rails db:migrate` + ## v4.3.4 2020 April 14 - Improved version check - Improved setup script for installations without nginx - Changed some default values for new installations -- Compatible database with Fab-manager v1, to allow upgrades +- Database is now compatible with Fab-manager v1, to allow upgrades - Updated documentation - Changed In-Context pseudo-language to Zulu instead of Acholi - Allow removing contacts from the about page @@ -25,7 +50,7 @@ ## v4.3.3 2020 April 1st - Docker build will no longer embed development dependencies -- Updated instructions to setup a development environment +- Updated instructions to set up a development environment - Updated translations - Removed `MESSAGEFORMAT_LOCALE` as it is now handled by make-plural - Updated rails framework to v5.2 @@ -61,7 +86,7 @@ ## v4.3.1 2020 March 04 - Updated user's manual for v4.3 (fr) -- Display user's manual when help is asked, if no tour is available +- Display user's manual when asking for help, if no tour is available - Change style and pluralize the text of the slot division alert in new availability assistant - Fix a bug: in feature tours, next and previous arrows may be broken on some systems - Fix a bug: in the user's menu, two links to the personal wallet diff --git a/Vagrantfile b/Vagrantfile index 3a0e8a7a9..aab64f51d 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # -*- mode: ruby -*- # vi: set ft=ruby : @@ -17,11 +19,11 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| 1080, # mailcatcher web ui 4040 # ngrok web ui ].each do |port| - config.vm.network "forwarded_port", guest: port, host: port + config.vm.network 'forwarded_port', guest: port, host: port end # nginx server - config.vm.network "forwarded_port", guest: 80, host: 8080 + config.vm.network 'forwarded_port', guest: 80, host: 8080 # Configuration to allocate resources fro the virtual machine config.vm.provider 'virtualbox' do |vb| @@ -32,29 +34,29 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| # configuration below for file syncronization config.vm.synced_folder '.', '/vagrant', type: 'virtualbox' - # Copy default configuration files for the database conenction and the Rails application - config.vm.provision "file", source: "./config/database.yml.default", destination: "/vagrant/config/database.yml" - config.vm.provision "file", source: "./config/application.yml.default", destination: "/vagrant/config/application.yml" + # Copy default configuration files for the database connection and the Rails application + config.vm.provision 'file', source: './config/database.yml.default', destination: '/vagrant/config/database.yml' + config.vm.provision 'file', source: './env.example', destination: '/vagrant/.env' # Copy default configuration files to allow reviewing the Docker Compose integration - config.vm.provision "file", source: "./docker/docker-compose.yml", destination: "/home/vagrant/docker-compose.yml" - config.vm.provision "file", source: "./docker/env.example", destination: "/home/vagrant/config/env" - config.vm.provision "file", source: "./docker/nginx.conf.example", destination: "/home/vagrant/config/nginx/fabmanager.conf" - config.vm.provision "file", source: "./docker/elasticsearch.yml", destination: "/home/vagrant/elasticsearch/config/elasticsearch.yml" - config.vm.provision "file", source: "./docker/log4j2.properties", destination: "/home/vagrant/elasticsearch/config/log4j2.properties" + config.vm.provision 'file', source: './docker/development/docker-compose.yml', destination: '/home/vagrant/docker-compose.yml' + config.vm.provision 'file', source: './setup/env.example', destination: '/home/vagrant/config/env' + config.vm.provision 'file', source: './setup/nginx.conf.example', destination: '/home/vagrant/config/nginx/fabmanager.conf' + config.vm.provision 'file', source: './setup/elasticsearch.yml', destination: '/home/vagrant/elasticsearch/config/elasticsearch.yml' + config.vm.provision 'file', source: './setup/log4j2.properties', destination: '/home/vagrant/elasticsearch/config/log4j2.properties' ## Provision software dependencies - config.vm.provision "shell", privileged: false, run: "once", - path: "provision/zsh_setup.sh" + config.vm.provision 'shell', privileged: false, run: 'once', + path: 'provision/zsh_setup.sh' - config.vm.provision "shell", privileged: false, run: "once", - path: "provision/box_setup.zsh", - env: { - "LC_ALL" => "en_US.UTF-8", - "LANG" => "en_US.UTF-8", - "LANGUAGE" => "en_US.UTF-8", - } + config.vm.provision 'shell', privileged: false, run: 'once', + path: 'provision/box_setup.zsh', + env: { + 'LC_ALL' => 'en_US.UTF-8', + 'LANG' => 'en_US.UTF-8', + 'LANGUAGE' => 'en_US.UTF-8' + } - config.vm.provision "shell", privileged: true, run: "once", - path: "provision/box_tuning.zsh" + config.vm.provision 'shell', privileged: true, run: 'once', + path: 'provision/box_tuning.zsh' end diff --git a/app/assets/javascripts/app.js b/app/assets/javascripts/app.js index 47902e1db..0b08ad6cf 100644 --- a/app/assets/javascripts/app.js +++ b/app/assets/javascripts/app.js @@ -64,8 +64,6 @@ angular.module('application', ['ngCookies', 'ngResource', 'ngSanitize', 'ui.rout $translateProvider.useMessageFormatInterpolation(); // Set the language of the instance (from ruby configuration) $translateProvider.preferredLanguage(Fablab.locale); - // In any cases, fallback to english - $translateProvider.fallbackLanguage('en'); // End the tour when the user clicks the forward or back buttons of the browser TourConfigProvider.enableNavigationInterceptors(); }]).run(['$rootScope', '$log', 'AuthService', 'Auth', 'amMoment', '$state', 'editableOptions', 'Analytics', diff --git a/app/assets/javascripts/controllers/admin/calendar.js.erb b/app/assets/javascripts/controllers/admin/calendar.js.erb index 8fbb2ec14..287ae138b 100644 --- a/app/assets/javascripts/controllers/admin/calendar.js.erb +++ b/app/assets/javascripts/controllers/admin/calendar.js.erb @@ -18,8 +18,8 @@ * Controller used in the calendar management page */ -Application.Controllers.controller('AdminCalendarController', ['$scope', '$state', '$uibModal', 'moment', 'Availability', 'Slot', 'Setting', 'Export', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', 'machinesPromise', 'plansPromise', 'groupsPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Member', 'uiTourService', - function ($scope, $state, $uibModal, moment, Availability, Slot, Setting, Export, growl, dialogs, bookingWindowStart, bookingWindowEnd, machinesPromise, plansPromise, groupsPromise, _t, uiCalendarConfig, CalendarConfig, Member, uiTourService) { +Application.Controllers.controller('AdminCalendarController', ['$scope', '$state', '$uibModal', 'moment', 'AuthService', 'Availability', 'Slot', 'Setting', 'Export', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', 'machinesPromise', 'plansPromise', 'groupsPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Member', 'uiTourService', + function ($scope, $state, $uibModal, moment, AuthService, Availability, Slot, Setting, Export, growl, dialogs, bookingWindowStart, bookingWindowEnd, machinesPromise, plansPromise, groupsPromise, _t, uiCalendarConfig, CalendarConfig, Member, uiTourService) { /* PRIVATE STATIC CONSTANTS */ // The calendar is divided in slots of 30 minutes @@ -313,14 +313,16 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state placement: 'right', popupClass: 'width-350' }); - uitour.createStep({ - selector: '.admin-calendar .export-xls-button', - stepId: 'export', - order: 2, - title: _t('app.admin.tour.calendar.export.title'), - content: _t('app.admin.tour.calendar.export.content'), - placement: 'left' - }); + if (AuthService.isAuthorized('admin')) { + uitour.createStep({ + selector: '.admin-calendar .export-xls-button', + stepId: 'export', + order: 2, + title: _t('app.admin.tour.calendar.export.title'), + content: _t('app.admin.tour.calendar.export.content'), + placement: 'left' + }); + } uitour.createStep({ selector: '.heading .import-ics-button', stepId: 'import', @@ -416,9 +418,12 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state const modalInstance = $uibModal.open({ templateUrl: '<%= asset_path "admin/calendar/eventModal.html" %>', controller: 'CreateEventModalController', + backdrop: 'static', + keyboard: false, resolve: { - start () { return start; }, - end () { return end; }, + start() { return start; }, + end() { return end; }, + slots() { return Math.ceil(slots); }, machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }], trainingsPromise: ['Training', function (Training) { return Training.query().$promise; }], spacesPromise: ['Space', function (Space) { return Space.query().$promise; }], @@ -526,8 +531,8 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state /** * Controller used in the slot creation modal window */ -Application.Controllers.controller('CreateEventModalController', ['$scope', '$uibModalInstance', '$sce', 'moment', 'start', 'end', 'machinesPromise', 'Availability', 'trainingsPromise', 'spacesPromise', 'tagsPromise', 'plansPromise', 'groupsPromise', 'growl', '_t', - function ($scope, $uibModalInstance, $sce, moment, start, end, machinesPromise, Availability, trainingsPromise, spacesPromise, tagsPromise, plansPromise, groupsPromise, growl, _t) { +Application.Controllers.controller('CreateEventModalController', ['$scope', '$uibModalInstance', '$sce', 'moment', 'start', 'end', 'slots', 'machinesPromise', 'Availability', 'trainingsPromise', 'spacesPromise', 'tagsPromise', 'plansPromise', 'groupsPromise', 'growl', '_t', + function ($scope, $uibModalInstance, $sce, moment, start, end, slots, machinesPromise, Availability, trainingsPromise, spacesPromise, tagsPromise, plansPromise, groupsPromise, growl, _t) { // $uibModal parameter $scope.start = start; @@ -551,15 +556,6 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui $scope.selectedPlansBinding = {}; // list of plans, classified by group $scope.plansClassifiedByGroup = []; - for (let group of Array.from(groupsPromise)) { - const groupObj = { id: group.id, name: group.name, plans: [] }; - for (let plan of Array.from(plansPromise)) { - if (plan.group_id === group.id) { groupObj.plans.push(plan); } - } - if (groupObj.plans.length > 0) { - $scope.plansClassifiedByGroup.push(groupObj); - } - } // machines associated with the created slot $scope.selectedMachines = []; @@ -598,7 +594,8 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui is_recurrent: false, period: 'week', nb_periods: 1, - end_date: undefined // recurrence end + end_date: undefined, // recurrence end + slot_duration: Fablab.slotDuration }; // recurrent slots @@ -613,8 +610,8 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui // localized name(s) of the selected plan(s) $scope.plansName = ''; - // make the duration available for display - $scope.slotDuration = Fablab.slotDuration; + // number of slots for this availability + $scope.slots_nb = slots; /** * Adds or removes the provided machine from the current slot @@ -731,6 +728,13 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui } }; + /* + * Test if the current availability type is divided in slots + */ + $scope.isTypeDivided = function () { + return isTypeDivided($scope.availability.available_type); + } + /* PRIVATE SCOPE */ /** @@ -752,35 +756,64 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui } }); + // group plans by Group + for (let group of groupsPromise.filter(g => !g.disabled)) { + const groupObj = { id: group.id, name: group.name, plans: [] }; + for (let plan of plansPromise.filter(g => !g.disabled)) { + if (plan.group_id === group.id) { groupObj.plans.push(plan); } + } + if (groupObj.plans.length > 0) { + $scope.plansClassifiedByGroup.push(groupObj); + } + } + + // When the slot duration changes, we increment the availability to match the value + $scope.$watch('availability.slot_duration', function (newValue, oldValue, scope) { + start = moment($scope.start); + start.add(newValue * $scope.slots_nb, 'minutes'); + $scope.end = start.toDate(); + }); + + // When the number of slot changes, we increment the availability to match the value + $scope.$watch('slots_nb', function (newValue, oldValue, scope) { + start = moment($scope.start); + start.add($scope.availability.slot_duration * newValue, 'minutes'); + $scope.end = start.toDate(); + }); + // When we configure a machine/space availability, do not let the user change the end time, as the total - // time must be dividable by Fablab.slotDuration minutes (base slot duration). For training availabilities, the user + // time must be dividable by $scope.availability.slot_duration minutes (base slot duration). For training availabilities, the user // can configure any duration as it does not matters. $scope.$watch('availability.available_type', function (newValue, oldValue, scope) { - if ((newValue === 'machines') || (newValue === 'space')) { + if (isTypeDivided(newValue)) { $scope.endDateReadOnly = true; - const slots = Math.trunc(($scope.end.valueOf() - $scope.start.valueOf()) / (60 * 1000)) / Fablab.slotDuration; - if (!Number.isInteger(slots)) { + const slotsCurrentRange = Math.trunc(($scope.end.valueOf() - $scope.start.valueOf()) / (60 * 1000)) / $scope.availability.slot_duration; + if (!Number.isInteger(slotsCurrentRange)) { // otherwise, round it to upper decimal - const upper = Math.ceil(slots) * Fablab.slotDuration; + const upperSlots = Math.ceil(slotsCurrentRange); + const upper = upperSlots * $scope.availability.slot_duration; $scope.end = moment($scope.start).add(upper, 'minutes').toDate(); + $scope.slots_nb = upperSlots; + } else { + $scope.slots_nb = slotsCurrentRange; } - return $scope.availability.end_at = $scope.end; + $scope.availability.end_at = $scope.end; } else { - return $scope.endDateReadOnly = false; + $scope.endDateReadOnly = false; } }); // When the start date is changed, if we are configuring a machine/space availability, // maintain the relative length of the slot (ie. change the end time accordingly) $scope.$watch('start', function (newValue, oldValue, scope) { - // for machine or space availabilities, adjust the end time - if (($scope.availability.available_type === 'machines') || ($scope.availability.available_type === 'space')) { + // for machine or space availabilities, adjust the end time + if ($scope.isTypeDivided()) { end = moment($scope.end); end.add(moment(newValue).diff(oldValue), 'milliseconds'); $scope.end = end.toDate(); } else { // for training availabilities - // prevent the admin from setting the beginning after the end - if (moment(newValue).add(Fablab.slotDuration, 'minutes').isAfter($scope.end)) { + // prevent the admin from setting the beginning after the end + if (moment(newValue).add($scope.availability.slot_duration, 'minutes').isAfter($scope.end)) { $scope.start = oldValue; } } @@ -791,7 +824,7 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui // Maintain consistency between the end time and the date object in the availability object $scope.$watch('end', function (newValue, oldValue, scope) { // we prevent the admin from setting the end of the availability before its beginning - if (moment($scope.start).add(Fablab.slotDuration, 'minutes').isAfter(newValue)) { + if (moment($scope.start).add($scope.availability.slot_duration, 'minutes').isAfter(newValue)) { $scope.end = oldValue; } // update availability object @@ -799,6 +832,13 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui }); }; + /* + * Test if the provided availability type is divided in slots + */ + const isTypeDivided = function (type) { + return ((type === 'machines') || (type === 'space')); + } + /** * Validates that a machine or more was/were selected before continuing to step 3 (adjust time + tags) */ diff --git a/app/assets/javascripts/controllers/admin/events.js.erb b/app/assets/javascripts/controllers/admin/events.js.erb index 1a1efbd6f..7417f6d6d 100644 --- a/app/assets/javascripts/controllers/admin/events.js.erb +++ b/app/assets/javascripts/controllers/admin/events.js.erb @@ -153,8 +153,8 @@ class EventsController { /** * Controller used in the events listing page (admin view) */ -Application.Controllers.controller('AdminEventsController', ['$scope', '$state', 'dialogs', '$uibModal', 'growl', 'Event', 'Category', 'EventTheme', 'AgeRange', 'PriceCategory', 'eventsPromise', 'categoriesPromise', 'themesPromise', 'ageRangesPromise', 'priceCategoriesPromise', '_t', 'Member', 'uiTourService', - function ($scope, $state, dialogs, $uibModal, growl, Event, Category, EventTheme, AgeRange, PriceCategory, eventsPromise, categoriesPromise, themesPromise, ageRangesPromise, priceCategoriesPromise, _t, Member, uiTourService) { +Application.Controllers.controller('AdminEventsController', ['$scope', '$state', 'dialogs', '$uibModal', 'growl', 'AuthService', 'Event', 'Category', 'EventTheme', 'AgeRange', 'PriceCategory', 'eventsPromise', 'categoriesPromise', 'themesPromise', 'ageRangesPromise', 'priceCategoriesPromise', '_t', 'Member', 'uiTourService', + function ($scope, $state, dialogs, $uibModal, growl, AuthService, Event, Category, EventTheme, AgeRange, PriceCategory, eventsPromise, categoriesPromise, themesPromise, ageRangesPromise, priceCategoriesPromise, _t, Member, uiTourService) { /* PUBLIC SCOPE */ // By default, the pagination mode is activated to limit the page size @@ -407,38 +407,40 @@ Application.Controllers.controller('AdminEventsController', ['$scope', '$state', content: _t('app.admin.tour.events.filter.content'), placement: 'bottom' }); - uitour.createStep({ - selector: '.events-management .events-categories', - stepId: 'categories', - order: 3, - title: _t('app.admin.tour.events.categories.title'), - content: _t('app.admin.tour.events.categories.content'), - placement: 'bottom' - }); - uitour.createStep({ - selector: '.events-management .events-themes', - stepId: 'themes', - order: 4, - title: _t('app.admin.tour.events.themes.title'), - content: _t('app.admin.tour.events.themes.content'), - placement: 'top' - }); - uitour.createStep({ - selector: '.events-management .events-age-ranges', - stepId: 'ages', - order: 5, - title: _t('app.admin.tour.events.ages.title'), - content: _t('app.admin.tour.events.ages.content'), - placement: 'top' - }); - uitour.createStep({ - selector: '.events-management .prices-tab', - stepId: 'prices', - order: 6, - title: _t('app.admin.tour.events.prices.title'), - content: _t('app.admin.tour.events.prices.content'), - placement: 'bottom' - }); + if (AuthService.isAuthorized('admin')) { + uitour.createStep({ + selector: '.events-management .events-categories', + stepId: 'categories', + order: 3, + title: _t('app.admin.tour.events.categories.title'), + content: _t('app.admin.tour.events.categories.content'), + placement: 'bottom' + }); + uitour.createStep({ + selector: '.events-management .events-themes', + stepId: 'themes', + order: 4, + title: _t('app.admin.tour.events.themes.title'), + content: _t('app.admin.tour.events.themes.content'), + placement: 'top' + }); + uitour.createStep({ + selector: '.events-management .events-age-ranges', + stepId: 'ages', + order: 5, + title: _t('app.admin.tour.events.ages.title'), + content: _t('app.admin.tour.events.ages.content'), + placement: 'top' + }); + uitour.createStep({ + selector: '.events-management .prices-tab', + stepId: 'prices', + order: 6, + title: _t('app.admin.tour.events.prices.title'), + content: _t('app.admin.tour.events.prices.content'), + placement: 'bottom' + }); + } uitour.createStep({ selector: 'body', stepId: 'conclusion', diff --git a/app/assets/javascripts/controllers/admin/invoices.js.erb b/app/assets/javascripts/controllers/admin/invoices.js.erb index 381272707..a2c7236df 100644 --- a/app/assets/javascripts/controllers/admin/invoices.js.erb +++ b/app/assets/javascripts/controllers/admin/invoices.js.erb @@ -17,8 +17,8 @@ /** * Controller used in the admin invoices listing page */ -Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'Invoice', 'AccountingPeriod', 'invoices', 'closedPeriods', '$uibModal', 'growl', '$filter', 'Setting', 'settings', '_t', 'Member', 'uiTourService', - function ($scope, $state, Invoice, AccountingPeriod, invoices, closedPeriods, $uibModal, growl, $filter, Setting, settings, _t, Member, uiTourService) { +Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'Invoice', 'AccountingPeriod', 'AuthService', 'invoices', 'closedPeriods', '$uibModal', 'growl', '$filter', 'Setting', 'settings', '_t', 'Member', 'uiTourService', + function ($scope, $state, Invoice, AccountingPeriod, AuthService, invoices, closedPeriods, $uibModal, growl, $filter, Setting, settings, _t, Member, uiTourService) { /* PRIVATE STATIC CONSTANTS */ // number of invoices loaded each time we click on 'load more...' @@ -291,8 +291,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I growl.success(_t('app.admin.invoices.invoice_reference_successfully_saved')); } , function (error) { - growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_invoice_reference')); - console.error(error); + if (error.status === 304) return; + + growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_invoice_reference')); + console.error(error); }); }); }; @@ -330,8 +332,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I } } , function (error) { - growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_invoicing_code')); - return console.error(error); + if (error.status === 304) return; + + growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_invoicing_code')); + console.error(error); }); return Setting.update({ name: 'invoice_code-active' }, { value: result.active ? 'true' : 'false' }, function (data) { @@ -343,8 +347,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I } } , function (error) { - growl.error(_t('app.admin.invoices.an_error_occurred_while_activating_the_invoicing_code')); - return console.error(error); + if (error.status === 304) return; + + growl.error(_t('app.admin.invoices.an_error_occurred_while_activating_the_invoicing_code')); + console.error(error); }); }); }; @@ -375,8 +381,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I return growl.success(_t('app.admin.invoices.order_number_successfully_saved')); } , function (error) { - growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_order_number')); - return console.error(error); + if (error.status === 304) return; + + growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_order_number')); + console.error(error); }); }); }; @@ -434,8 +442,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I } } , function (error) { - growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_VAT_rate')); - return console.error(error); + if (error.status === 304) return; + + growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_VAT_rate')); + console.error(error); }); return Setting.update({ name: 'invoice_VAT-active' }, { value: result.active ? 'true' : 'false' }, function (data) { @@ -447,8 +457,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I } } , function (error) { - growl.error(_t('app.admin.invoices.an_error_occurred_while_activating_the_VAT')); - return console.error(error); + if (error.status === 304) return; + + growl.error(_t('app.admin.invoices.an_error_occurred_while_activating_the_VAT')); + console.error(error); }); }); }; @@ -463,8 +475,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I return growl.success(_t('app.admin.invoices.text_successfully_saved')); } , function (error) { - growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_text')); - return console.error(error); + if (error.status === 304) return; + + growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_text')); + console.error(error); }); }; @@ -478,8 +492,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I return growl.success(_t('app.admin.invoices.address_and_legal_information_successfully_saved')); } , function (error) { - growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_address_and_the_legal_information')); - return console.error(error); + if (error.status === 304) return; + + growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_address_and_the_legal_information')); + console.error(error); }); }; @@ -559,6 +575,15 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I ); } + /** + * Return the name of the operator that creates the invoice + */ + $scope.operatorName = function(invoice) { + if (!invoice.operator) return ''; + + return `${invoice.operator.first_name} ${invoice.operator.last_name}`; + } + /** * Setup the feature-tour for the admin/invoices page. * This is intended as a contextual help (when pressing F1) @@ -566,15 +591,27 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I $scope.setupInvoicesTour = function () { // get the tour defined by the ui-tour directive const uitour = uiTourService.getTourByName('invoices'); - uitour.createStep({ - selector: 'body', - stepId: 'welcome', - order: 0, - title: _t('app.admin.tour.invoices.welcome.title'), - content: _t('app.admin.tour.invoices.welcome.content'), - placement: 'bottom', - orphan: true - }); + if (AuthService.isAuthorized('admin')) { + uitour.createStep({ + selector: 'body', + stepId: 'welcome', + order: 0, + title: _t('app.admin.tour.invoices.welcome.title'), + content: _t('app.admin.tour.invoices.welcome.content'), + placement: 'bottom', + orphan: true + }); + } else { + uitour.createStep({ + selector: 'body', + stepId: 'welcome_manager', + order: 0, + title: _t('app.admin.tour.invoices.welcome_manager.title'), + content: _t('app.admin.tour.invoices.welcome_manager.content'), + placement: 'bottom', + orphan: true + }); + } if (!Fablab.withoutInvoices && $scope.invoices.length > 0) { uitour.createStep({ selector: '.invoices-management .invoices-list', @@ -609,39 +646,41 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I placement: 'left' }); } - uitour.createStep({ - selector: '.invoices-management .invoices-settings', - stepId: 'settings', - order: 5, - title: _t('app.admin.tour.invoices.settings.title'), - content: _t('app.admin.tour.invoices.settings.content'), - placement: 'bottom' - }); - uitour.createStep({ - selector: '.invoices-management .accounting-codes-tab', - stepId: 'codes', - order: 6, - title: _t('app.admin.tour.invoices.codes.title'), - content: _t('app.admin.tour.invoices.codes.content'), - placement: 'bottom' - }); - uitour.createStep({ - selector: '.heading .export-accounting-button', - stepId: 'export', - order: 7, - title: _t('app.admin.tour.invoices.export.title'), - content: _t('app.admin.tour.invoices.export.content'), - placement: 'bottom' - }); - uitour.createStep({ - selector: '.heading .close-accounting-periods-button', - stepId: 'periods', - order: 8, - title: _t('app.admin.tour.invoices.periods.title'), - content: _t('app.admin.tour.invoices.periods.content'), - placement: 'bottom', - popupClass: 'shift-left-50' - }); + if (AuthService.isAuthorized('admin')) { + uitour.createStep({ + selector: '.invoices-management .invoices-settings', + stepId: 'settings', + order: 5, + title: _t('app.admin.tour.invoices.settings.title'), + content: _t('app.admin.tour.invoices.settings.content'), + placement: 'bottom' + }); + uitour.createStep({ + selector: '.invoices-management .accounting-codes-tab', + stepId: 'codes', + order: 6, + title: _t('app.admin.tour.invoices.codes.title'), + content: _t('app.admin.tour.invoices.codes.content'), + placement: 'bottom' + }); + uitour.createStep({ + selector: '.heading .export-accounting-button', + stepId: 'export', + order: 7, + title: _t('app.admin.tour.invoices.export.title'), + content: _t('app.admin.tour.invoices.export.content'), + placement: 'bottom' + }); + uitour.createStep({ + selector: '.heading .close-accounting-periods-button', + stepId: 'periods', + order: 8, + title: _t('app.admin.tour.invoices.periods.title'), + content: _t('app.admin.tour.invoices.periods.content'), + placement: 'bottom', + popupClass: 'shift-left-50' + }); + } uitour.createStep({ selector: 'body', stepId: 'conclusion', @@ -710,8 +749,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I { value: $scope.invoice.logo.base64 }, function (data) { growl.success(_t('app.admin.invoices.logo_successfully_saved')); }, function (error) { + if (error.status === 304) return; + growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_logo')); - return console.error(error); + console.error(error); } ); } diff --git a/app/assets/javascripts/controllers/admin/members.js.erb b/app/assets/javascripts/controllers/admin/members.js.erb index e392925d2..906bf8396 100644 --- a/app/assets/javascripts/controllers/admin/members.js.erb +++ b/app/assets/javascripts/controllers/admin/members.js.erb @@ -126,8 +126,8 @@ class MembersController { /** * Controller used in the members/groups management page */ -Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', 'membersPromise', 'adminsPromise', 'growl', 'Admin', 'dialogs', '_t', 'Member', 'Export', 'uiTourService', - function ($scope, $sce, membersPromise, adminsPromise, growl, Admin, dialogs, _t, Member, Export, uiTourService) { +Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', '$uibModal', 'membersPromise', 'adminsPromise', 'partnersPromise', 'managersPromise', 'growl', 'Admin', 'AuthService', 'dialogs', '_t', 'Member', 'Export', 'User', 'uiTourService', + function ($scope, $sce, $uibModal, membersPromise, adminsPromise, partnersPromise, managersPromise, growl, Admin, AuthService, dialogs, _t, Member, Export, User, uiTourService) { /* PRIVATE STATIC CONSTANTS */ // number of users loaded each time we click on 'load more...' @@ -163,8 +163,20 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', // Admins ordering/sorting. Default: not sorted $scope.orderAdmin = null; + // partners list + $scope.partners = partnersPromise.users; + + // Partners ordering/sorting. Default: not sorted + $scope.orderPartner = null; + + // managers list + $scope.managers = managersPromise.users; + + // Managers ordering/sorting. Default: not sorted + $scope.orderManager = null; + // default tab: members list - $scope.tabs = { active: 0 }; + $scope.tabs = { active: 0, sub: 0 }; /** * Change the members ordering criterion to the one provided @@ -193,6 +205,67 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', } }; + /** + * Change the partners ordering criterion to the one provided + * @param orderPartner {string} ordering criterion + */ + $scope.setOrderPartner = function (orderPartner) { + if ($scope.orderPartner === orderPartner) { + return $scope.orderPartner = `-${orderPartner}`; + } else { + return $scope.orderPartner = orderPartner; + } + }; + + /** + * Change the managers ordering criterion to the one provided + * @param orderManager {string} ordering criterion + */ + $scope.setOrderManager = function (orderManager) { + if ($scope.orderManager === orderManager) { + return $scope.orderManager = `-${orderManager}`; + } else { + return $scope.orderManager = orderManager; + } + }; + + + /** + * Open a modal dialog allowing the admin to create a new partner user + */ + $scope.openPartnerNewModal = function () { + const modalInstance = $uibModal.open({ + animation: true, + templateUrl: '<%= asset_path "shared/_partner_new_modal.html" %>', + size: 'lg', + controller: ['$scope', '$uibModalInstance', 'User', function ($scope, $uibModalInstance, User) { + $scope.partner = {}; + + $scope.ok = function () { + User.save( + {}, + { user: $scope.partner }, + function (user) { + $scope.partner.id = user.id; + $scope.partner.name = `${user.first_name} ${user.last_name}`; + $uibModalInstance.close($scope.partner); + }, + function (error) { + growl.error(_t('app.admin.plans.new.unable_to_save_this_user_check_that_there_isnt_an_already_a_user_with_the_same_name')); + console.error(error); + } + ); + }; + $scope.cancel = function () { $uibModalInstance.dismiss('cancel'); }; + }] + }); + // once the form was validated successfully ... + return modalInstance.result.then(function (partner) { + $scope.partners.push(partner); + }); + }; + + /** * Ask for confirmation then delete the specified user * @param memberId {number} identifier of the user to delete @@ -252,6 +325,66 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', ); }; + /** + * Ask for confirmation then delete the specified partner + * @param partners {Array} full list of partners + * @param partner {Object} partner to delete + */ + $scope.destroyPartner = function (partners, partner) { + dialogs.confirm( + { + resolve: { + object () { + return { + title: _t('app.admin.members.confirmation_required'), + msg: $sce.trustAsHtml(_t('app.admin.members.delete_this_partner') + '

' + _t('app.admin.members.this_may_take_a_while_please_wait')) + }; + } + } + }, + function () { // cancel confirmed + User.delete( + { id: partner.id }, + function () { + partners.splice(findItemIdxById(partners, partner.id), 1); + return growl.success(_t('app.admin.members.partner_successfully_deleted')); + }, + function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_partner')); } + ); + } + ); + } + + /** + * Ask for confirmation then delete the specified manager + * @param managers {Array} full list of managers + * @param manager {Object} manager to delete + */ + $scope.destroyManager = function (managers, manager) { + dialogs.confirm( + { + resolve: { + object () { + return { + title: _t('app.admin.members.confirmation_required'), + msg: $sce.trustAsHtml(_t('app.admin.members.delete_this_manager') + '

' + _t('app.admin.members.this_may_take_a_while_please_wait')) + }; + } + } + }, + function () { // cancel confirmed + User.delete( + { id: manager.id }, + function () { + managers.splice(findItemIdxById(managers, manager.id), 1); + return growl.success(_t('app.admin.members.manager_successfully_deleted')); + }, + function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_manager')); } + ); + } + ); + } + /** * Callback for the 'load more' button. * Will load the next results of the current search, if any @@ -343,22 +476,24 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', placement: 'left' }); } - uitour.createStep({ - selector: '.members-management .exports-buttons', - stepId: 'exports', - order: 5, - title: _t('app.admin.tour.members.exports.title'), - content: _t('app.admin.tour.members.exports.content'), - placement: 'bottom' - }); - uitour.createStep({ - selector: '.heading .import-members', - stepId: 'import', - order: 6, - title: _t('app.admin.tour.members.import.title'), - content: _t('app.admin.tour.members.import.content'), - placement: 'left' - }); + if (AuthService.isAuthorized('admin')) { + uitour.createStep({ + selector: '.members-management .exports-buttons', + stepId: 'exports', + order: 5, + title: _t('app.admin.tour.members.exports.title'), + content: _t('app.admin.tour.members.exports.content'), + placement: 'bottom' + }); + uitour.createStep({ + selector: '.heading .import-members', + stepId: 'import', + order: 6, + title: _t('app.admin.tour.members.import.title'), + content: _t('app.admin.tour.members.import.content'), + placement: 'left' + }); + } uitour.createStep({ selector: '.members-management .admins-tab', stepId: 'admins', @@ -367,31 +502,33 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', content: _t('app.admin.tour.members.admins.content'), placement: 'bottom' }); - uitour.createStep({ - selector: '.members-management .groups-tab', - stepId: 'groups', - order: 8, - title: _t('app.admin.tour.members.groups.title'), - content: _t('app.admin.tour.members.groups.content'), - placement: 'bottom' - }); - uitour.createStep({ - selector: '.members-management .labels-tab', - stepId: 'labels', - order: 9, - title: _t('app.admin.tour.members.labels.title'), - content: _t('app.admin.tour.members.labels.content'), - placement: 'bottom' - }); - uitour.createStep({ - selector: '.members-management .sso-tab', - stepId: 'sso', - order: 10, - title: _t('app.admin.tour.members.sso.title'), - content: _t('app.admin.tour.members.sso.content'), - placement: 'bottom', - popupClass: 'shift-left-50' - }); + if (AuthService.isAuthorized('admin')) { + uitour.createStep({ + selector: '.members-management .groups-tab', + stepId: 'groups', + order: 8, + title: _t('app.admin.tour.members.groups.title'), + content: _t('app.admin.tour.members.groups.content'), + placement: 'bottom' + }); + uitour.createStep({ + selector: '.members-management .labels-tab', + stepId: 'labels', + order: 9, + title: _t('app.admin.tour.members.labels.title'), + content: _t('app.admin.tour.members.labels.content'), + placement: 'bottom' + }); + uitour.createStep({ + selector: '.members-management .sso-tab', + stepId: 'sso', + order: 10, + title: _t('app.admin.tour.members.sso.title'), + content: _t('app.admin.tour.members.sso.content'), + placement: 'bottom', + popupClass: 'shift-left-50' + }); + } uitour.createStep({ selector: 'body', stepId: 'conclusion', @@ -405,18 +542,20 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', uitour.on('stepChanged', function (nextStep) { if (nextStep.stepId === 'list' || nextStep.stepId === 'import') { $scope.tabs.active = 0; + $scope.tabs.sub = 0; } if (nextStep.stepId === 'admins') { - $scope.tabs.active = 1; + $scope.tabs.active = 0; + $scope.tabs.sub = 1; } if (nextStep.stepId === 'groups') { - $scope.tabs.active = 2; + $scope.tabs.active = 1; } if (nextStep.stepId === 'labels') { - $scope.tabs.active = 3; + $scope.tabs.active = 2; } if (nextStep.stepId === 'sso') { - $scope.tabs.active = 4; + $scope.tabs.active = 3; } }); // on tour end, save the status in database @@ -552,6 +691,54 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', // current active authentication provider $scope.activeProvider = activeProviderPromise; + /** + * Open a modal dialog asking for confirmation to change the role of the given user + * @param userId {number} id of the user to "promote" + * @returns {*} + */ + $scope.changeUserRole = function() { + const modalInstance = $uibModal.open({ + animation: true, + templateUrl: '<%= asset_path "admin/members/change_role_modal.html" %>', + size: 'lg', + resolve: { + user() { return $scope.user; } + }, + controller: ['$scope', '$uibModalInstance', 'Member', 'user', '_t', function ($scope, $uibModalInstance, Member, user, _t) { + $scope.user = user; + + $scope.role = user.role; + + $scope.roles = [ + { key: 'admin', label: _t('app.admin.members_edit.admin') }, + { key: 'manager', label: _t('app.admin.members_edit.manager'), notAnOption: (user.role === 'admin') }, + { key: 'member', label: _t('app.admin.members_edit.member'), notAnOption: (user.role === 'admin' || user.role === 'manager') }, + ]; + + $scope.ok = function () { + Member.updateRole( + { id: $scope.user.id }, + { role: $scope.role }, + function (_res) { + growl.success(_t('app.admin.members_edit.role_changed', { OLD: _t(`app.admin.members_edit.${user.role}`), NEW: _t(`app.admin.members_edit.${$scope.role}`) })); + return $uibModalInstance.close(_res); + }, + function (error) { + growl.error(_t('app.admin.members_edit.error_while_changing_role')); + console.error(error); + } + ); + }; + + $scope.cancel = function () { $uibModalInstance.dismiss('cancel'); }; + }] + }); + // once the form was validated successfully ... + return modalInstance.result.then(function (user) { + // remove the user for the old list add to the new + }); + } + /** * Open a modal dialog, allowing the admin to extend the current user's subscription (freely or not) * @param subscription {Object} User's subscription object @@ -936,3 +1123,72 @@ Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'A } ]); + +/** + * Controller used in the manager's creation page (admin view) + */ +Application.Controllers.controller('NewManagerController', ['$state', '$scope', 'User', 'groupsPromise', 'tagsPromise', 'growl', '_t', + function ($state, $scope, User, groupsPromise, tagsPromise, growl, _t) { + // default admin profile + $scope.manager = { + statistic_profile_attributes: { + gender: true + }, + profile_attributes: {}, + invoicing_profile_attributes: {} + }; + + // Default parameters for AngularUI-Bootstrap datepicker + $scope.datePicker = { + format: Fablab.uibDateFormat, + opened: false, + options: { + startingDay: Fablab.weekStartingDay + } + }; + + // list of all groups + $scope.groups = groupsPromise.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; }); + + // list of all tags + $scope.tags = tagsPromise; + + /** + * Shows the birth day datepicker + * @param $event {Object} jQuery event object + */ + $scope.openDatePicker = function ($event) { $scope.datePicker.opened = true; }; + + /** + * Send the new manager, currently stored in $scope.manager, to the server for database saving + */ + $scope.saveManager = function () { + User.save( + {}, + { manager: $scope.manager }, + function () { + growl.success(_t('app.admin.manager_new.manager_successfully_created', { GENDER: getGender($scope.manager) })); + return $state.go('app.admin.members'); + } + , function (error) { + growl.error(_t('app.admin.admins_new.failed_to_create_manager') + JSON.stringify(error.data ? error.data : error)); + console.error(error); + } + ); + }; + + /* PRIVATE SCOPE */ + + /** + * Return an enumerable meaningful string for the gender of the provider user + * @param user {Object} Database user record + * @return {string} 'male' or 'female' + */ + const getGender = function (user) { + if (user.statistic_profile_attributes) { + if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; } + } else { return 'other'; } + }; +} + +]); diff --git a/app/assets/javascripts/controllers/admin/pricing.js.erb b/app/assets/javascripts/controllers/admin/pricing.js.erb index 96239941e..7d501e4a0 100644 --- a/app/assets/javascripts/controllers/admin/pricing.js.erb +++ b/app/assets/javascripts/controllers/admin/pricing.js.erb @@ -617,6 +617,22 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state', }); } + /** + * Return the exemple price based on the configuration of the default slot duration. + * @param type {string} 'hourly_rate' | * + * @returns {number} price for Fablab.slotDuration minutes. + */ + $scope.examplePrice = function(type) { + const hourlyRate = 10; + + if (type === 'hourly_rate') { + return $filter('currency')(hourlyRate); + } + + const price = (hourlyRate / 60) * Fablab.slotDuration; + return $filter('currency')(price); + } + /** * Setup the feature-tour for the admin/pricing page. * This is intended as a contextual help (when pressing F1) diff --git a/app/assets/javascripts/controllers/admin/settings.js.erb b/app/assets/javascripts/controllers/admin/settings.js.erb index 7f4947fca..e48d2c2e6 100644 --- a/app/assets/javascripts/controllers/admin/settings.js.erb +++ b/app/assets/javascripts/controllers/admin/settings.js.erb @@ -209,7 +209,12 @@ Application.Controllers.controller('SettingsController', ['$scope', '$rootScope' { name: setting.name }, { value }, function () { growl.success(_t('app.admin.settings.customization_of_SETTING_successfully_saved', { SETTING: _t(`app.admin.settings.${setting.name}`) })); }, - function (error) { console.log(error); } + function (error) { + if (error.status === 304) return; + + growl.error(_t('app.admin.settings.an_error_occurred_saving_the_setting')); + console.log(error); + } ); }; diff --git a/app/assets/javascripts/controllers/events.js.erb b/app/assets/javascripts/controllers/events.js.erb index d81811991..40768ee4b 100644 --- a/app/assets/javascripts/controllers/events.js.erb +++ b/app/assets/javascripts/controllers/events.js.erb @@ -126,8 +126,8 @@ Application.Controllers.controller('EventsController', ['$scope', '$state', 'Eve } ]); -Application.Controllers.controller('ShowEventController', ['$scope', '$state', '$stateParams', '$rootScope', 'Event', '$uibModal', 'Member', 'Reservation', 'Price', 'CustomAsset', 'Slot', 'eventPromise', 'growl', '_t', 'Wallet', 'helpers', 'dialogs', 'priceCategoriesPromise', 'settingsPromise', - function ($scope, $state, $stateParams, $rootScope, Event, $uibModal, Member, Reservation, Price, CustomAsset, Slot, eventPromise, growl, _t, Wallet, helpers, dialogs, priceCategoriesPromise, settingsPromise) { +Application.Controllers.controller('ShowEventController', ['$scope', '$state', '$stateParams', '$rootScope', 'Event', '$uibModal', 'Member', 'Reservation', 'Price', 'CustomAsset', 'Slot', 'eventPromise', 'growl', '_t', 'Wallet', 'AuthService', 'helpers', 'dialogs', 'priceCategoriesPromise', 'settingsPromise', + function ($scope, $state, $stateParams, $rootScope, Event, $uibModal, Member, Reservation, Price, CustomAsset, Slot, eventPromise, growl, _t, Wallet, AuthService, helpers, dialogs, priceCategoriesPromise, settingsPromise) { /* PUBLIC SCOPE */ // reservations for the currently shown event @@ -245,32 +245,32 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' if ($scope.event.nb_total_places > 0) { $scope.reserveSuccess = false; if (!$scope.isAuthenticated()) { - return $scope.login(null, function (user) { - if (user.role !== 'admin') { - return $scope.ctrl.member = user; + $scope.login(null, function (user) { + if (user.role !== 'admin' || user.role !== 'manager') { + $scope.ctrl.member = user; } const sameTimeReservations = findReservationsAtSameTime(); if (sameTimeReservations.length > 0) { showReserveSlotSameTimeModal(sameTimeReservations, function(res) { - return $scope.reserve.toReserve = !$scope.reserve.toReserve; + $scope.reserve.toReserve = !$scope.reserve.toReserve; }); } else { - return $scope.reserve.toReserve = !$scope.reserve.toReserve; + $scope.reserve.toReserve = !$scope.reserve.toReserve; } }); } else { - if ($scope.currentUser.role === 'admin') { - return $scope.reserve.toReserve = !$scope.reserve.toReserve; + if (AuthService.isAuthorized(['admin', 'manager'])) { + $scope.reserve.toReserve = !$scope.reserve.toReserve; } else { Member.get({ id: $scope.currentUser.id }, function (member) { $scope.ctrl.member = member; const sameTimeReservations = findReservationsAtSameTime(); if (sameTimeReservations.length > 0) { showReserveSlotSameTimeModal(sameTimeReservations, function(res) { - return $scope.reserve.toReserve = !$scope.reserve.toReserve; + $scope.reserve.toReserve = !$scope.reserve.toReserve; }); } else { - return $scope.reserve.toReserve = !$scope.reserve.toReserve; + $scope.reserve.toReserve = !$scope.reserve.toReserve; } }); } @@ -286,9 +286,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' resetEventReserve(); $scope.reserveSuccess = false; if ($scope.ctrl.member) { - return Member.get({ id: $scope.ctrl.member.id }, function (member) { + Member.get({ id: $scope.ctrl.member.id }, function (member) { $scope.ctrl.member = member; - return getReservations($scope.event.id, 'Event', $scope.ctrl.member.id); + getReservations($scope.event.id, 'Event', $scope.ctrl.member.id); }); } }; @@ -303,14 +303,17 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' return Wallet.getWalletByUser({ user_id: $scope.ctrl.member.id }, function (wallet) { const amountToPay = helpers.getAmountToPay($scope.reserve.amountTotal, wallet.amount); - if (($scope.currentUser.role !== 'admin') && (amountToPay > 0)) { + if ((AuthService.isAuthorized(['member']) && amountToPay > 0) + || (AuthService.isAuthorized('manager') && $scope.ctrl.member.id === $rootScope.currentUser.id && amountToPay > 0)) { if ($rootScope.fablabWithoutOnlinePayment) { growl.error(_t('app.public.events_show.online_payment_disabled')); } else { return payByStripe(reservation); } } else { - if (($scope.currentUser.role === 'admin') || (amountToPay === 0)) { + if (AuthService.isAuthorized('admin') + || (AuthService.isAuthorized('manager') && $scope.ctrl.member.id !== $rootScope.currentUser.id) + || amountToPay === 0) { return payOnSite(reservation); } } @@ -564,7 +567,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' } // watch when a coupon is applied to re-compute the total price - return $scope.$watch('coupon.applied', function (newValue, oldValue) { + $scope.$watch('coupon.applied', function (newValue, oldValue) { if ((newValue !== null) || (oldValue !== null)) { return $scope.computeEventAmount(); } @@ -577,7 +580,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' * @param reservable_type {string} 'Event' * @param user_id {number} the user's id (current or managed) */ - var getReservations = function (reservable_id, reservable_type, user_id) { + const getReservations = function (reservable_id, reservable_type, user_id) { Reservation.query({ reservable_id, reservable_type, @@ -592,7 +595,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' * @param event {Object} Current event * @return {{user_id:number, reservable_id:number, reservable_type:string, slots_attributes:Array, nb_reserve_places:number}} */ - var mkReservation = function (member, reserve, event) { + const mkReservation = function (member, reserve, event) { const reservation = { user_id: member.id, reservable_id: event.id, @@ -628,7 +631,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' * @param coupon {Object} Coupon as returned from the API * @return {{reservation:Object, coupon_code:string}} */ - var mkRequestParams = function (reservation, coupon) { + const mkRequestParams = function (reservation, coupon) { const params = { reservation, coupon_code: ((coupon ? coupon.code : undefined)) @@ -640,7 +643,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' /** * Set the current reservation to the default values. This implies the reservation form to be hidden. */ - var resetEventReserve = function () { + const resetEventReserve = function () { if ($scope.event) { $scope.reserve = { nbPlaces: { @@ -666,7 +669,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' * Open a modal window which trigger the stripe payment process * @param reservation {Object} to book */ - var payByStripe = function (reservation) { + const payByStripe = function (reservation) { $uibModal.open({ templateUrl: '<%= asset_path "stripe/payment_modal.html" %>', size: 'md', @@ -730,7 +733,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' * Open a modal window which trigger the local payment process * @param reservation {Object} to book */ - var payOnSite = function (reservation) { + const payOnSite = function (reservation) { $uibModal.open({ templateUrl: '<%= asset_path "shared/valid_reservation_modal.html" %>', size: 'sm', @@ -808,7 +811,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' * What to do after the payment was successful * @param reservation {Object} booked reservation */ - var afterPayment = function (reservation) { + const afterPayment = function (reservation) { $scope.event.nb_free_places = $scope.event.nb_free_places - reservation.total_booked_seats; resetEventReserve(); $scope.reserveSuccess = true; @@ -822,7 +825,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' /** * Find user's reservations, the same date at the same time, with event */ - var findReservationsAtSameTime = function () { + const findReservationsAtSameTime = function () { let sameTimeReservations = [ 'training_reservations', 'machine_reservations', @@ -848,7 +851,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', ' * @param sameTimeReservations {Array} reservations the same date at the same time * @param callback {function} callback will invoke when user confirm */ - var showReserveSlotSameTimeModal = function(sameTimeReservations, callback) { + const showReserveSlotSameTimeModal = function(sameTimeReservations, callback) { const modalInstance = $uibModal.open({ animation: true, templateUrl: '<%= asset_path "shared/_reserve_slot_same_time.html" %>', diff --git a/app/assets/javascripts/controllers/home.js b/app/assets/javascripts/controllers/home.js index 1d961ae00..ca681fc40 100644 --- a/app/assets/javascripts/controllers/home.js +++ b/app/assets/javascripts/controllers/home.js @@ -1,7 +1,7 @@ 'use strict'; -Application.Controllers.controller('HomeController', ['$scope', '$stateParams', 'settingsPromise', 'Member', 'uiTourService', '_t', 'Help', - function ($scope, $stateParams, settingsPromise, Member, uiTourService, _t, Help) { +Application.Controllers.controller('HomeController', ['$scope', '$stateParams', '$translatePartialLoader', 'AuthService', 'settingsPromise', 'Member', 'uiTourService', '_t', 'Help', + function ($scope, $stateParams, $translatePartialLoader, AuthService, settingsPromise, Member, uiTourService, _t, Help) { /* PUBLIC SCOPE */ // Home page HTML content @@ -21,8 +21,12 @@ Application.Controllers.controller('HomeController', ['$scope', '$stateParams', * This is intended as a contextual help (when pressing F1) */ $scope.setupHomeTour = function () { - if ($scope.currentUser && $scope.currentUser.role === 'admin') { - setupWelcomeTour(); + if (AuthService.isAuthorized(['admin', 'manager'])) { + // Workaround for the following bug: sometimes, when the feature tour is shown, the translations keys are not + // interpreted. This is an ugly hack, but we can't do better for now because angular-ui-tour does not support + // removing steps (this would allow us to recreate the steps when the translations are loaded), and we can't use + // promises with _t's translations (this would be a very big refactoring) + setTimeout(setupWelcomeTour, 1000); } }; @@ -182,7 +186,7 @@ Application.Controllers.controller('HomeController', ['$scope', '$stateParams', selector: '.nav-primary .admin-section', stepId: 'admin', order: 9, - title: _t('app.public.tour.welcome.admin.title'), + title: _t('app.public.tour.welcome.admin.title', { ROLE: _t(`app.public.common.${$scope.currentUser.role}`) }), content: _t('app.public.tour.welcome.admin.content'), placement: 'right' }); @@ -271,14 +275,16 @@ Application.Controllers.controller('HomeController', ['$scope', '$stateParams', placement: 'bottom', orphan: 'true' }); - uitour.createStep({ - selector: '.app-generator .app-version', - stepId: 'version', - order: 19, - title: _t('app.public.tour.welcome.version.title'), - content: _t('app.public.tour.welcome.version.content'), - placement: 'top' - }); + if (AuthService.isAuthorized('admin')) { + uitour.createStep({ + selector: '.app-generator .app-version', + stepId: 'version', + order: 19, + title: _t('app.public.tour.welcome.version.title'), + content: _t('app.public.tour.welcome.version.content'), + placement: 'top' + }); + } uitour.createStep({ selector: 'body', stepId: 'conclusion', diff --git a/app/assets/javascripts/controllers/machines.js.erb b/app/assets/javascripts/controllers/machines.js.erb index 434bee02e..3f807dfaa 100644 --- a/app/assets/javascripts/controllers/machines.js.erb +++ b/app/assets/javascripts/controllers/machines.js.erb @@ -180,8 +180,8 @@ const _reserveMachine = function (machine, e) { /** * Controller used in the public listing page, allowing everyone to see the list of machines */ -Application.Controllers.controller('MachinesController', ['$scope', '$state', '_t', 'Machine', '$uibModal', 'machinesPromise', 'Member', 'uiTourService', - function ($scope, $state, _t, Machine, $uibModal, machinesPromise, Member, uiTourService) { +Application.Controllers.controller('MachinesController', ['$scope', '$state', '_t', 'AuthService', 'Machine', '$uibModal', 'machinesPromise', 'Member', 'uiTourService', + function ($scope, $state, _t, AuthService, Machine, $uibModal, machinesPromise, Member, uiTourService) { /* PUBLIC SCOPE */ // Retrieve the list of machines @@ -219,32 +219,54 @@ Application.Controllers.controller('MachinesController', ['$scope', '$state', '_ */ $scope.setupMachinesTour = function () { // setup the tour for admins only - if ($scope.currentUser && $scope.currentUser.role === 'admin') { + if (AuthService.isAuthorized(['admin', 'manager'])) { // get the tour defined by the ui-tour directive const uitour = uiTourService.getTourByName('machines'); - uitour.createStep({ - selector: 'body', - stepId: 'welcome', - order: 0, - title: _t('app.public.tour.machines.welcome.title'), - content: _t('app.public.tour.machines.welcome.content'), - placement: 'bottom', - orphan: true - }); + if (AuthService.isAuthorized('admin')) { + uitour.createStep({ + selector: 'body', + stepId: 'welcome', + order: 0, + title: _t('app.public.tour.machines.welcome.title'), + content: _t('app.public.tour.machines.welcome.content'), + placement: 'bottom', + orphan: true + }); + if ($scope.machines.length > 0) { + uitour.createStep({ + selector: '.machines-list .show-button', + stepId: 'view', + order: 1, + title: _t('app.public.tour.machines.view.title'), + content: _t('app.public.tour.machines.view.content'), + placement: 'top' + }); + } + } else { + uitour.createStep({ + selector: 'body', + stepId: 'welcome_manager', + order: 0, + title: _t('app.public.tour.machines.welcome_manager.title'), + content: _t('app.public.tour.machines.welcome_manager.content'), + placement: 'bottom', + orphan: true + }); + } if ($scope.machines.length > 0) { uitour.createStep({ - selector: '.machines-list .show-button', - stepId: 'view', - order: 1, - title: _t('app.public.tour.machines.view.title'), - content: _t('app.public.tour.machines.view.content'), + selector: '.machines-list .reserve-button', + stepId: 'reserve', + order: 2, + title: _t('app.public.tour.machines.reserve.title'), + content: _t('app.public.tour.machines.reserve.content'), placement: 'top' }); } uitour.createStep({ selector: 'body', stepId: 'conclusion', - order: 2, + order: 3, title: _t('app.public.tour.conclusion.title'), content: _t('app.public.tour.conclusion.content'), placement: 'bottom', @@ -406,7 +428,7 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat // indicates the state of the current view : calendar or plans information $scope.plansAreShown = false; - // will store the user's plan if he choosed to buy one + // will store the user's plan if he chose to buy one $scope.selectedPlan = null; // the moment when the plan selection changed for the last time, used to trigger changes in the cart @@ -524,12 +546,13 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat * When modifying an already booked reservation, callback when the modification was successfully done. */ $scope.modifyMachineSlot = function () { - $scope.events.placable.title = $scope.currentUser.role !== 'admin' ? _t('app.logged.machines_reserve.i_ve_reserved') : _t('app.logged.machines_reserve.not_available'); + $scope.events.placable.title = $scope.currentUser.id === $scope.events.modifiable.user.id ? _t('app.logged.machines_reserve.i_ve_reserved') : _t('app.logged.machines_reserve.not_available'); $scope.events.placable.backgroundColor = 'white'; $scope.events.placable.borderColor = $scope.events.modifiable.borderColor; $scope.events.placable.id = $scope.events.modifiable.id; $scope.events.placable.is_reserved = true; $scope.events.placable.can_modify = true; + $scope.events.placable.user = angular.copy($scope.events.modifiable.user); $scope.events.modifiable.backgroundColor = 'white'; $scope.events.modifiable.title = ''; @@ -549,7 +572,7 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat $scope.events.placable.backgroundColor = 'white'; $scope.events.placable.title = ''; } - $scope.events.modifiable.title = $scope.currentUser.role !== 'admin' ? _t('app.logged.machines_reserve.i_ve_reserved') : _t('app.logged.machines_reserve.not_available'); + $scope.events.modifiable.title = $scope.currentUser.id === $scope.events.modifiable.user.id ? _t('app.logged.machines_reserve.i_ve_reserved') : _t('app.logged.machines_reserve.not_available'); $scope.events.modifiable.backgroundColor = 'white'; return updateCalendar(); @@ -604,16 +627,18 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat angular.forEach($scope.events.reserved, function (machineSlot, key) { machineSlot.is_reserved = true; machineSlot.can_modify = true; - if ($scope.currentUser.role !== 'admin') { - machineSlot.title = _t('app.logged.machines_reserve.i_ve_reserved'); - machineSlot.borderColor = BOOKED_SLOT_BORDER_COLOR; - updateMachineSlot(machineSlot, reservation, $scope.currentUser); - } else { + if ($scope.currentUser.role === 'admin' || ($scope.currentUser.role === 'manager' && reservation.user_id !== $scope.currentUser.id)) { + // an admin or a manager booked for someone else machineSlot.title = _t('app.logged.machines_reserve.not_available'); machineSlot.borderColor = UNAVAILABLE_SLOT_BORDER_COLOR; updateMachineSlot(machineSlot, reservation, $scope.ctrl.member); + } else { + // booked for "myself" + machineSlot.title = _t('app.logged.machines_reserve.i_ve_reserved'); + machineSlot.borderColor = BOOKED_SLOT_BORDER_COLOR; + updateMachineSlot(machineSlot, reservation, $scope.currentUser); } - return machineSlot.backgroundColor = 'white'; + machineSlot.backgroundColor = 'white'; }); if ($scope.selectedPlan) { diff --git a/app/assets/javascripts/controllers/main_nav.js b/app/assets/javascripts/controllers/main_nav.js index fe9adada4..88ee97899 100644 --- a/app/assets/javascripts/controllers/main_nav.js +++ b/app/assets/javascripts/controllers/main_nav.js @@ -81,59 +81,73 @@ Application.Controllers.controller('MainNavController', ['$scope', function ($sc { state: 'app.admin.calendar', linkText: 'app.public.common.manage_the_calendar', - linkIcon: 'calendar' + linkIcon: 'calendar', + authorizedRoles: ['admin', 'manager'] }, { state: 'app.public.machines_list', linkText: 'app.public.common.manage_the_machines', - linkIcon: 'cogs' + linkIcon: 'cogs', + authorizedRoles: ['admin', 'manager'] }, { state: 'app.admin.trainings', linkText: 'app.public.common.trainings_monitoring', - linkIcon: 'graduation-cap' + linkIcon: 'graduation-cap', + authorizedRoles: ['admin', 'manager'] }, { state: 'app.admin.events', linkText: 'app.public.common.manage_the_events', - linkIcon: 'tags' + linkIcon: 'tags', + authorizedRoles: ['admin', 'manager'] }, { class: 'menu-spacer' }, { state: 'app.admin.members', linkText: 'app.public.common.manage_the_users', - linkIcon: 'users' + linkIcon: 'users', + authorizedRoles: ['admin', 'manager'] }, { state: 'app.admin.pricing', linkText: 'app.public.common.subscriptions_and_prices', - linkIcon: 'money' + linkIcon: 'money', + authorizedRoles: ['admin'] }, { state: 'app.admin.invoices', linkText: 'app.public.common.manage_the_invoices', - linkIcon: 'file-pdf-o' + linkIcon: 'file-pdf-o', + authorizedRoles: ['admin', 'manager'] }, { state: 'app.admin.statistics', linkText: 'app.public.common.statistics', - linkIcon: 'bar-chart-o' + linkIcon: 'bar-chart-o', + authorizedRoles: ['admin'] + }, + { + class: 'menu-spacer', + authorizedRoles: ['admin'] }, - { class: 'menu-spacer' }, { state: 'app.admin.settings', linkText: 'app.public.common.customization', - linkIcon: 'gear' + linkIcon: 'gear', + authorizedRoles: ['admin'] }, { state: 'app.admin.project_elements', linkText: 'app.public.common.manage_the_projects_elements', - linkIcon: 'tasks' + linkIcon: 'tasks', + authorizedRoles: ['admin'] }, { state: 'app.admin.open_api_clients', linkText: 'app.public.common.open_api_clients', - linkIcon: 'cloud' + linkIcon: 'cloud', + authorizedRoles: ['admin'] } ].concat(Fablab.adminNavLinks); diff --git a/app/assets/javascripts/controllers/plans.js.erb b/app/assets/javascripts/controllers/plans.js.erb index bbdbd4cee..c19adf5fd 100644 --- a/app/assets/javascripts/controllers/plans.js.erb +++ b/app/assets/javascripts/controllers/plans.js.erb @@ -12,8 +12,8 @@ */ 'use strict'; -Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScope', '$state', '$uibModal', 'Auth', 'dialogs', 'growl', 'plansPromise', 'groupsPromise', 'Subscription', 'Member', 'subscriptionExplicationsPromise', '_t', 'Wallet', 'helpers', - function ($scope, $rootScope, $state, $uibModal, Auth, dialogs, growl, plansPromise, groupsPromise, Subscription, Member, subscriptionExplicationsPromise, _t, Wallet, helpers) { +Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScope', '$state', '$uibModal', 'Auth', 'AuthService', 'dialogs', 'growl', 'plansPromise', 'groupsPromise', 'Subscription', 'Member', 'subscriptionExplicationsPromise', '_t', 'Wallet', 'helpers', + function ($scope, $rootScope, $state, $uibModal, Auth, AuthService, dialogs, growl, plansPromise, groupsPromise, Subscription, Member, subscriptionExplicationsPromise, _t, Wallet, helpers) { /* PUBLIC SCOPE */ // list of groups @@ -28,13 +28,6 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop // list of plans, classified by group $scope.plansClassifiedByGroup = []; - for (var group of Array.from($scope.groups)) { - const groupObj = { id: group.id, name: group.name, plans: [] }; - for (let plan of Array.from(plansPromise)) { - if (plan.group_id === group.id) { groupObj.plans.push(plan); } - } - $scope.plansClassifiedByGroup.push(groupObj); - } // user to deal with $scope.ctrl = { @@ -62,15 +55,15 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop /** * Callback to deal with the subscription of the user selected in the dropdown list instead of the current user's - * subscription. (admins only) + * subscription. (admins and managers only) */ $scope.updateMember = function () { $scope.selectedPlan = null; $scope.paid.plan = null; $scope.group.change = false; - return Member.get({ id: $scope.ctrl.member.id }, function (member) { + Member.get({ id: $scope.ctrl.member.id }, function (member) { $scope.ctrl.member = member; - return $scope.group.id = $scope.ctrl.member.group_id; + $scope.group.id = $scope.ctrl.member.group_id; }); }; @@ -97,14 +90,17 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop $scope.openSubscribePlanModal = function () { Wallet.getWalletByUser({ user_id: $scope.ctrl.member.id }, function (wallet) { const amountToPay = helpers.getAmountToPay($scope.cart.total, wallet.amount); - if (($scope.currentUser.role !== 'admin') && (amountToPay > 0)) { + if ((AuthService.isAuthorized('member') && amountToPay > 0) + || (AuthService.isAuthorized('manager') && $scope.ctrl.member.id === $rootScope.currentUser.id && amountToPay > 0)) { if ($rootScope.fablabWithoutOnlinePayment) { growl.error(_t('app.public.plans.online_payment_disabled')); } else { return payByStripe(); } } else { - if (($scope.currentUser.role === 'admin') || (amountToPay === 0)) { + if (AuthService.isAuthorized('admin') + || (AuthService.isAuthorized('manager') && $scope.ctrl.member.id !== $rootScope.currentUser.id) + || amountToPay === 0) { return payOnSite(); } } @@ -115,7 +111,7 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop * Return the group object, identified by the ID set in $scope.group.id */ $scope.getUserGroup = function () { - for (group of Array.from($scope.groups)) { + for (const group of Array.from($scope.groups)) { if (group.id === $scope.group.id) { return group; } @@ -130,7 +126,8 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop $scope.ctrl.member = user; $scope.group.change = false; $scope.selectedPlan = null; - if ($scope.currentUser.role !== 'admin') { + if (AuthService.isAuthorized('member') || + (AuthService.isAuthorized('manager') && $scope.currentUser.id !== $scope.ctrl.member.id)) { $rootScope.currentUser = user; Auth._currentUser.group_id = user.group_id; growl.success(_t('app.public.plans.your_group_was_successfully_changed')); @@ -139,7 +136,8 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop } } , function (err) { - if ($scope.currentUser.role !== 'admin') { + if (AuthService.isAuthorized('member') || + (AuthService.isAuthorized('manager') && $scope.currentUser.id !== $scope.ctrl.member.id)) { growl.error(_t('app.public.plans.an_error_prevented_your_group_from_being_changed')); } else { growl.error(_t('app.public.plans.an_error_prevented_to_change_the_user_s_group')); @@ -179,8 +177,20 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop * Kind of constructor: these actions will be realized first when the controller is loaded */ const initialize = function () { + // group all plans by Group + for (const group of $scope.groups) { + const groupObj = { id: group.id, name: group.name, plans: [], actives: 0 }; + for (let plan of plansPromise) { + if (plan.group_id === group.id) { + groupObj.plans.push(plan); + if (!plan.disabled) { groupplansClassifiedByGroupObj.actives++; } + } + } + $scope.plansClassifiedByGroup.push(groupObj); + } + if ($scope.currentUser) { - if ($scope.currentUser.role !== 'admin') { + if (!AuthService.isAuthorized('admin')) { $scope.ctrl.member = $scope.currentUser; $scope.paid.plan = $scope.currentUser.subscribed_plan; $scope.group.id = $scope.currentUser.group_id; @@ -201,9 +211,9 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop * Compute the total amount for the current reservation according to the previously set parameters * and assign the result in $scope.reserve.amountTotal */ - var updateCartPrice = function () { - // first we check that a user was selected - if (Object.keys($scope.ctrl.member).length > 0) { + const updateCartPrice = function () { + // first we check the selection of a user + if (Object.keys($scope.ctrl.member).length > 0 && $scope.selectedPlan) { $scope.cart.total = $scope.selectedPlan.amount; // apply the coupon if any if ($scope.coupon.applied) { @@ -223,7 +233,7 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop /** * Open a modal window which trigger the stripe payment process */ - var payByStripe = function () { + const payByStripe = function () { $uibModal.open({ templateUrl: '<%= asset_path "stripe/payment_modal.html" %>', size: 'md', @@ -262,7 +272,7 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop CustomAsset.get({ name: 'cgv-file' }, function (cgv) { $scope.cgv = cgv.custom_asset; }); /** - * Callback for click on the 'proceed' button. + * Callback for a click on the 'proceed' button. * Handle the stripe's card tokenization process response and save the subscription to the API with the * card token just created. */ @@ -283,7 +293,7 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop /** * Open a modal window which trigger the local payment process */ - var payOnSite = function () { + const payOnSite = function () { $uibModal.open({ templateUrl: '<%= asset_path "plans/payment_modal.html" %>', size: 'sm', @@ -301,7 +311,7 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop // user wallet amount $scope.walletAmount = wallet.amount; - // subcription price, coupon subtracted if any + // subscription price, coupon subtracted if any $scope.price = price; // price to pay diff --git a/app/assets/javascripts/controllers/spaces.js.erb b/app/assets/javascripts/controllers/spaces.js.erb index fe268ea36..861c62fb0 100644 --- a/app/assets/javascripts/controllers/spaces.js.erb +++ b/app/assets/javascripts/controllers/spaces.js.erb @@ -98,8 +98,8 @@ class SpacesController { /** * Controller used in the public listing page, allowing everyone to see the list of spaces */ -Application.Controllers.controller('SpacesController', ['$scope', '$state', 'spacesPromise', '_t', 'Member', 'uiTourService', - function ($scope, $state, spacesPromise, _t, Member, uiTourService) { +Application.Controllers.controller('SpacesController', ['$scope', '$state', 'spacesPromise', 'AuthService', '_t', 'Member', 'uiTourService', + function ($scope, $state, spacesPromise, AuthService, _t, Member, uiTourService) { /* PUBLIC SCOPE */ // Retrieve the list of spaces @@ -131,9 +131,10 @@ Application.Controllers.controller('SpacesController', ['$scope', '$state', 'spa */ $scope.setupSpacesTour = function () { // setup the tour for admins only - if ($scope.currentUser && $scope.currentUser.role === 'admin') { + if (AuthService.isAuthorized(['admin', 'manager'])) { // get the tour defined by the ui-tour directive const uitour = uiTourService.getTourByName('spaces'); + if (AuthService.isAuthorized('admin')) { uitour.createStep({ selector: 'body', stepId: 'welcome', @@ -153,10 +154,31 @@ Application.Controllers.controller('SpacesController', ['$scope', '$state', 'spa placement: 'top' }); } + } else { + uitour.createStep({ + selector: 'body', + stepId: 'welcome_manager', + order: 0, + title: _t('app.public.tour.spaces.welcome_manager.title'), + content: _t('app.public.tour.spaces.welcome_manager.content'), + placement: 'bottom', + orphan: true + }); + } + if ($scope.spaces.length > 0) { + uitour.createStep({ + selector: '.spaces-list .reserve-button', + stepId: 'reserve', + order: 2, + title: _t('app.public.tour.spaces.reserve.title'), + content: _t('app.public.tour.spaces.reserve.content'), + placement: 'top' + }); + } uitour.createStep({ selector: 'body', stepId: 'conclusion', - order: 2, + order: 3, title: _t('app.public.tour.conclusion.title'), content: _t('app.public.tour.conclusion.content'), placement: 'bottom', diff --git a/app/assets/javascripts/directives/cart.js.erb b/app/assets/javascripts/directives/cart.js.erb index 35546e187..eb927e229 100644 --- a/app/assets/javascripts/directives/cart.js.erb +++ b/app/assets/javascripts/directives/cart.js.erb @@ -10,8 +10,8 @@ * DS102: Remove unnecessary code created because of implicit returns * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', 'growl', 'Auth', 'Price', 'Wallet', 'CustomAsset', 'Slot', 'helpers', '_t', '$uibModal', - function ($rootScope, $uibModal, dialogs, growl, Auth, Price, Wallet, CustomAsset, Slot, helpers, _t, $uibModal) { +Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', 'growl', 'Auth', 'Price', 'Wallet', 'CustomAsset', 'Slot', 'AuthService', 'helpers', '_t', + function ($rootScope, $uibModal, dialogs, growl, Auth, Price, Wallet, CustomAsset, Slot, AuthService, helpers, _t) { return ({ restrict: 'E', scope: { @@ -74,38 +74,12 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', * @param slot {Object} fullCalendar event object */ $scope.validateSlot = function (slot) { - let sameTimeReservations = [ - 'training_reservations', - 'machine_reservations', - 'space_reservations', - 'events_reservations' - ].map(function (k) { - return _.filter($scope.user[k], function(r) { - return slot.start.isSame(r.start_at) || - (slot.end.isAfter(r.start_at) && slot.end.isBefore(r.end_at)) || - (slot.start.isAfter(r.start_at) && slot.start.isBefore(r.end_at)) || - (slot.start.isBefore(r.start_at) && slot.end.isAfter(r.end_at)); - }) - }); - sameTimeReservations = _.union.apply(null, sameTimeReservations); - if (sameTimeReservations.length > 0) { - const modalInstance = $uibModal.open({ - animation: true, - templateUrl: '<%= asset_path "shared/_reserve_slot_same_time.html" %>', - size: 'md', - controller: 'ReserveSlotSameTimeController', - resolve: { - sameTimeReservations: function() { return sameTimeReservations; } - } - }); - modalInstance.result.then(function(res) { + validateTags(slot, function () { + validateSameTimeReservations(slot, function () { slot.isValid = true; - return updateCartPrice(); - }); - } else { - slot.isValid = true; - return updateCartPrice(); - } + updateCartPrice(); + }) + }) }; /** @@ -167,7 +141,7 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', // first, we check that a user was selected if (Object.keys($scope.user).length > 0) { - // check user was selected a plan if slot is restricted for subscriptions + // check selected user has a subscription, if any slot is restricted for subscriptions const slotValidations = []; let slotNotValid; let slotNotValidError; @@ -195,7 +169,7 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', }); const hasPlanForSlot = slotValidations.every(function (a) { return a; }); if (!hasPlanForSlot) { - if (!$scope.isAdmin()) { + if (!AuthService.isAuthorized(['admin', 'manager'])) { return growl.error(_t('app.shared.cart.slot_restrict_subscriptions_must_select_plan')); } else { const modalInstance = $uibModal.open({ @@ -216,7 +190,7 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', return paySlots(); } } else { - // otherwise we alert, this error musn't occur when the current user is not admin + // otherwise we alert, this error musn't occur when the current user is not admin or manager return growl.error(_t('app.shared.cart.please_select_a_member_first')); } }; @@ -286,10 +260,18 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', }; /** - * Check if the currently logged user has teh 'admin' role? + * Check if the currently logged user has the 'admin' role OR the 'manager' role, but is not taking reseravtion for himself * @returns {boolean} */ - $scope.isAdmin = function () { return $rootScope.currentUser && ($rootScope.currentUser.role === 'admin'); }; + $scope.isAuthorized = function () { + if (AuthService.isAuthorized('admin')) return true; + + if (AuthService.isAuthorized('manager')) { + return ($rootScope.currentUser.id !== $scope.user.id); + } + + return false; + } /* PRIVATE SCOPE */ @@ -322,14 +304,84 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', }); }; + /** + * Validates that the current slot is reserved by a member with an authorized tag. Admin and managers can overpass + * the mismatch. + * @param slot {Object} fullCalendar event object. + * @param callback {function} + */ + const validateTags = function (slot, callback) { + const interTags = _.intersection.apply(null, [slot.tag_ids, $scope.user.tag_ids]); + if (slot.tag_ids.length === 0 || interTags.length > 0) { + if (typeof callback === 'function') callback(); + } else { + // ask confirmation + const modalInstance = $uibModal.open({ + animation: true, + templateUrl: '<%= asset_path "shared/_reserve_slot_tags_mismatch.html" %>', + size: 'md', + controller: 'ReserveSlotTagsMismatchController', + resolve: { + slotTags: function() { return slot.tags; }, + userTags: function () { return $scope.user.tags; }, + userName: function () { return $scope.user.name; } + } + }); + modalInstance.result.then(function(res) { + if (typeof callback === 'function') callback(res); + }); + } + } + + /** + * Validates that no other reservations were made that conflict the current slot and alert the user about the conflict. + * If the user is an administrator or a manager, he can overpass the conflict. + * @param slot {Object} fullCalendar event object. + * @param callback {function} + */ + const validateSameTimeReservations = function (slot, callback) { + let sameTimeReservations = [ + 'training_reservations', + 'machine_reservations', + 'space_reservations', + 'events_reservations' + ].map(function (k) { + return _.filter($scope.user[k], function(r) { + return slot.start.isSame(r.start_at) || + (slot.end.isAfter(r.start_at) && slot.end.isBefore(r.end_at)) || + (slot.start.isAfter(r.start_at) && slot.start.isBefore(r.end_at)) || + (slot.start.isBefore(r.start_at) && slot.end.isAfter(r.end_at)); + }) + }); + sameTimeReservations = _.union.apply(null, sameTimeReservations); + if (sameTimeReservations.length > 0) { + const modalInstance = $uibModal.open({ + animation: true, + templateUrl: '<%= asset_path "shared/_reserve_slot_same_time.html" %>', + size: 'md', + controller: 'ReserveSlotSameTimeController', + resolve: { + sameTimeReservations: function() { return sameTimeReservations; } + } + }); + modalInstance.result.then(function(res) { + if (typeof callback === 'function') callback(res); + }); + } else { + if (typeof callback === 'function') callback(); + } + } + /** * Callback triggered when the selected slot changed */ - var slotSelectionChanged = function () { + const slotSelectionChanged = function () { if ($scope.slot) { - // build a list of plans if this slot is restricted for subscriptions + // if this slot is restricted for subscribers... if ($scope.slot.plan_ids.length > 0) { + // ... we select all the plans matching these restrictions... const _plans = _.filter($scope.plans, function (p) { return _.include($scope.slot.plan_ids, p.id) }); + // ... and we group these plans, by Group... $scope.slot.plansGrouped = []; $scope.slot.group_ids = []; for (let group of Array.from($scope.groups)) { @@ -338,7 +390,9 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', if (plan.group_id === group.id) { groupObj.plans.push(plan); } } if (groupObj.plans.length > 0) { - if ($scope.isAdmin()) { + // ... Finally, we only keep the plans matching the group of the current user + // OR all plans if the current user is admin or manager + if (AuthService.isAuthorized(['admin', 'manager'])) { $scope.slot.plansGrouped.push(groupObj); } else if ($scope.user.group_id === groupObj.id) { $scope.slot.plansGrouped.push(groupObj); @@ -398,7 +452,7 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', } } , function (type) { - // the user has choosen an action, so we proceed + // the user has chosen an action, so we proceed if (type === 'move') { if (typeof $scope.onSlotStartToModify === 'function') { $scope.onSlotStartToModify(); } return $scope.events.modifiable = $scope.slot; @@ -433,7 +487,7 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', /** * Reset the parameters that may lead to a wrong price but leave the content (events added to cart) */ - var resetCartState = function () { + const resetCartState = function () { $scope.selectedPlan = null; $scope.coupon.applied = null; $scope.events.moved = null; @@ -446,8 +500,8 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', * Determines if the provided booked slot is able to be modified by the user. * @param slot {Object} fullCalendar event object */ - var slotCanBeModified = function (slot) { - if ($scope.isAdmin()) { return true; } + const slotCanBeModified = function (slot) { + if (AuthService.isAuthorized(['admin', 'manager'])) { return true; } const slotStart = moment(slot.start); const now = moment(); return (slot.can_modify && $scope.enableBookingMove && (slotStart.diff(now, 'hours') >= $scope.moveBookingDelay)); @@ -457,8 +511,8 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', * Determines if the provided booked slot is able to be canceled by the user. * @param slot {Object} fullCalendar event object */ - var slotCanBeCanceled = function (slot) { - if ($scope.isAdmin()) { return true; } + const slotCanBeCanceled = function (slot) { + if (AuthService.isAuthorized(['admin', 'manager'])) { return true; } const slotStart = moment(slot.start); const now = moment(); return (slot.can_modify && $scope.enableBookingCancel && (slotStart.diff(now, 'hours') >= $scope.cancelBookingDelay)); @@ -467,7 +521,7 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', /** * Callback triggered when the selected slot changed */ - var planSelectionChanged = function () { + const planSelectionChanged = function () { if (Auth.isAuthenticated()) { if ($scope.selectedPlan !== $scope.plan) { $scope.selectedPlan = $scope.plan; @@ -486,27 +540,27 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', /** * Update the total price of the current selection/reservation */ - var updateCartPrice = function () { + const updateCartPrice = function () { if (Object.keys($scope.user).length > 0) { const r = mkReservation($scope.user, $scope.events.reserved, $scope.selectedPlan); return Price.compute(mkRequestParams(r, $scope.coupon.applied), function (res) { $scope.amountTotal = res.price; $scope.totalNoCoupon = res.price_without_coupon; - return setSlotsDetails(res.details); + setSlotsDetails(res.details); }); } else { // otherwise we alert, this error musn't occur when the current user is not admin growl.warning(_t('app.shared.cart.please_select_a_member_first')); - return $scope.amountTotal = null; + $scope.amountTotal = null; } }; - var setSlotsDetails = function (details) { + const setSlotsDetails = function (details) { angular.forEach($scope.events.reserved, function (slot) { angular.forEach(details.slots, function (s) { if (moment(s.start_at).isSame(slot.start)) { slot.promo = s.promo; - return slot.price = s.price; + slot.price = s.price; } }); }); @@ -518,7 +572,7 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', * @param coupon {Object} Coupon as returned from the API * @return {{reservation:Object, coupon_code:string}} */ - var mkRequestParams = function (reservation, coupon) { + const mkRequestParams = function (reservation, coupon) { return { reservation, coupon_code: ((coupon ? coupon.code : undefined)) @@ -532,7 +586,7 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', * @param [plan] {Object} Plan as retrieved from the API: plan to buy with the current reservation * @return {{user_id:Number, reservable_id:Number, reservable_type:String, slots_attributes:Array, plan_id:Number|null}} */ - var mkReservation = function (member, slots, plan) { + const mkReservation = function (member, slots, plan) { const reservation = { user_id: member.id, reservable_id: $scope.reservableId, @@ -555,7 +609,7 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', /** * Open a modal window that allows the user to process a credit card payment for his current shopping cart. */ - var payByStripe = function (reservation) { + const payByStripe = function (reservation) { $uibModal.open({ templateUrl: '<%= asset_path "stripe/payment_modal.html" %>', size: 'md', @@ -612,7 +666,7 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', /** * Open a modal window that allows the user to process a local payment for his current shopping cart (admin only). */ - var payOnSite = function (reservation) { + const payOnSite = function (reservation) { $uibModal.open({ templateUrl: '<%= asset_path "shared/valid_reservation_modal.html" %>', size: 'sm', @@ -681,7 +735,7 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', /** * Actions to run after the payment was successful */ - var afterPayment = function (reservation) { + const afterPayment = function (reservation) { // we set the cart content as 'paid' to display a summary of the transaction $scope.events.paid = $scope.events.reserved; $scope.amountPaid = $scope.amountTotal; @@ -697,19 +751,22 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', /** * Actions to pay slots */ - var paySlots = function() { + const paySlots = function() { const reservation = mkReservation($scope.user, $scope.events.reserved, $scope.selectedPlan); return Wallet.getWalletByUser({ user_id: $scope.user.id }, function (wallet) { const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount); - if (!$scope.isAdmin() && (amountToPay > 0)) { + if ((AuthService.isAuthorized(['member']) && amountToPay > 0) + || (AuthService.isAuthorized('manager') && $scope.user.id === $rootScope.currentUser.id && amountToPay > 0)) { if ($rootScope.fablabWithoutOnlinePayment) { growl.error(_t('app.shared.cart.online_payment_disabled')); } else { return payByStripe(reservation); } } else { - if ($scope.isAdmin() || (amountToPay === 0)) { + if (AuthService.isAuthorized(['admin']) + || (AuthService.isAuthorized('manager') && $scope.user.id !== $rootScope.currentUser.id) + || amountToPay === 0) { return payOnSite(reservation); } } @@ -724,12 +781,38 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', ]); /** - * Controller of modal for show reservations the same date at the same time + * Controller of the modal showing the reservations the same date at the same time */ -Application.Controllers.controller('ReserveSlotSameTimeController', ['$scope', '$uibModalInstance', 'sameTimeReservations', 'growl', '_t', - function ($scope, $uibModalInstance, sameTimeReservations, growl, _t) { +Application.Controllers.controller('ReserveSlotSameTimeController', ['$scope', '$uibModalInstance', 'AuthService', 'sameTimeReservations', + function ($scope, $uibModalInstance, AuthService, sameTimeReservations) { $scope.sameTimeReservations = sameTimeReservations; $scope.bookSlotAtSameTime = Fablab.bookSlotAtSameTime; + $scope.isAuthorized = AuthService.isAuthorized; + /** + * Confirmation callback + */ + $scope.ok = function () { + $uibModalInstance.close({}); + } + /** + * Cancellation callback + */ + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + } + } +]); + + +/** + * Controller of the modal showing the slot tags + */ +Application.Controllers.controller('ReserveSlotTagsMismatchController', ['$scope', '$uibModalInstance', 'AuthService', 'slotTags', 'userTags', 'userName', + function ($scope, $uibModalInstance, AuthService, slotTags, userTags, userName) { + $scope.slotTags = slotTags; + $scope.userTags = userTags; + $scope.userName = userName; + $scope.isAuthorized = AuthService.isAuthorized; /** * Confirmation callback */ diff --git a/app/assets/javascripts/router.js.erb b/app/assets/javascripts/router.js.erb index cb3935ea2..b4d800d98 100644 --- a/app/assets/javascripts/router.js.erb +++ b/app/assets/javascripts/router.js.erb @@ -55,7 +55,7 @@ angular.module('application.router', ['ui.router']) .state('app.logged', { abstract: true, data: { - authorizedRoles: ['member', 'admin'] + authorizedRoles: ['member', 'admin', 'manager'] }, resolve: { currentUser: ['Auth', function (Auth) { return Auth.currentUser(); }], @@ -68,7 +68,7 @@ angular.module('application.router', ['ui.router']) .state('app.admin', { abstract: true, data: { - authorizedRoles: ['admin'] + authorizedRoles: ['admin', 'manager'] }, resolve: { currentUser: ['Auth', function (Auth) { return Auth.currentUser(); }], @@ -866,6 +866,8 @@ angular.module('application.router', ['ui.router']) resolve: { membersPromise: ['Member', function (Member) { return Member.list({ query: { search: '', order_by: 'id', page: 1, size: 20 } }).$promise; }], adminsPromise: ['Admin', function (Admin) { return Admin.query().$promise; }], + partnersPromise: ['User', function (User) { return User.query({ role: 'partner' }).$promise; }], + managersPromise: ['User', function (User) { return User.query({ role: 'manager' }).$promise; }], groupsPromise: ['Group', function (Group) { return Group.query().$promise; }], tagsPromise: ['Tag', function (Tag) { return Tag.query().$promise; }], authProvidersPromise: ['AuthProvider', function (AuthProvider) { return AuthProvider.query().$promise; }] @@ -929,8 +931,21 @@ angular.module('application.router', ['ui.router']) } } }) + .state('app.admin.managers_new', { + url: '/admin/managers/new', + views: { + 'main@': { + templateUrl: '<%= asset_path "admin/managers/new.html" %>', + controller: 'NewManagerController' + } + }, + resolve: { + groupsPromise: ['Group', function (Group) { return Group.query().$promise; }], + tagsPromise: ['Tag', function (Tag) { return Tag.query().$promise; }] + } + }) - // authentification providers + // authentication providers .state('app.admin.authentication_new', { url: '/admin/authentications/new', views: { diff --git a/app/assets/javascripts/services/_t.js b/app/assets/javascripts/services/_t.js index 2ea42f764..735f51e6f 100644 --- a/app/assets/javascripts/services/_t.js +++ b/app/assets/javascripts/services/_t.js @@ -1,9 +1,8 @@ 'use strict'; -Application.Services.factory('_t', ['$filter', function ($filter) { - return function (key, interpolation, options) { - if (interpolation == null) { interpolation = undefined; } - if (options == null) { options = undefined; } - return $filter('translate')(key, interpolation, options); +Application.Services.factory('_t', ['$translate', function ($translate) { + return function (key, interpolations) { + if (interpolations == null) { interpolations = undefined; } + return $translate.instant(key, interpolations); }; }]); diff --git a/app/assets/javascripts/services/help.js.erb b/app/assets/javascripts/services/help.js.erb index 344af4bde..1397422eb 100644 --- a/app/assets/javascripts/services/help.js.erb +++ b/app/assets/javascripts/services/help.js.erb @@ -1,6 +1,7 @@ 'use strict'; -Application.Services.factory('Help', ['$rootScope', '$uibModal', '$state', function ($rootScope, $uibModal, $state) { +Application.Services.factory('Help', ['$rootScope', '$uibModal', '$state', 'AuthService', + function ($rootScope, $uibModal, $state, AuthService) { const TOURS = { 'app.public.home': 'welcome', 'app.public.machines_list': 'machines', @@ -19,7 +20,7 @@ Application.Services.factory('Help', ['$rootScope', '$uibModal', '$state', funct return function (e) { - if (!$rootScope.currentUser || $rootScope.currentUser.role !== 'admin') return; + if (!AuthService.isAuthorized(['admin', 'manager'])) return; if (e.key === 'F1') { e.preventDefault(); diff --git a/app/assets/javascripts/services/member.js b/app/assets/javascripts/services/member.js index 52eab1157..c920fb263 100644 --- a/app/assets/javascripts/services/member.js +++ b/app/assets/javascripts/services/member.js @@ -44,6 +44,10 @@ Application.Services.factory('Member', ['$resource', '$q', function ($resource, return response.data; } } + }, + updateRole: { + method: 'PATCH', + url: '/api/members/:id/update_role' } } ); diff --git a/app/assets/javascripts/services/user.js b/app/assets/javascripts/services/user.js index d64b2e29d..c49649ac9 100644 --- a/app/assets/javascripts/services/user.js +++ b/app/assets/javascripts/services/user.js @@ -1,8 +1,8 @@ 'use strict'; Application.Services.factory('User', ['$resource', function ($resource) { - return $resource('/api/users', - {}, { + return $resource('/api/users/:id', + { id: '@id' }, { query: { isArray: false } diff --git a/app/assets/stylesheets/app.layout.scss b/app/assets/stylesheets/app.layout.scss index eb84fdb79..0b80d229c 100644 --- a/app/assets/stylesheets/app.layout.scss +++ b/app/assets/stylesheets/app.layout.scss @@ -678,3 +678,11 @@ body.container { left: -4px; } } + + +.middle-of-inputs { + line-height: 24px; + padding: 6px; + text-align: center; + vertical-align: middle; +} \ No newline at end of file diff --git a/app/assets/stylesheets/app.nav.scss b/app/assets/stylesheets/app.nav.scss index c6e4ab790..f632fd94f 100644 --- a/app/assets/stylesheets/app.nav.scss +++ b/app/assets/stylesheets/app.nav.scss @@ -107,7 +107,6 @@ > li.menu-spacer { height: 1px; margin: 6px 80% 6px 5px; - background: linear-gradient(45deg, black, transparent); } ul { @@ -535,3 +534,13 @@ display: inline-block !important; padding: 11px 44px !important; } + +li.level-2-tab > a { + line-height: 14px; + font-size: 12px; +} + +li.active.level-2-tab > a { + background: linear-gradient(#eee, #fff); +} + diff --git a/app/assets/stylesheets/modules/members.scss b/app/assets/stylesheets/modules/members.scss new file mode 100644 index 000000000..34a9209f9 --- /dev/null +++ b/app/assets/stylesheets/modules/members.scss @@ -0,0 +1,4 @@ +.promote-member img { + width: 16px; + height: 21px; +} \ No newline at end of file diff --git a/app/assets/templates/admin/calendar/calendar.html.erb b/app/assets/templates/admin/calendar/calendar.html.erb index fe97354e8..60e293c4c 100644 --- a/app/assets/templates/admin/calendar/calendar.html.erb +++ b/app/assets/templates/admin/calendar/calendar.html.erb @@ -45,7 +45,7 @@