diff --git a/app/controllers/CSVImportController.scala b/app/controllers/CSVImportController.scala index 36b64c6d2..9b3ccad7f 100644 --- a/app/controllers/CSVImportController.scala +++ b/app/controllers/CSVImportController.scala @@ -341,6 +341,8 @@ case class CSVImportController @Inject() ( firstLoginDate = none, phoneNumber = userData.user.phoneNumber, observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, sharedAccount = userData.user.name.nonEmpty, internalSupportComment = None, ) diff --git a/app/controllers/SignupController.scala b/app/controllers/SignupController.scala index dfeec7fb9..736edd09b 100644 --- a/app/controllers/SignupController.scala +++ b/app/controllers/SignupController.scala @@ -92,6 +92,8 @@ case class SignupController @Inject() ( firstLoginDate = Instant.now().some, phoneNumber = form.phoneNumber, observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, sharedAccount = form.sharedAccount, internalSupportComment = None ) diff --git a/app/controllers/UserController.scala b/app/controllers/UserController.scala index fa06bd8e8..4e9ca4100 100644 --- a/app/controllers/UserController.scala +++ b/app/controllers/UserController.scala @@ -484,6 +484,8 @@ case class UserController @Inject() ( cguAcceptationDate = cguDate, phoneNumber = updatedUserData.phoneNumber, observableOrganisationIds = updatedUserData.observableOrganisationIds.distinct, + managingOrganisationIds = updatedUserData.managingOrganisationIds.distinct, + managingAreaIds = updatedUserData.managingAreaIds.distinct, sharedAccount = updatedUserData.sharedAccount, internalSupportComment = updatedUserData.internalSupportComment ) @@ -606,6 +608,8 @@ case class UserController @Inject() ( firstLoginDate = none, phoneNumber = userToAdd.phoneNumber, observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, sharedAccount = userToAdd.sharedAccount, internalSupportComment = None ) diff --git a/app/models/Authorization.scala b/app/models/Authorization.scala index 475c780c6..a3ff72ce5 100644 --- a/app/models/Authorization.scala +++ b/app/models/Authorization.scala @@ -27,6 +27,13 @@ object Authorization { if (user.groupAdmin && not(user.disabled)) Some(ManagerOfGroups(user.groupIds.toSet)) else None, + if ( + (user.managingAreaIds.nonEmpty || user.managingOrganisationIds.nonEmpty) && not( + user.disabled + ) + ) + Some(ManagerOfAreas(user.managingAreaIds.toSet, user.managingOrganisationIds.toSet)) + else None, if (user.observableOrganisationIds.nonEmpty && not(user.disabled)) Some(ObserverOfOrganisations(user.observableOrganisationIds.toSet)) else None @@ -45,6 +52,10 @@ object Authorization { case class InstructorOfGroups(groupsManaged: Set[UUID]) extends UserRight case class AdminOfAreas(administeredAreas: Set[UUID]) extends UserRight case class ManagerOfGroups(groupsManaged: Set[UUID]) extends UserRight + + case class ManagerOfAreas(areas: Set[UUID], organisations: Set[Organisation.Id]) + extends UserRight + case class ObserverOfOrganisations(organisations: Set[Organisation.Id]) extends UserRight } @@ -132,6 +143,13 @@ object Authorization { case _ => false } + def isAreaManager(areaIds: Set[UUID], organisationIds: Set[Organisation.Id]): Check = + _.rights.exists { + case UserRight.ManagerOfAreas(managingAreaIds, managingOrganisationIds) => + areaIds.subsetOf(managingAreaIds) && organisationIds.subsetOf(managingOrganisationIds) + case _ => false + } + def isObserver: Check = _.rights.exists { case UserRight.ObserverOfOrganisations(organisations) => organisations.nonEmpty diff --git a/app/models/User.scala b/app/models/User.scala index 5a1794326..e970f7a98 100644 --- a/app/models/User.scala +++ b/app/models/User.scala @@ -47,7 +47,9 @@ case class User( // * can see stats+deployment of all areas, // * can see all users, // * can see one user but not edit it - observableOrganisationIds: List[Organisation.Id] = Nil, + observableOrganisationIds: List[Organisation.Id], + managingOrganisationIds: List[Organisation.Id], + managingAreaIds: List[UUID], sharedAccount: Boolean = false, // This is a comment only visible by the admins internalSupportComment: Option[String], @@ -111,6 +113,8 @@ case class User( lazy val phoneNumberLog: String = phoneNumber.map(withQuotes).getOrElse("") lazy val observableOrganisationIdsLog: String = observableOrganisationIds.map(_.id).mkString(", ") + lazy val managingOrganisationIdsLog: String = managingOrganisationIds.map(_.id).mkString(", ") + lazy val managingAreaIdsLog: String = managingAreaIds.mkString(", ") lazy val sharedAccountLog: String = sharedAccount.toString lazy val internalSupportCommentLog: String = @@ -138,6 +142,8 @@ case class User( ("Newsletter", newsletterAcceptationDateLog), ("Première connexion", firstLoginDateLog), ("Observation des organismes", observableOrganisationIdsLog), + ("Responsable des organismes", managingOrganisationIdsLog), + ("Responsable des territoires", managingAreaIdsLog), ("Information Support", internalSupportCommentLog), ).map { case (fieldName, value) => s"$fieldName : $value" }.mkString(" | ") + "]" @@ -188,6 +194,18 @@ case class User( observableOrganisationIdsLog, other.observableOrganisationIdsLog ), + ( + "Responsable des organismes", + managingOrganisationIds =!= other.managingOrganisationIds, + managingOrganisationIdsLog, + other.managingOrganisationIdsLog + ), + ( + "Responsable des territoires", + managingAreaIds =!= other.managingAreaIds, + managingAreaIdsLog, + other.managingAreaIdsLog + ), ( "Information Support", internalSupportCommentLog =!= other.internalSupportCommentLog, @@ -225,6 +243,8 @@ case class User( firstLoginDate = firstLoginDate, phoneNumber = pseudoPhone, observableOrganisationIds = observableOrganisationIds, + managingOrganisationIds = managingOrganisationIds, + managingAreaIds = managingAreaIds, sharedAccount = sharedAccount, internalSupportComment = none, ) @@ -251,6 +271,9 @@ object User { groupAdmin = false, disabled = true, firstLoginDate = none, + observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, internalSupportComment = None ) diff --git a/app/models/dataModels.scala b/app/models/dataModels.scala index af5ea32b0..209903e54 100644 --- a/app/models/dataModels.scala +++ b/app/models/dataModels.scala @@ -293,6 +293,8 @@ object dataModels { firstLoginDate = user.firstLoginDate, phoneNumber = user.phoneNumber, observableOrganisationIds = user.observableOrganisationIds.distinct.map(_.id), + managingOrganisationIds = user.managingOrganisationIds.distinct.map(_.id), + managingAreaIds = user.managingAreaIds.distinct, sharedAccount = user.sharedAccount, internalSupportComment = user.internalSupportComment, ) @@ -323,6 +325,8 @@ object dataModels { firstLoginDate: Option[Instant], phoneNumber: Option[String], observableOrganisationIds: List[String], + managingOrganisationIds: List[String], + managingAreaIds: List[UUID], sharedAccount: Boolean, internalSupportComment: Option[String] ) { @@ -350,6 +354,8 @@ object dataModels { firstLoginDate = firstLoginDate, phoneNumber = phoneNumber, observableOrganisationIds = observableOrganisationIds.map(Organisation.Id.apply), + managingOrganisationIds = managingOrganisationIds.map(Organisation.Id.apply), + managingAreaIds = managingAreaIds, sharedAccount = sharedAccount, internalSupportComment = internalSupportComment ) diff --git a/app/models/formModels.scala b/app/models/formModels.scala index 53e8d5541..740f01e81 100644 --- a/app/models/formModels.scala +++ b/app/models/formModels.scala @@ -308,6 +308,8 @@ object formModels { groupIds = user.groupIds, phoneNumber = user.phoneNumber, observableOrganisationIds = user.observableOrganisationIds, + managingOrganisationIds = user.managingOrganisationIds, + managingAreaIds = user.managingAreaIds, sharedAccount = user.sharedAccount, internalSupportComment = user.internalSupportComment ) @@ -341,6 +343,8 @@ object formModels { "groupIds" -> default(list(uuid), Nil), "phoneNumber" -> normalizedOptionalText, "observableOrganisationIds" -> list(of[Organisation.Id]), + "managingOrganisationIds" -> list(of[Organisation.Id]), + "managingAreaIds" -> list(uuid), Keys.User.sharedAccount -> boolean, "internalSupportComment" -> normalizedOptionalText )(EditUserFormData.apply)(EditUserFormData.unapply) @@ -363,6 +367,8 @@ object formModels { groupIds: List[UUID], phoneNumber: Option[String], observableOrganisationIds: List[Organisation.Id], + managingOrganisationIds: List[Organisation.Id], + managingAreaIds: List[UUID], sharedAccount: Boolean, internalSupportComment: Option[String] ) diff --git a/app/serializers/ApiModel.scala b/app/serializers/ApiModel.scala index f90eafa7f..11ab6e11e 100644 --- a/app/serializers/ApiModel.scala +++ b/app/serializers/ApiModel.scala @@ -150,9 +150,24 @@ object ApiModel { } object UserInfos { + case class Group(id: UUID, name: String, currentUserCanEditGroup: Boolean) + case class Permissions( + helper: Boolean, + instructor: Boolean, + groupAdmin: Boolean, + admin: Boolean, + expert: Boolean, + managingOrganisations: List[String], + managingAreas: List[String], + ) + implicit val userInfosGroupFormat: Format[UserInfos.Group] = Json.format[UserInfos.Group] + + implicit val userInfosPermissionsFormat: Format[UserInfos.Permissions] = + Json.format[UserInfos.Permissions] + implicit val userInfosFormat: Format[UserInfos] = Json.format[UserInfos] def fromUser( @@ -181,8 +196,6 @@ object ApiModel { qualite = user.qualite, email = user.email, phoneNumber = user.phoneNumber, - helper = user.helperRoleName.nonEmpty, - instructor = user.instructorRoleName.nonEmpty, areas = user.areas.flatMap(Area.fromId).map(_.toString).sorted, groupNames = groups.map(_.name), groups = groups @@ -191,12 +204,19 @@ object ApiModel { ), groupEmails = groups.flatMap(_.email), organisations = organisations, - groupAdmin = user.groupAdminRoleName.nonEmpty, - admin = user.adminRoleName.nonEmpty, - expert = user.expert, disabled = user.disabledRoleName.nonEmpty, sharedAccount = user.sharedAccount, cgu = user.cguAcceptationDate.nonEmpty, + permissions = Permissions( + helper = user.helperRoleName.nonEmpty, + instructor = user.instructorRoleName.nonEmpty, + groupAdmin = user.groupAdminRoleName.nonEmpty, + admin = user.adminRoleName.nonEmpty, + expert = user.expert, + managingOrganisations = + user.managingOrganisationIds.flatMap(Organisation.byId).map(_.shortName).sorted, + managingAreas = user.managingAreaIds.flatMap(Area.fromId).map(_.toString).sorted, + ) ) } @@ -211,19 +231,16 @@ object ApiModel { qualite: String, email: String, phoneNumber: Option[String], - helper: Boolean, - instructor: Boolean, areas: List[String], groupNames: List[String], groups: List[UserInfos.Group], groupEmails: List[String], organisations: List[String], - groupAdmin: Boolean, - admin: Boolean, - expert: Boolean, disabled: Boolean, sharedAccount: Boolean, - cgu: Boolean + cgu: Boolean, + // This case class is a workaround for the 22 fields tuple limit in play-json + permissions: UserInfos.Permissions, ) object UserGroupInfos { diff --git a/app/services/UserService.scala b/app/services/UserService.scala index c92a9fec7..a2e753c3c 100644 --- a/app/services/UserService.scala +++ b/app/services/UserService.scala @@ -48,6 +48,8 @@ class UserService @Inject() ( "first_login_date", "phone_number", "observable_organisation_ids", + "managing_organisation_ids", + "managing_area_ids", "shared_account", "internal_support_comment" ) @@ -353,6 +355,8 @@ class UserService @Inject() ( phone_number = ${row.phoneNumber}, disabled = ${row.disabled}, observable_organisation_ids = array[${row.observableOrganisationIds}]::varchar[], + managing_organisation_ids = array[${row.managingOrganisationIds}]::varchar[], + managing_area_ids = array[${row.managingAreaIds}]::uuid[], shared_account = ${row.sharedAccount}, internal_support_comment = ${row.internalSupportComment} WHERE id = ${row.id}::uuid diff --git a/app/views/editUser.scala.html b/app/views/editUser.scala.html index d1490765d..e7fdd630f 100644 --- a/app/views/editUser.scala.html +++ b/app/views/editUser.scala.html @@ -125,6 +125,31 @@ >@{organisation.shortName} - @{organisation.name} } + + Responsable d’organismes + + Responsable de territoires + + @if(form("areas").hasErrors) {

@form("areas").errors.map(_.format).mkString(", ")

} diff --git a/conf/evolutions/default/70.sql b/conf/evolutions/default/70.sql new file mode 100644 index 000000000..fb89a276d --- /dev/null +++ b/conf/evolutions/default/70.sql @@ -0,0 +1,10 @@ +--- !Ups + +ALTER TABLE "user" ADD COLUMN managing_organisation_ids varchar[] DEFAULT ARRAY[]::varchar[] NOT NULL; +ALTER TABLE "user" ADD COLUMN managing_area_ids varchar[] DEFAULT ARRAY[]::varchar[] NOT NULL; + + +--- !Downs + +ALTER TABLE "user" DROP COLUMN managing_area_ids; +ALTER TABLE "user" DROP COLUMN managing_organisation_ids; diff --git a/test/browser/AnswerSpec.scala b/test/browser/AnswerSpec.scala index 01b50a2c1..090660438 100644 --- a/test/browser/AnswerSpec.scala +++ b/test/browser/AnswerSpec.scala @@ -67,6 +67,9 @@ class AnswerSpec extends Specification with Tables with BaseSpec { cguAcceptationDate = Some(Time.nowParis()), firstLoginDate = None, groupIds = groups.map(_.id), + observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, internalSupportComment = None ) val result = userService.add(List(user)) diff --git a/test/browser/ApplicationAccessSpec.scala b/test/browser/ApplicationAccessSpec.scala index 193396488..71b8c0b5d 100644 --- a/test/browser/ApplicationAccessSpec.scala +++ b/test/browser/ApplicationAccessSpec.scala @@ -142,6 +142,9 @@ class ApplicationAccessSpec extends Specification with BaseSpec { cguAcceptationDate = Some(Time.nowParis()), firstLoginDate = None, groupIds = List(instructorGroup.id), + observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, internalSupportComment = None ) val unrelatedInstructorUser = User( @@ -163,6 +166,9 @@ class ApplicationAccessSpec extends Specification with BaseSpec { cguAcceptationDate = Some(Time.nowParis()), firstLoginDate = None, groupIds = Nil, + observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, internalSupportComment = None ) val helperUser = User( @@ -184,6 +190,9 @@ class ApplicationAccessSpec extends Specification with BaseSpec { cguAcceptationDate = Some(Time.nowParis()), firstLoginDate = None, groupIds = List(helperGroup.id), + observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, internalSupportComment = None ) val helperFriendUser = User( @@ -205,6 +214,9 @@ class ApplicationAccessSpec extends Specification with BaseSpec { cguAcceptationDate = Some(Time.nowParis()), firstLoginDate = None, groupIds = List(helperGroup.id), + observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, internalSupportComment = None ) val unrelatedHelperUser = User( @@ -226,6 +238,9 @@ class ApplicationAccessSpec extends Specification with BaseSpec { cguAcceptationDate = Some(Time.nowParis()), firstLoginDate = None, groupIds = List(helperGroup.id), + observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, internalSupportComment = None ) val unrelatedExpertUser = User( @@ -247,6 +262,9 @@ class ApplicationAccessSpec extends Specification with BaseSpec { cguAcceptationDate = Some(Time.nowParis()), firstLoginDate = None, groupIds = List(helperGroup.id), + observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, internalSupportComment = None ) val managerUser = User( @@ -268,6 +286,9 @@ class ApplicationAccessSpec extends Specification with BaseSpec { cguAcceptationDate = Some(Time.nowParis()), firstLoginDate = None, groupIds = List(helperGroup.id), + observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, internalSupportComment = None ) diff --git a/test/browser/ApplicationSpec.scala b/test/browser/ApplicationSpec.scala index 8019afa16..34e397c97 100644 --- a/test/browser/ApplicationSpec.scala +++ b/test/browser/ApplicationSpec.scala @@ -55,6 +55,9 @@ class ApplicationSpec extends Specification with BaseSpec { cguAcceptationDate = Some(Time.nowParis()), firstLoginDate = None, groupIds = List(instructorGroup.id), + observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, internalSupportComment = None ) val helperUser = User( @@ -75,6 +78,9 @@ class ApplicationSpec extends Specification with BaseSpec { disabled = false, cguAcceptationDate = Some(Time.nowParis()), firstLoginDate = None, + observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, internalSupportComment = None ) userService.add(List(instructorUser, helperUser)) diff --git a/test/browser/LoginSpec.scala b/test/browser/LoginSpec.scala index 0c11e32a9..bb8256184 100644 --- a/test/browser/LoginSpec.scala +++ b/test/browser/LoginSpec.scala @@ -32,6 +32,9 @@ class LoginSpec extends Specification with Tables with BaseSpec with BeforeAfter firstLoginDate = None, groupAdmin = true, disabled = false, + observableOrganisationIds = Nil, + managingOrganisationIds = Nil, + managingAreaIds = Nil, internalSupportComment = None ) diff --git a/typescript/src/users.ts b/typescript/src/users.ts index b3d15413f..76b7bfab2 100644 --- a/typescript/src/users.ts +++ b/typescript/src/users.ts @@ -19,6 +19,14 @@ interface UserInfosGroup { currentUserCanEditGroup: boolean; } +interface UserInfosPermissions { + helper: boolean; + instructor: boolean; + groupAdmin: boolean; + admin: boolean; + expert: boolean; +} + interface UserInfos { id: string; firstName: string | null; @@ -28,18 +36,14 @@ interface UserInfos { qualite: string; email: string; phoneNumber: string | null; - helper: boolean; - instructor: boolean; areas: Array; groupNames: Array; groups: Array; groupEmails: Array; - groupAdmin: boolean; - admin: boolean; - expert: boolean; disabled: boolean; sharedAccount: boolean; cgu: boolean; + permissions: UserInfosPermissions; } interface UserGroupInfos { @@ -72,14 +76,14 @@ async function callSearch(searchString: string): Promise { if (window.document.getElementById(usersTableId)) { const verticalHeader = false; - const editIcon: Formatter = function(cell) { + const editIcon: Formatter = function (cell) { //plain text value let uuid = cell.getRow().getData().id; let url = jsRoutes.controllers.UserController.editUser(uuid).url; return ""; }; - const groupsFormatter: Formatter = function(cell) { + const groupsFormatter: Formatter = function (cell) { const groups = >cell.getRow().getData().groups; let links = ""; let isNotFirst = false; @@ -99,7 +103,7 @@ if (window.document.getElementById(usersTableId)) { return links; }; - const joinWithCommaDownload: CustomAccessor = function(value) { + const joinWithCommaDownload: CustomAccessor = function (value) { if (value != null) { return value.join(", "); } else { @@ -107,14 +111,14 @@ if (window.document.getElementById(usersTableId)) { } }; - const groupNameFormatter: Formatter = function(cell) { + const groupNameFormatter: Formatter = function (cell) { const group = cell.getRow().getData(); const groupUrl = jsRoutes.controllers.GroupController.editGroup(group.id).url; const html = "" + group.name + ""; return html; }; - const rowFormatter = function(row: RowComponent) { + const rowFormatter = function (row: RowComponent) { let element = row.getElement(), data = row.getData(); if (data.disabled) { @@ -175,7 +179,7 @@ if (window.document.getElementById(usersTableId)) { }, { title: "Aidant", - field: "helper", + field: "permissions.helper", formatter: "tickCross", headerFilter: "tickCross", headerFilterParams: { tristate: true }, @@ -185,7 +189,7 @@ if (window.document.getElementById(usersTableId)) { }, { title: "Instructeur", - field: "instructor", + field: "permissions.instructor", formatter: "tickCross", headerFilter: "tickCross", headerFilterParams: { tristate: true }, @@ -195,7 +199,7 @@ if (window.document.getElementById(usersTableId)) { }, { title: "Responsable", - field: "groupAdmin", + field: "permissions.groupAdmin", formatter: "tickCross", headerFilter: "tickCross", headerFilterParams: { tristate: true }, @@ -215,7 +219,7 @@ if (window.document.getElementById(usersTableId)) { }, { title: "Expert", - field: "expert", + field: "permissions.expert", formatter: "tickCross", headerFilter: "tickCross", headerFilterParams: { tristate: true }, @@ -225,7 +229,7 @@ if (window.document.getElementById(usersTableId)) { }, { title: "Admin", - field: "admin", + field: "permissions.admin", formatter: "tickCross", headerFilter: "tickCross", headerFilterParams: { tristate: true }, @@ -290,6 +294,22 @@ if (window.document.getElementById(usersTableId)) { formatter: "html", width: 200, }, + { + title: "Organismes en charge", + field: "permissions.managingOrganisations", + sorter: "string", + headerFilter: "input", + width: 200, + accessorDownload: joinWithCommaDownload, + }, + { + title: "Départements en charge", + field: "permissions.managingAreas", + sorter: "string", + headerFilter: "input", + width: 200, + accessorDownload: joinWithCommaDownload, + }, ]; const groupsColumns: Array = [ @@ -367,7 +387,16 @@ if (window.document.getElementById(usersTableId)) { columns: adminColumns, }; - const excludedFieldsForNonAdmins = ["name", "lastName", "firstName", "helper", "expert", "admin"]; + const excludedFieldsForNonAdmins = [ + "name", + "lastName", + "firstName", + "permissions.helper", + "permissions.expert", + "permissions.admin", + "permissions.managingOrganisations", + "permissions.managingAreas", + ]; if (!canSeeEditUserPage) { excludedFieldsForNonAdmins.push("id"); } @@ -402,7 +431,7 @@ if (window.document.getElementById(usersTableId)) { const usersOptions: Options = isAdmin ? usersOptionsForAdmins : usersOptionsForNonAdmins; usersTable = new TabulatorFull("#" + usersTableId, usersOptions); - usersTable.on("tableBuilt", function() { + usersTable.on("tableBuilt", function () { usersTable?.setLocale("fr-fr"); }); @@ -420,7 +449,7 @@ if (window.document.getElementById(usersTableId)) { columns: groupsColumns, }; groupsTable = new TabulatorFull("#" + groupsTableId, groupsOptions); - groupsTable.on("tableBuilt", function() { + groupsTable.on("tableBuilt", function () { groupsTable?.setLocale("fr-fr"); groupsTable?.setSort("name", "asc"); });