From 51aa9670018cc6aa173b4ce2d9fda5d6a4ed82be Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 3 Oct 2022 14:22:15 +0200 Subject: [PATCH 1/3] (feat) dismiss a user to a lower privileged role --- CHANGELOG.md | 2 + app/controllers/api/members_controller.rb | 92 ++++--------- app/frontend/src/javascript/api/member.ts | 7 +- .../components/user/change-role-modal.tsx | 129 ++++++++++++++++++ .../javascript/controllers/admin/members.js | 14 ++ app/frontend/src/stylesheets/application.scss | 1 + .../stylesheets/modules/form/form-select.scss | 9 ++ .../templates/admin/members/edit.html | 3 +- app/services/export_service.rb | 48 +++++++ app/services/members/members_service.rb | 38 ++++++ config/locales/app.admin.en.yml | 11 +- 11 files changed, 282 insertions(+), 72 deletions(-) create mode 100644 app/frontend/src/javascript/components/user/change-role-modal.tsx create mode 100644 app/frontend/src/stylesheets/modules/form/form-select.scss create mode 100644 app/services/export_service.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 47e0547e0..a570eb140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog Fab-manager +- Ability to dismiss a user to a lower privileged role + ## v5.4.20 2022 September 27 - Fix a bug: unable to show the daily view of the public agenda, if it contains trainings or events diff --git a/app/controllers/api/members_controller.rb b/app/controllers/api/members_controller.rb index 5ca7f455c..12cb86743 100644 --- a/app/controllers/api/members_controller.rb +++ b/app/controllers/api/members_controller.rb @@ -18,15 +18,7 @@ class API::MembersController < API::ApiController end def last_subscribed - @query = User.active.with_role(:member) - .includes(:statistic_profile, profile: [:user_avatar]) - .where('is_allow_contact = true AND confirmed_at IS NOT NULL') - .order('created_at desc') - .limit(params[:last]) - - # remove unmerged profiles from list - @members = @query.to_a - @members.delete_if(&:need_completion?) + @query, @members = Members::MembersService.last_registered(params[:last]) @requested_attributes = ['profile'] render :index @@ -74,9 +66,7 @@ class API::MembersController < API::ApiController def export_subscriptions authorize :export - export = Export.where(category: 'users', export_type: 'subscriptions') - .where('created_at > ?', Subscription.maximum('updated_at')) - .last + export = ExportService.last_export('users/subscription') if export.nil? || !FileTest.exist?(export.file) @export = Export.new(category: 'users', export_type: 'subscriptions', user: current_user) if @export.save @@ -85,7 +75,7 @@ class API::MembersController < API::ApiController render json: @export.errors, status: :unprocessable_entity end else - send_file File.join(Rails.root, export.file), + send_file Rails.root.join(export.file), type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', disposition: 'attachment' end @@ -95,9 +85,7 @@ class API::MembersController < API::ApiController def export_reservations authorize :export - export = Export.where(category: 'users', export_type: 'reservations') - .where('created_at > ?', Reservation.maximum('updated_at')) - .last + export = ExportService.last_export('users/reservations') if export.nil? || !FileTest.exist?(export.file) @export = Export.new(category: 'users', export_type: 'reservations', user: current_user) if @export.save @@ -106,7 +94,7 @@ class API::MembersController < API::ApiController render json: @export.errors, status: :unprocessable_entity end else - send_file File.join(Rails.root, export.file), + send_file Rails.root.join(export.file), type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', disposition: 'attachment' end @@ -115,17 +103,7 @@ class API::MembersController < API::ApiController def export_members authorize :export - last_update = [ - User.members.maximum('updated_at'), - Profile.where(user_id: User.members).maximum('updated_at'), - InvoicingProfile.where(user_id: User.members).maximum('updated_at'), - StatisticProfile.where(user_id: User.members).maximum('updated_at'), - Subscription.maximum('updated_at') || DateTime.current - ].max - - export = Export.where(category: 'users', export_type: 'members') - .where('created_at > ?', last_update) - .last + export = ExportService.last_export('users/members') if export.nil? || !FileTest.exist?(export.file) @export = Export.new(category: 'users', export_type: 'members', user: current_user) if @export.save @@ -134,7 +112,7 @@ class API::MembersController < API::ApiController render json: @export.errors, status: :unprocessable_entity end else - send_file File.join(Rails.root, export.file), + send_file Rails.root.join(export.file), type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', disposition: 'attachment' end @@ -158,8 +136,8 @@ class API::MembersController < API::ApiController else render json: @member.errors, status: :unprocessable_entity end - rescue DuplicateIndexError => error - render json: { error: t('members.please_input_the_authentication_code_sent_to_the_address', EMAIL: error.message) }, + rescue DuplicateIndexError => e + render json: { error: t('members.please_input_the_authentication_code_sent_to_the_address', EMAIL: e.message) }, status: :unprocessable_entity end else @@ -176,7 +154,6 @@ class API::MembersController < API::ApiController query = Members::ListService.list(query_params) @max_members = query.except(:offset, :limit, :order).count @members = query.to_a - end def search @@ -196,7 +173,7 @@ class API::MembersController < API::ApiController render json: { tours: [params[:tour]] } else tours = "#{@member.profile.tours} #{params[:tour]}" - @member.profile.update_attributes(tours: tours.strip) + @member.profile.update(tours: tours.strip) render json: { tours: @member.profile.tours.split } end @@ -205,31 +182,8 @@ class API::MembersController < API::ApiController def update_role authorize @member - # we do not allow dismissing a user to a lower role - if params[:role] == 'member' - render 403 and return if @member.role == 'admin' || @member.role == 'manager' - elsif params[:role] == 'manager' - render 403 and return if @member.role == 'admin' - end - - # do nothing if the role does not change - render json: @member and return if params[:role] == @member.role - - ex_role = @member.role.to_sym - @member.remove_role ex_role - @member.add_role params[:role] - - # if the new role is 'admin', then change the group to the admins group - @member.update_attributes(group_id: Group.find_by(slug: 'admins').id) if params[:role] == 'admin' - - NotificationCenter.call type: 'notify_user_role_update', - receiver: @member, - attached_object: @member - - NotificationCenter.call type: 'notify_admins_role_update', - receiver: User.admins_and_managers, - attached_object: @member, - meta_data: { ex_role: ex_role } + service = Members::MembersService.new(@member) + service.update_role(params[:role], params[:group_id]) render json: @member end @@ -265,12 +219,14 @@ class API::MembersController < API::ApiController profile_attributes: [:id, :first_name, :last_name, :phone, :interest, :software_mastered, :website, :job, :facebook, :twitter, :google_plus, :viadeo, :linkedin, :instagram, :youtube, :vimeo, :dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr, - user_avatar_attributes: %i[id attachment destroy]], + { user_avatar_attributes: %i[id attachment destroy] }], invoicing_profile_attributes: [ :id, :organization, - address_attributes: %i[id address], - organization_attributes: [:id, :name, address_attributes: %i[id address]], - user_profile_custom_fields_attributes: %i[id value invoicing_profile_id profile_custom_field_id] + { + address_attributes: %i[id address], + organization_attributes: [:id, :name, { address_attributes: %i[id address] }], + user_profile_custom_fields_attributes: %i[id value invoicing_profile_id profile_custom_field_id] + } ], statistic_profile_attributes: %i[id gender birthday]) @@ -280,14 +236,16 @@ class API::MembersController < API::ApiController profile_attributes: [:id, :first_name, :last_name, :phone, :interest, :software_mastered, :website, :job, :facebook, :twitter, :google_plus, :viadeo, :linkedin, :instagram, :youtube, :vimeo, :dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr, - user_avatar_attributes: %i[id attachment destroy]], + { user_avatar_attributes: %i[id attachment destroy] }], invoicing_profile_attributes: [ :id, :organization, - address_attributes: %i[id address], - organization_attributes: [:id, :name, address_attributes: %i[id address]], - user_profile_custom_fields_attributes: %i[id value invoicing_profile_id profile_custom_field_id] + { + address_attributes: %i[id address], + organization_attributes: [:id, :name, { address_attributes: %i[id address] }], + user_profile_custom_fields_attributes: %i[id value invoicing_profile_id profile_custom_field_id] + } ], - statistic_profile_attributes: [:id, :gender, :birthday, training_ids: []]) + statistic_profile_attributes: [:id, :gender, :birthday, { training_ids: [] }]) end end diff --git a/app/frontend/src/javascript/api/member.ts b/app/frontend/src/javascript/api/member.ts index 0c3697c18..9970bef03 100644 --- a/app/frontend/src/javascript/api/member.ts +++ b/app/frontend/src/javascript/api/member.ts @@ -1,7 +1,7 @@ import apiClient from './clients/api-client'; import { AxiosResponse } from 'axios'; import { serialize } from 'object-to-formdata'; -import { User, UserIndexFilter } from '../models/user'; +import { User, UserIndexFilter, UserRole } from '../models/user'; export default class MemberAPI { static async list (filters: UserIndexFilter): Promise> { @@ -35,6 +35,11 @@ export default class MemberAPI { return res?.data; } + static async updateRole (user: User, role: UserRole, groupId?: number): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/members/${user.id}/update_role`, { role, group_id: groupId }); + return res?.data; + } + static async current (): Promise { const res: AxiosResponse = await apiClient.get('/api/members/current'); return res?.data; diff --git a/app/frontend/src/javascript/components/user/change-role-modal.tsx b/app/frontend/src/javascript/components/user/change-role-modal.tsx new file mode 100644 index 000000000..3f844f643 --- /dev/null +++ b/app/frontend/src/javascript/components/user/change-role-modal.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useState } from 'react'; +import { FabModal, ModalSize } from '../base/fab-modal'; +import { User, UserRole } from '../../models/user'; +import { IApplication } from '../../models/application'; +import { Loader } from '../base/loader'; +import { react2angular } from 'react2angular'; +import { useTranslation } from 'react-i18next'; +import { HtmlTranslate } from '../base/html-translate'; +import { useForm } from 'react-hook-form'; +import MemberAPI from '../../api/member'; +import { FormSelect } from '../form/form-select'; +import { Group } from '../../models/group'; +import GroupAPI from '../../api/group'; + +declare const Application: IApplication; + +interface ChangeRoleModalProps { + isOpen: boolean, + toggleModal: () => void, + user: User, + onError: (message: string) => void, + onSuccess: (message: string) => void, +} + +interface RoleFormData { + role: UserRole, + groupId?: number +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectRoleOption = { value: UserRole, label: string, isDisabled: boolean }; +type selectGroupOption = { value: number, label: string }; + +/** + * This modal dialog allows to change the current role of the given user + */ +export const ChangeRoleModal: React.FC = ({ isOpen, toggleModal, user, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + const { control, handleSubmit } = useForm({ defaultValues: { groupId: user.group_id } }); + + const [groups, setGroups] = useState>([]); + const [selectedRole, setSelectedRole] = useState(user.role); + + useEffect(() => { + GroupAPI.index({ disabled: false, admins: false }).then(setGroups).catch(onError); + }, []); + + /** + * Handle the form submission: update the role on the API + */ + const onSubmit = (data: RoleFormData) => { + MemberAPI.updateRole(user, data.role, data.groupId).then(res => { + onSuccess( + t( + 'app.admin.change_role_modal.role_changed', + { OLD: t(`app.admin.change_role_modal.${user.role}`), NEW: t(`app.admin.change_role_modal.${res.role}`) } + ) + ); + toggleModal(); + }).catch(err => onError(t('app.admin.change_role_modal.error_while_changing_role') + err)); + }; + + /** + * Callback triggered when the user changes the selected role in the dropdown selection list + */ + const onRoleSelect = (data: UserRole) => { + setSelectedRole(data); + }; + + /** + * Return the various available roles for the select input + */ + const buildRolesOptions = (): Array => { + return [ + { value: 'admin' as UserRole, label: t('app.admin.change_role_modal.admin'), isDisabled: user.role === 'admin' }, + { value: 'manager' as UserRole, label: t('app.admin.change_role_modal.manager'), isDisabled: user.role === 'manager' }, + { value: 'member' as UserRole, label: t('app.admin.change_role_modal.member'), isDisabled: user.role === 'member' } + ]; + }; + + /** + * Return the various available groups for the select input + */ + const buildGroupsOptions = (): Array => { + return groups.map(group => { + return { value: group.id, label: group.name }; + }); + }; + + return ( + + +
+ + {selectedRole !== 'admin' && + } + +
+ ); +}; + +const ChangeRoleModalWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('changeRoleModal', react2angular(ChangeRoleModalWrapper, ['isOpen', 'toggleModal', 'user', 'onError', 'onSuccess'])); diff --git a/app/frontend/src/javascript/controllers/admin/members.js b/app/frontend/src/javascript/controllers/admin/members.js index ad0f343f7..ab1bdae22 100644 --- a/app/frontend/src/javascript/controllers/admin/members.js +++ b/app/frontend/src/javascript/controllers/admin/members.js @@ -724,6 +724,9 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', // modal dialog to take a new subscription $scope.isOpenSubscribeModal = false; + // modal dialog to change the user's role + $scope.isOpenChangeRoleModal = false; + /** * Open a modal dialog asking for confirmation to change the role of the given user * @returns {*} @@ -800,6 +803,17 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', $scope.$apply(); }, 50); }; + + /** + * Opens/closes the modal dialog to change the user's role + */ + $scope.toggleChangeRoleModal = () => { + setTimeout(() => { + $scope.isOpenChangeRoleModal = !$scope.isOpenChangeRoleModal; + $scope.$apply(); + }, 0); + }; + /** * Callback triggered if the subscription was successfully extended */ diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 14723c579..e7d0a4efe 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -37,6 +37,7 @@ @import "modules/form/abstract-form-item"; @import "modules/form/form-input"; @import "modules/form/form-rich-text"; +@import "modules/form/form-select"; @import "modules/form/form-switch"; @import "modules/group/change-group"; @import "modules/machines/machine-card"; diff --git a/app/frontend/src/stylesheets/modules/form/form-select.scss b/app/frontend/src/stylesheets/modules/form/form-select.scss new file mode 100644 index 000000000..73aa63a33 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-select.scss @@ -0,0 +1,9 @@ +.form-select { + .rs__menu .rs__menu-list { + .rs__option { + &--is-disabled { + color: var(--gray-hard-lightest); + } + } + } +} diff --git a/app/frontend/templates/admin/members/edit.html b/app/frontend/templates/admin/members/edit.html index 389ec4a9d..905924ff5 100644 --- a/app/frontend/templates/admin/members/edit.html +++ b/app/frontend/templates/admin/members/edit.html @@ -21,9 +21,10 @@
-
+
role icon{{ 'app.admin.members_edit.change_role' }}
+
diff --git a/app/services/export_service.rb b/app/services/export_service.rb new file mode 100644 index 000000000..1e4bd3ebe --- /dev/null +++ b/app/services/export_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Provides helper methods for Exports resources and properties +class ExportService + class << self + # Check if the last export of the provided type is still accurate or if it must be regenerated + def last_export(type) + case type + when 'users/members' + last_export_members + when 'users/reservations' + last_export_reservations + when 'users/subscription' + last_export_subscriptions + else + raise TypeError "unknown export type: #{type}" + end + end + + private + + def last_export_subscriptions + Export.where(category: 'users', export_type: 'subscriptions') + .where('created_at > ?', Subscription.maximum('updated_at')) + .last + end + + def last_export_reservations + Export.where(category: 'users', export_type: 'reservations') + .where('created_at > ?', Reservation.maximum('updated_at')) + .last + end + + def last_export_members + last_update = [ + User.members.maximum('updated_at'), + Profile.where(user_id: User.members).maximum('updated_at'), + InvoicingProfile.where(user_id: User.members).maximum('updated_at'), + StatisticProfile.where(user_id: User.members).maximum('updated_at'), + Subscription.maximum('updated_at') || DateTime.current + ].max + + Export.where(category: 'users', export_type: 'members') + .where('created_at > ?', last_update) + .last + end + end +end diff --git a/app/services/members/members_service.rb b/app/services/members/members_service.rb index c1009d214..7e7bf8807 100644 --- a/app/services/members/members_service.rb +++ b/app/services/members/members_service.rb @@ -107,6 +107,44 @@ class Members::MembersService params end + def self.last_registered(limit) + query = User.active.with_role(:member) + .includes(:statistic_profile, profile: [:user_avatar]) + .where('is_allow_contact = true AND confirmed_at IS NOT NULL') + .order('created_at desc') + .limit(limit) + + # remove unmerged profiles from list + members = query.to_a + members.delete_if(&:need_completion?) + + [query, members] + end + + def update_role(new_role, new_group_id = Group.first.id) + # do nothing if the role does not change + return if new_role == @member.role + + # update role + ex_role = @member.role.to_sym + @member.remove_role ex_role + @member.add_role new_role + + # if the new role is 'admin', then change the group to the admins group, otherwise to change to the provided group + group_id = new_role == 'admin' ? Group.find_by(slug: 'admins').id : new_group_id + @member.update(group_id: group_id) + + # notify + NotificationCenter.call type: 'notify_user_role_update', + receiver: @member, + attached_object: @member + + NotificationCenter.call type: 'notify_admins_role_update', + receiver: User.admins_and_managers, + attached_object: @member, + meta_data: { ex_role: ex_role } + end + private def notify_user_profile_complete(previous_state) diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index d2687bec2..ab7c05d53 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -974,15 +974,20 @@ en: to_complete: "To complete" refuse_documents: "Refusing the documents" refuse_documents_info: "After verification, you may notify the member that the evidence submitted is not acceptable. You can specify the reasons for your refusal and indicate the actions to be taken. The member will be notified by e-mail." - #edit a member - members_edit: + change_role_modal: change_role: "Change role" - warning_role_change: "

Warning: changing the role of a user is not a harmless operation. Is not currently possible to dismiss a user to a lower privileged role.

  • Members can only book reservations for themselves, paying by card or wallet.
  • Managers can book reservations for themselves, paying by card or wallet, and for other members and managers, by collecting payments at the checkout.
  • Administrators can only book reservations for members and managers, by collecting payments at the checkout. Moreover, they can change every settings of the application.
" + warning_role_change: "

Warning: changing the role of a user is not a harmless operation.

  • Members can only book reservations for themselves, paying by card or wallet.
  • Managers can book reservations for themselves, paying by card or wallet, and for other members and managers, by collecting payments at the checkout.
  • Administrators can only book reservations for members and managers, by collecting payments at the checkout. Moreover, they can change every settings of the application.
" + new_role: "New role" admin: "Administrator" manager: "Manager" member: "Member" + new_group: "New group" + new_group_help: "Members and managers must be placed in a group." + confirm: "Change role" role_changed: "Role successfully changed from {OLD} to {NEW}." error_while_changing_role: "An error occurred while changing the role. Please try again later." + #edit a member + members_edit: subscription: "Subscription" duration: "Duration:" expires_at: "Expires at:" From 2b805161775bce28fd55cdcf77d65d18912e9edf Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 5 Oct 2022 13:11:17 +0200 Subject: [PATCH 2/3] (bug) unable to generate statistics This bug is due to the refactoring of the statistics builder service, in 2022 august. The default_options were not defined so the nightly worker has no luck to run. The statistics may not have been built since then, so a rebuild is required --- CHANGELOG.md | 2 ++ app/services/statistics/builder_service.rb | 10 ++++++++++ test/services/statistic_service_test.rb | 4 ++++ 3 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a570eb140..00250404c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog Fab-manager - Ability to dismiss a user to a lower privileged role +- Fix a bug: unable to generate statistics +- [TODO DEPLOY] `rails fablab:maintenance:regenerate_statistics[2022,08]` ## v5.4.20 2022 September 27 diff --git a/app/services/statistics/builder_service.rb b/app/services/statistics/builder_service.rb index 78e3a829a..53d7a49a5 100644 --- a/app/services/statistics/builder_service.rb +++ b/app/services/statistics/builder_service.rb @@ -12,5 +12,15 @@ class Statistics::BuilderService Statistics::Builders::MembersBuilderService.build(options) Statistics::Builders::ProjectsBuilderService.build(options) end + + private + + def default_options + yesterday = 1.day.ago + { + start_date: yesterday.beginning_of_day, + end_date: yesterday.end_of_day + } + end end end diff --git a/test/services/statistic_service_test.rb b/test/services/statistic_service_test.rb index db10ba4b8..ef1556fa1 100644 --- a/test/services/statistic_service_test.rb +++ b/test/services/statistic_service_test.rb @@ -9,6 +9,10 @@ class StatisticServiceTest < ActionDispatch::IntegrationTest login_as(@admin, scope: :user) end + test 'build default stats' do + ::Statistics::BuilderService.generate_statistic + end + test 'build stats' do # Create a reservation to generate an invoice machine = Machine.find(1) From 5d90451e3d7c1105da4d1e76ed2902f9a872a255 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 5 Oct 2022 13:24:17 +0200 Subject: [PATCH 3/3] Version 5.4.21 --- CHANGELOG.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00250404c..37fbe40b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog Fab-manager +## v5.4.21 2022 October 05 + - Ability to dismiss a user to a lower privileged role - Fix a bug: unable to generate statistics - [TODO DEPLOY] `rails fablab:maintenance:regenerate_statistics[2022,08]` diff --git a/package.json b/package.json index 318be1130..73097400e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fab-manager", - "version": "5.4.20", + "version": "5.4.21", "description": "Fab-manager is the FabLab management solution. It provides a comprehensive, web-based, open-source tool to simplify your administrative tasks and your marker's projects.", "keywords": [ "fablab",