Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 85 additions & 28 deletions app/assets/javascripts/admin/commons/users.js
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
arnaudlevy marked this conversation as resolved.
Comment thread
qltysh[bot] marked this conversation as resolved.
},

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();
});
3 changes: 2 additions & 1 deletion app/controllers/admin/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module ApplicationController::WithMaintenance
protected

def check_maintenance
# TODO(roles-cache): prédicat sur le cache `role` -> has_role?('server_admin') si cache supprimé.
if (ENV['MAINTENANCE'] == 'true') && !current_user&.server_admin?
redirect_to '/maintenance'
end
Expand Down
1 change: 1 addition & 0 deletions app/controllers/extranet/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def user_is_authorized?
end

def user_is_more_than_visitor
# TODO(roles-cache): visitor? = aucun rôle effectif. Sans cache -> roles.none?.
!current_user.visitor?
end

Expand Down
1 change: 1 addition & 0 deletions app/controllers/server/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def breadcrumb
end

def ensure_user_if_server_admin
# TODO(roles-cache): prédicat sur le cache `role` -> has_role?('server_admin') si cache supprimé.
raise CanCan::AccessDenied unless current_user.server_admin?
end

Expand Down
1 change: 1 addition & 0 deletions app/helpers/admin/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def publish_link(object)
end

def static_link(path)
# TODO(roles-cache): prédicat sur le cache `role` -> has_role?('server_admin') si cache supprimé.
return unless current_user.server_admin?
raw "<a href=\"#{path}\" class=\"#{button_classes}\">#{t 'static' }</a>"
end
Expand Down
18 changes: 15 additions & 3 deletions app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ class Ability
include CanCan::Ability

def self.for(user)
"Ability::#{user.role.classify}".constantize.new user
user ||= User.new # guest user (not logged in)
ability = new(user)
# 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|
ability.merge "Ability::#{role_name.classify}".constantize.new(user)
end
ability
end

def initialize(user)
Expand All @@ -13,7 +21,11 @@ def initialize(user)

protected

def managed_websites_ids
@managed_websites_ids ||= @user.websites_to_manage.pluck(:communication_website_id)
# 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 p. ex. 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
6 changes: 6 additions & 0 deletions app/models/ability/author.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions app/models/ability/contributor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion app/models/ability/program_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/models/ability/website_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion app/models/emergency_message.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ 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(roles-cache): cible par la colonne cache `role` (= rôle le plus élevé).
# Sans cache -> jointure sur `roles`. (cf. arbitrage dans User::WithRoles)
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)
Expand Down
100 changes: 100 additions & 0 deletions app/models/user/role.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# == 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
self.table_name = 'user_roles'

# Source de vérité des droits : un utilisateur a autant de lignes que de
# couples (rôle, cible). Le scope est polymorphe et facultatif (les rôles
# globaux — teacher, admin, server_admin — n'ont pas de cible).
enum :role, User::WithRoles::ROLES

belongs_to :user, inverse_of: :roles
belongs_to :university
belongs_to :scope, polymorphic: true, optional: true

before_validation :set_university_from_user
before_validation :assign_scope_type_from_role
validate :scope_required_for_scoped_role

# TODO(roles-cache): ces deux callbacks maintiennent le cache User#role ; ils
# disparaissent si l'équipe décide de supprimer le cache (cf. User::WithRoles).
after_save :refresh_user_cached_role
after_destroy :refresh_user_cached_role
after_create :autoset_favorite_for_website_manager

def to_s
[
I18n.t("activerecord.attributes.user.roles.#{role}"),
scope&.to_s
].compact.join(' — ')
end

# Pilote la cible via un seul champ de formulaire : on ne transmet que l'uuid,
# le type est déduit du rôle (cf. assign_scope_type_from_role).
def scope_choice
scope_id
end

def scope_choice=(value)
self.scope_id = value.presence
end

protected

def set_university_from_user
self.university ||= user&.university
end

# Le type de cible découle du rôle : pas besoin de le transmettre depuis le
# formulaire. Un rôle global n'a aucune cible.
def assign_scope_type_from_role
if global_role?
self.scope_type = nil
self.scope_id = nil
else
self.scope_type = User::WithRoles::SCOPED_ROLES[role]
end
end

def scope_required_for_scoped_role
errors.add(:scope, 'is required for this role') if !global_role? && scope_id.blank?
end

def global_role?
User::WithRoles::SCOPED_ROLES[role].nil?
end

def refresh_user_cached_role
user&.refresh_cached_role!
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
9 changes: 0 additions & 9 deletions app/models/user/with_favorites.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ module User::WithFavorites

included do
has_many :favorites, dependent: :destroy
after_save :autoset_favorites
end

def add_favorite(about)
Expand All @@ -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
Loading
Loading