diff --git a/app/assets/javascripts/admin/commons/users.js b/app/assets/javascripts/admin/commons/users.js index 5ed2861f2d..ab7a3717f9 100644 --- a/app/assets/javascripts/admin/commons/users.js +++ b/app/assets/javascripts/admin/commons/users.js @@ -1,34 +1,91 @@ /*global $ */ -$(function () { - 'use strict'; +// Affiche la cible d'un rôle et n'y propose que les options du bon type. +// Le type attendu est porté par l'option de rôle sélectionnée +// (data-scope-type, vide = rôle global) ; chaque option de cible porte aussi +// son type. Aucun mapping en dur : tout vient du HTML rendu. +window.osuny.userRoles = { + init: function () { + 'use strict'; + var rows, + i; + if (!document.body.matches('.users-edit, .users-new, .users-update, .users-create')) { + return; + } + rows = document.querySelectorAll('[data-user-role]'); + for (i = 0; i < rows.length; i += 1) { + this.refreshRow(rows[i]); + } + this.initEvents(); + }, + + initEvents: function () { + 'use strict'; + document.addEventListener('change', this.onChangeRole.bind(this)); + $('#user_roles').on('cocoon:after-insert', this.onAfterInsert.bind(this)); + }, + + onChangeRole: function (event) { + 'use strict'; + var roleSelect = event.target.closest('[data-user-role-role]'); + if (roleSelect !== null) { + this.refreshRow(roleSelect.closest('[data-user-role]')); + } + }, + + onAfterInsert: function (event, row) { + 'use strict'; + this.refreshRow(row[0]); + }, + + refreshRow: function (row) { + 'use strict'; + var roleSelect = row.querySelector('[data-user-role-role]'), + roleOption = roleSelect && roleSelect.options[roleSelect.selectedIndex], + type = roleOption && roleOption.getAttribute('data-scope-type'), + container = row.querySelector('[data-user-role-scope-container]'), + scope = row.querySelector('[data-user-role-scope]'); - var changeRole = function () { - var value = $('select[name="user[role]"]').val(), - showForRoles, - required; - - $('*[data-show-for-roles]').each(function () { - showForRoles = $(this) - .attr('data-show-for-roles') - .split(','); - if ($.inArray(value, showForRoles) > -1) { - required = $(this).attr('data-required'); - if (required) { - $('input, select', this).attr('required', 'required'); - } else { - $('input, select', this).removeAttr('required'); - } - $(this).show(); - } else { - $(this).hide(); - // hidden field cannot be required - $('input, select', this).removeAttr('required'); + if (!type) { + container.classList.add('d-none'); + return; + } + container.classList.remove('d-none'); + this.filterScopeOptions(scope, type); + }, + + // N'affiche que les cibles du bon type et, si la cible courante n'est pas + // du bon type, sélectionne la première valide. + filterScopeOptions: function (scope, type) { + 'use strict'; + var options = scope.options, + firstMatch = null, + selected = scope.options[scope.selectedIndex], + matches, + i; + + for (i = 0; i < options.length; i += 1) { + matches = options[i].getAttribute('data-scope-type') === type; + options[i].hidden = !matches; + options[i].disabled = !matches; + if (matches && firstMatch === null) { + firstMatch = options[i].value; } - }); - }; + } + + if (!selected || selected.getAttribute('data-scope-type') !== type) { + scope.value = firstMatch; + } + }, - if ($('body').is('.users-edit, .users-new, .users-update, .users-create')) { - changeRole(); - $('select[name="user[role]"]').change(changeRole); + invoke: function () { + 'use strict'; + return { + init: this.init.bind(this) + }; } +}.invoke(); + +window.addEventListener('DOMContentLoaded', function () { + 'use strict'; + window.osuny.userRoles.init(); }); diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 8b01237bd2..f9dc0a1317 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -81,7 +81,8 @@ def breadcrumb def user_params params.require(:user) - .permit(:email, :first_name, :last_name, :role, :language_id, :picture, :picture_delete, :picture_infos, :mobile_phone, programs_to_manage_ids: [], websites_to_manage_ids: []) + .permit(:email, :first_name, :last_name, :language_id, :picture, :picture_delete, :picture_infos, :mobile_phone, + roles_attributes: [:id, :role, :scope_choice, :_destroy]) .merge(university_id: current_university.id) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c2c32ab025..f803610ce4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -27,7 +27,7 @@ def render_as_plain_text(partial: nil) end def current_ability - @current_ability ||= Ability.for(current_user) + @current_ability ||= User::Ability.for(current_user) end def ensure_university diff --git a/app/controllers/extranet/application_controller.rb b/app/controllers/extranet/application_controller.rb index de64c15c1c..9bf99d5d51 100644 --- a/app/controllers/extranet/application_controller.rb +++ b/app/controllers/extranet/application_controller.rb @@ -24,11 +24,7 @@ def authorize_extranet_access! end def user_is_authorized? - user_is_more_than_visitor || user_is_alumnus || user_is_contact - end - - def user_is_more_than_visitor - !current_user.visitor? + current_ability.can? :read, current_extranet end def user_is_alumnus diff --git a/app/models/ability.rb b/app/models/ability.rb deleted file mode 100644 index e98457ce2a..0000000000 --- a/app/models/ability.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -class Ability - include CanCan::Ability - - def self.for(user) - "Ability::#{user.role.classify}".constantize.new user - end - - def initialize(user) - @user = user ||= User.new # guest user (not logged in) - end - - protected - - def managed_websites_ids - @managed_websites_ids ||= @user.websites_to_manage.pluck(:communication_website_id) - end -end diff --git a/app/models/ability/visitor.rb b/app/models/ability/visitor.rb deleted file mode 100644 index 0784040545..0000000000 --- a/app/models/ability/visitor.rb +++ /dev/null @@ -1,2 +0,0 @@ -class Ability::Visitor < Ability -end \ No newline at end of file diff --git a/app/models/communication/website.rb b/app/models/communication/website.rb index 6bf83679df..1307975df7 100644 --- a/app/models/communication/website.rb +++ b/app/models/communication/website.rb @@ -138,7 +138,7 @@ class Communication::Website < ApplicationRecord def self.organized_for(user, language, limit: 6) university = user.university - ability = ::Ability.for(user) + ability = ::User::Ability.for(user) # Favorites first favorites_ids = user.favorites.websites.pluck(:about_id) websites = university.websites diff --git a/app/models/emergency_message.rb b/app/models/emergency_message.rb index c92419d4cb..7de79036db 100644 --- a/app/models/emergency_message.rb +++ b/app/models/emergency_message.rb @@ -53,7 +53,8 @@ def to_s def target users = User.all - users = users.where(university_id: university_id) if university_id.present? + users = users.where(university_id: university_id) if university_id.present? + # TODO(multiroles) @pabois users = users.where(role: role) if role.present? # next lines are to prevent to send the message to multiple occurrences of the same email (as for server_admin!) target_user_ids = users.select("DISTINCT ON (users.email) users.email, users.id").map(&:id) diff --git a/app/models/user.rb b/app/models/user.rb index 4f6ae1abd4..b7bbdeba37 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -30,6 +30,7 @@ # reset_password_token :string uniquely indexed # role :integer default("visitor") # second_factor_attempts_count :integer default(0) +# server_admin :boolean default(FALSE) # session_token :string # sign_in_count :integer default(0), not null # totp_timestamp :datetime diff --git a/app/models/user/ability.rb b/app/models/user/ability.rb new file mode 100644 index 0000000000..3714754438 --- /dev/null +++ b/app/models/user/ability.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class User::Ability + include CanCan::Ability + + attr_reader :user + + def self.for(user) + user ||= User.new # guest user (not logged in) + new(user) + end + + def initialize(user) + @user = user ||= User.new # guest user (not logged in) + if user.server_admin? + can :manage, :all + else + # Les rôles sont fusionnés du moins au plus privilégié (cf. User#ability_roles) + # pour que, en cas de conflit, la règle du rôle le plus puissant — déclarée + # en dernier — l'emporte (CanCanCan : last rule wins). + user.ability_roles.each do |role_name| + merge "User::Ability::#{role_name.classify}".constantize.new(user) + end + end + end + + protected + + # Ids des cibles attachées à un rôle donné de l'utilisateur. + # Chaque ability scope ses règles aux cibles de SON rôle (cf. User#scopes_for), + # ce qui permet d'être par exemple author du site A et website_manager du site B + # sans que les périmètres ne débordent l'un sur l'autre. + def scoped_ids(role_name) + @user.scopes_for(role_name).map(&:id) + end +end diff --git a/app/models/ability/admin.rb b/app/models/user/ability/admin.rb similarity index 99% rename from app/models/ability/admin.rb rename to app/models/user/ability/admin.rb index 35c551dc83..929e7b8f45 100644 --- a/app/models/ability/admin.rb +++ b/app/models/user/ability/admin.rb @@ -1,4 +1,4 @@ -class Ability::Admin < Ability +class User::Ability::Admin < User::Ability def initialize(user) super diff --git a/app/models/ability/alumni_manager.rb b/app/models/user/ability/alumni_manager.rb similarity index 97% rename from app/models/ability/alumni_manager.rb rename to app/models/user/ability/alumni_manager.rb index 5b8639bb08..4720123dbb 100644 --- a/app/models/ability/alumni_manager.rb +++ b/app/models/user/ability/alumni_manager.rb @@ -1,4 +1,4 @@ -class Ability::AlumniManager < Ability +class User::Ability::AlumniManager < User::Ability def initialize(user) super diff --git a/app/models/ability/author.rb b/app/models/user/ability/author.rb similarity index 96% rename from app/models/ability/author.rb rename to app/models/user/ability/author.rb index b314faaea9..a90bbc50d9 100644 --- a/app/models/ability/author.rb +++ b/app/models/user/ability/author.rb @@ -1,4 +1,4 @@ -class Ability::Author < Ability +class User::Ability::Author < User::Ability def initialize(user) super @@ -27,6 +27,12 @@ def initialize(user) protected + # Sites sur lesquels l'utilisateur est author. Surchargé en :contributor par + # Ability::Contributor, qui hérite des mêmes règles mais sur ses propres sites. + def managed_websites_ids + @managed_websites_ids ||= scoped_ids(:author) + end + def manage_records_based_on_abouts(record_class) can :manage, record_class, university_id: @user.university_id, about_type: 'Communication::Website::Agenda::Event::Localization', about_id: managed_agenda_event_localization_ids can :manage, record_class, university_id: @user.university_id, about_type: 'Communication::Website::Agenda::Exhibition::Localization', about_id: managed_agenda_exhibition_localization_ids diff --git a/app/models/ability/contributor.rb b/app/models/user/ability/contributor.rb similarity index 51% rename from app/models/ability/contributor.rb rename to app/models/user/ability/contributor.rb index 32a29e5f69..9b099ebc9c 100644 --- a/app/models/ability/contributor.rb +++ b/app/models/user/ability/contributor.rb @@ -1,4 +1,4 @@ -class Ability::Contributor < Ability::Author +class User::Ability::Contributor < User::Ability::Author def initialize(user) super @@ -9,4 +9,12 @@ def initialize(user) cannot :publish, Communication::Website::Jobboard::Job end + protected + + # Hérite des règles d'author, mais scopées sur les sites où l'utilisateur est + # contributor (l'override est résolu dynamiquement, y compris pendant `super`). + def managed_websites_ids + @managed_websites_ids ||= scoped_ids(:contributor) + end + end \ No newline at end of file diff --git a/app/models/ability/program_manager.rb b/app/models/user/ability/program_manager.rb similarity index 98% rename from app/models/ability/program_manager.rb rename to app/models/user/ability/program_manager.rb index 6af10383d5..2a81db9ddb 100644 --- a/app/models/ability/program_manager.rb +++ b/app/models/user/ability/program_manager.rb @@ -1,4 +1,4 @@ -class Ability::ProgramManager < Ability +class User::Ability::ProgramManager < User::Ability def initialize(user) super @@ -96,7 +96,7 @@ def managed_post_category_localization_ids end def managed_programs_ids - @managed_programs_ids ||= @user.programs_to_manage.pluck(:id) + @managed_programs_ids ||= scoped_ids(:program_manager) end def managed_program_localization_ids diff --git a/app/models/ability/server_admin.rb b/app/models/user/ability/server_admin.rb similarity index 57% rename from app/models/ability/server_admin.rb rename to app/models/user/ability/server_admin.rb index ba1e7f835a..b6cddfdf95 100644 --- a/app/models/ability/server_admin.rb +++ b/app/models/user/ability/server_admin.rb @@ -1,4 +1,4 @@ -class Ability::ServerAdmin < Ability +class User::Ability::ServerAdmin < User::Ability def initialize(user) super diff --git a/app/models/ability/teacher.rb b/app/models/user/ability/teacher.rb similarity index 96% rename from app/models/ability/teacher.rb rename to app/models/user/ability/teacher.rb index 1f95243e66..32b58b5a88 100644 --- a/app/models/ability/teacher.rb +++ b/app/models/user/ability/teacher.rb @@ -1,4 +1,4 @@ -class Ability::Teacher < Ability +class User::Ability::Teacher < User::Ability def initialize(user) super diff --git a/app/models/ability/website_manager.rb b/app/models/user/ability/website_manager.rb similarity index 97% rename from app/models/ability/website_manager.rb rename to app/models/user/ability/website_manager.rb index 97012a719a..3e6dafa543 100644 --- a/app/models/ability/website_manager.rb +++ b/app/models/user/ability/website_manager.rb @@ -1,4 +1,4 @@ -class Ability::WebsiteManager < Ability +class User::Ability::WebsiteManager < User::Ability def initialize(user) super @@ -38,6 +38,11 @@ def initialize(user) protected + # Sites dont l'utilisateur est responsable (rôle website_manager). + def managed_websites_ids + @managed_websites_ids ||= scoped_ids(:website_manager) + end + def managed_agenda_category_localization_ids @managed_agenda_category_localization_ids ||= begin Communication::Website::Agenda::Category::Localization diff --git a/app/models/user/role.rb b/app/models/user/role.rb new file mode 100644 index 0000000000..b519353724 --- /dev/null +++ b/app/models/user/role.rb @@ -0,0 +1,96 @@ +# == Schema Information +# +# Table name: user_roles +# +# id :uuid not null, primary key +# role :integer not null, uniquely indexed => [user_id, scope_type, scope_id] +# scope_type :string indexed => [scope_id], uniquely indexed => [user_id, role, scope_id] +# created_at :datetime not null +# updated_at :datetime not null +# scope_id :uuid indexed => [scope_type], uniquely indexed => [user_id, role, scope_type] +# university_id :uuid not null, indexed +# user_id :uuid not null, indexed, uniquely indexed => [role, scope_type, scope_id] +# +# Indexes +# +# index_user_roles_on_scope (scope_type,scope_id) +# index_user_roles_on_university_id (university_id) +# index_user_roles_on_user_id (user_id) +# index_user_roles_uniqueness (user_id,role,scope_type,scope_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_318345354e (user_id => users.id) +# fk_rails_c177139056 (university_id => universities.id) +# +class User::Role < ApplicationRecord + + # Rôles qui se rattachent à une cible (site ou formation) + ROLES_WITH_SCOPE = { + 'contributor' => 'Communication::Website', + 'author' => 'Communication::Website', + 'website_manager' => 'Communication::Website', + 'teacher' => 'Education::Program', + 'program_manager' => 'Education::Program', + }.freeze + + enum :role, { + contributor: 4, + author: 5, + teacher: 10, + program_manager: 12, + website_manager: 15, + alumni_manager: 18, + admin: 20, + } + + belongs_to :user, inverse_of: :roles + belongs_to :university + belongs_to :scope, polymorphic: true, optional: true + + before_validation :set_university_from_user + validate :scope_required_for_scoped_role + + after_create :autoset_favorite_for_website_manager + + def self.scope_type_for(role_name) + ROLES_WITH_SCOPE[role_name] + end + + def scope_choice + scope_id + end + + def scope_choice=(value) + self.scope_type = ROLES_WITH_SCOPE[role] + self.scope_id = value.presence + end + + def to_s + [ + I18n.t("activerecord.attributes.user.roles.#{role}"), + scope&.to_s + ].compact.join(' — ') + end + + protected + + def set_university_from_user + self.university ||= user&.university + end + + def scope_required_for_scoped_role + errors.add(:scope, 'is required for this role') if has_scope? && scope_id.blank? + end + + def has_scope? + ROLES_WITH_SCOPE[role].present? + end + + # Quand on confie un site à un responsable, on l'ajoute à ses favoris pour un + # accès rapide (idempotent). Remplace l'ancien autoset_favorites de User. + def autoset_favorite_for_website_manager + return unless website_manager? && scope.is_a?(Communication::Website) + user&.add_favorite(scope) + end +end diff --git a/app/models/user/with_favorites.rb b/app/models/user/with_favorites.rb index 66a8cb4636..d5a71a3b24 100644 --- a/app/models/user/with_favorites.rb +++ b/app/models/user/with_favorites.rb @@ -3,7 +3,6 @@ module User::WithFavorites included do has_many :favorites, dependent: :destroy - after_save :autoset_favorites end def add_favorite(about) @@ -23,12 +22,4 @@ def favorite?(about) def favorites_for(about) favorites.where(about_id: about.id, about_type: about.class.polymorphic_name) end - - def autoset_favorites - if saved_change_to_role? && website_manager? && favorites.none? - websites_to_manage.each do |website| - add_favorite(website) - end - end - end end diff --git a/app/models/user/with_roles.rb b/app/models/user/with_roles.rb index b9f80698bf..b2e4ad000d 100644 --- a/app/models/user/with_roles.rb +++ b/app/models/user/with_roles.rb @@ -4,6 +4,7 @@ module User::WithRoles included do attr_accessor :modified_by, :just_autopromoted + # TODO(multiroles) remove enum :role, { visitor: 0, contributor: 4, @@ -16,60 +17,92 @@ module User::WithRoles server_admin: 30 } - has_and_belongs_to_many :programs_to_manage, - class_name: 'Education::Program', - join_table: :education_programs_users, - association_foreign_key: :education_program_id - - has_and_belongs_to_many :websites_to_manage, - class_name: 'Communication::Website', - join_table: :communication_websites_users, - association_foreign_key: :communication_website_id + has_many :roles, + class_name: 'User::Role', + dependent: :destroy, + inverse_of: :user + accepts_nested_attributes_for :roles, reject_if: :all_blank, allow_destroy: true + # TODO(multiroles) écrire ça en fonction des rôles liés au user scope :for_role, -> (role, language = nil) { where(role: role) } before_validation :set_default_role, on: :create - before_validation :check_modifier_role + validate :assigned_roles_within_modifier_scope + after_create :bootstrap_role_assignment after_create :set_university_autopromote_option, if: :just_autopromoted def self.roles_with_access_to_global_menu roles.keys - ['contributor', 'author', 'website_manager'] end + # Rôles effectifs (distincts), ordonnés du moins au plus privilégié. + # L'ordre est volontaire : Ability fusionne les abilities dans cet ordre pour + # que les règles du rôle le plus puissant soient déclarées en dernier et + # l'emportent (CanCanCan évalue de la dernière règle définie à la première). + def ability_roles + roles.map(&:role).uniq.sort_by { |name| self.class.roles[name] } + end + + # Détient ce rôle (sur n'importe quelle cible) ? + def has_role?(name) + roles.any? { |user_role| user_role.role == name.to_s } + end + + # Cibles (records) attachées à un rôle donné. + def scopes_for(role_name) + roles.select { |user_role| user_role.role == role_name.to_s } + .filter_map(&:scope) + end + + def grant_role!(role_name, scope: nil) + roles.find_or_create_by!( + role: role_name, + scope_type: scope&.class&.base_class&.name, + scope_id: scope&.id + ) + end + def managed_roles - User.roles.map do |role_name, role_id| - next if role_id > User.roles[role] - role_name - end.compact + can_grant_roles? ? User::Role.roles.keys : [] + end + + def can_grant_roles? + server_admin? || has_role?('admin') end def managed_websites - if server_admin? + if has_role?('server_admin') Communication::Website.all - elsif admin? + elsif has_role?('admin') Communication::Website.where(university_id: university_id) - elsif website_manager? - Communication::Website.where(id: websites_to_manage_ids) + elsif has_role?('website_manager') + Communication::Website.where(id: scopes_for('website_manager').map(&:id)) else Communication::Website.none end end def can_display_global_menu? - User.roles_with_access_to_global_menu.include?(role) + (ability_roles & User.roles_with_access_to_global_menu).any? end protected - def check_modifier_role - errors.add(:role, 'cannot be set to this role') if modified_by && !modified_by.managed_roles.include?(self.role) + # Empêche d'attribuer un rôle au-dessus de ce que le modificateur gère. + def assigned_roles_within_modifier_scope + return unless modified_by + assigned = roles.reject(&:marked_for_destruction?).map(&:role).uniq + forbidden = assigned - modified_by.managed_roles + errors.add(:base, 'cannot assign a role above your own') if forbidden.any? end def set_default_role return if server_admin? if User.all.empty? + # Premier user de toutes les instances self.role = :server_admin elsif !university.admin_already_auto_promoted? && university.users.not_server_admin.empty? + # Premier user de l'instsance self.role = :admin self.just_autopromoted = true end diff --git a/app/models/user/with_sync_between_universities.rb b/app/models/user/with_sync_between_universities.rb index 11aa0aae80..af6dc98829 100644 --- a/app/models/user/with_sync_between_universities.rb +++ b/app/models/user/with_sync_between_universities.rb @@ -24,13 +24,16 @@ def sync_in_university(target_university) unless User.where(email: email, university_id: target_university.id).any? duplicate_user_for_university(target_university) else - User.find_by(email: email, university_id: target_university.id)&.update_columns( + existing = User.find_by(email: email, university_id: target_university.id) + existing&.update_columns( encrypted_password: self.encrypted_password, first_name: self.first_name, last_name: self.last_name, mobile_phone: self.mobile_phone, role: :server_admin ) + # `role` est un cache ; la source de vérité est roles. + existing&.grant_role!(:server_admin) end end diff --git a/app/views/admin/education/programs/parts/admission.html.erb b/app/views/admin/education/programs/parts/admission.html.erb index c727dbd69e..e61916c4c8 100644 --- a/app/views/admin/education/programs/parts/admission.html.erb +++ b/app/views/admin/education/programs/parts/admission.html.erb @@ -36,11 +36,9 @@ <% action = '' -if !current_user.program_manager? || current_user.programs_to_manage.pluck(:education_program_id).include?(@program.id) action += link_to t('education.manage_roles'), admin_education_program_roles_path(program_id: @program.id), - class: 'action' if can?(:update, University::Role) -end + class: 'action' if can?(:edit, @program) %> <%= osuny_panel Education::Program.human_attribute_name('roles'), action: action, classes: 'mt-5' do %> <% if @roles.any? %> diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index ee3da5ac0f..b9e232793b 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -3,27 +3,33 @@ <%= f.error_notification message: f.object.errors[:base].to_sentence if f.object.errors[:base].present? %>
-
+
<%= f.input :email %> <%= f.input :first_name %> <%= f.input :last_name %> + <%= f.input :mobile_phone %> <%= render 'admin/application/l10n/form', f: f %>
-
- <%= f.input :mobile_phone %> - <%= f.input :role, include_blank: false, collection: current_user.managed_roles, label_method: lambda { |k| t("activerecord.attributes.user.roles.#{k[1]}")} %> -
- <%= f.association :programs_to_manage, - as: :check_boxes, - collection: osuny_collection_tree(current_university.education_programs.root, localized: true) %> -
-
- <%= f.association :websites_to_manage, - as: :check_boxes, - collection: osuny_collection(current_university.communication_websites, localized: true) %> -
+
+ <%= osuny_label User.human_attribute_name('roles') %> + <% if user.server_admin? %> + <%= f.input :server_admin %> + <% else %> +

<%= t('admin.users.roles_hint') %>

+
+ <%= f.simple_fields_for :roles do |role_f| %> + <%= render 'admin/users/role_fields', f: role_f %> + <% end %> +
+ <%= link_to_add_association t('admin.users.add_role'), f, :roles, + class: button_classes, + data: { + 'association-insertion-method': 'append', + 'association-insertion-node': '#user_roles' + } %> + <% end %>
-
+
<%= f.input :picture, as: :single_deletable_file, input_html: { accept: default_images_formats_accepted }, diff --git a/app/views/admin/users/_role_fields.html.erb b/app/views/admin/users/_role_fields.html.erb new file mode 100644 index 0000000000..e89255856b --- /dev/null +++ b/app/views/admin/users/_role_fields.html.erb @@ -0,0 +1,41 @@ +<% +# Chaque