001/* 002 * Copyright 2016 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.plugins.workspaces.members; 017 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.Collection; 021import java.util.Comparator; 022import java.util.HashMap; 023import java.util.List; 024import java.util.Map; 025import java.util.Map.Entry; 026import java.util.Objects; 027import java.util.Optional; 028import java.util.Set; 029import java.util.TreeSet; 030import java.util.function.BiPredicate; 031import java.util.function.Predicate; 032import java.util.regex.Matcher; 033import java.util.regex.Pattern; 034import java.util.stream.Collectors; 035 036import org.apache.avalon.framework.activity.Disposable; 037import org.apache.avalon.framework.activity.Initializable; 038import org.apache.avalon.framework.component.Component; 039import org.apache.avalon.framework.context.Context; 040import org.apache.avalon.framework.context.ContextException; 041import org.apache.avalon.framework.context.Contextualizable; 042import org.apache.avalon.framework.service.ServiceException; 043import org.apache.avalon.framework.service.ServiceManager; 044import org.apache.avalon.framework.service.Serviceable; 045import org.apache.cocoon.components.ContextHelper; 046import org.apache.cocoon.environment.Request; 047import org.apache.commons.collections.CollectionUtils; 048import org.apache.commons.lang3.StringUtils; 049import org.apache.http.annotation.Obsolete; 050 051import org.ametys.cms.fo.ForceDefaultRepositoryWorkspaceCallableDecorator; 052import org.ametys.cms.languages.Language; 053import org.ametys.cms.languages.LanguagesManager; 054import org.ametys.cms.repository.Content; 055import org.ametys.cms.transformation.URIResolverExtensionPoint; 056import org.ametys.core.cache.AbstractCacheManager; 057import org.ametys.core.cache.Cache; 058import org.ametys.core.group.Group; 059import org.ametys.core.group.GroupDirectoryContextHelper; 060import org.ametys.core.group.GroupIdentity; 061import org.ametys.core.group.GroupManager; 062import org.ametys.core.observation.AsyncObserver; 063import org.ametys.core.observation.Event; 064import org.ametys.core.observation.ObservationManager; 065import org.ametys.core.right.ProfileAssignmentStorage.UserOrGroup; 066import org.ametys.core.right.ProfileAssignmentStorageExtensionPoint; 067import org.ametys.core.right.RightManager; 068import org.ametys.core.right.RightProfilesDAO; 069import org.ametys.core.ui.Callable; 070import org.ametys.core.user.CurrentUserProvider; 071import org.ametys.core.user.User; 072import org.ametys.core.user.UserIdentity; 073import org.ametys.core.user.UserManager; 074import org.ametys.core.user.directory.NotUniqueUserException; 075import org.ametys.plugins.core.impl.cache.AbstractCacheKey; 076import org.ametys.plugins.core.user.UserHelper; 077import org.ametys.plugins.explorer.resources.ModifiableResourceCollection; 078import org.ametys.plugins.repository.AmetysObject; 079import org.ametys.plugins.repository.AmetysObjectIterable; 080import org.ametys.plugins.repository.AmetysObjectResolver; 081import org.ametys.plugins.repository.AmetysRepositoryException; 082import org.ametys.plugins.repository.ModifiableAmetysObject; 083import org.ametys.plugins.repository.ModifiableTraversableAmetysObject; 084import org.ametys.plugins.repository.RepositoryConstants; 085import org.ametys.plugins.repository.query.expression.Expression.Operator; 086import org.ametys.plugins.repository.query.expression.UserExpression; 087import org.ametys.plugins.userdirectory.UserDirectoryHelper; 088import org.ametys.plugins.userdirectory.page.UserDirectoryPageResolver; 089import org.ametys.plugins.userdirectory.page.UserPage; 090import org.ametys.plugins.workspaces.ObservationConstants; 091import org.ametys.plugins.workspaces.WorkspacesHelper; 092import org.ametys.plugins.workspaces.documents.DocumentWorkspaceModule; 093import org.ametys.plugins.workspaces.forum.ForumWorkspaceModule; 094import org.ametys.plugins.workspaces.members.JCRProjectMember.MemberType; 095import org.ametys.plugins.workspaces.project.ProjectManager; 096import org.ametys.plugins.workspaces.project.ProjectsCatalogueManager; 097import org.ametys.plugins.workspaces.project.modules.WorkspaceModule; 098import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint; 099import org.ametys.plugins.workspaces.project.objects.Project; 100import org.ametys.plugins.workspaces.project.objects.Project.InscriptionStatus; 101import org.ametys.plugins.workspaces.project.rights.ProjectRightHelper; 102import org.ametys.plugins.workspaces.tasks.TasksWorkspaceModule; 103import org.ametys.runtime.authentication.AccessDeniedException; 104import org.ametys.runtime.config.Config; 105import org.ametys.runtime.i18n.I18nizableText; 106import org.ametys.runtime.plugin.component.AbstractLogEnabled; 107import org.ametys.web.WebConstants; 108import org.ametys.web.WebHelper; 109import org.ametys.web.population.PopulationContextHelper; 110import org.ametys.web.repository.site.Site; 111import org.ametys.web.usermanagement.UserManagementException; 112import org.ametys.web.usermanagement.UserSignupManager; 113 114/** 115 * Helper component for managing project's users 116 */ 117public class ProjectMemberManager extends AbstractLogEnabled implements Serviceable, Component, Contextualizable, Initializable, AsyncObserver, Disposable 118{ 119 /** Avalon Role */ 120 public static final String ROLE = ProjectMemberManager.class.getName(); 121 122 /** The id of the members service */ 123 public static final String __WORKSPACES_SERVICE_MEMBERS = "org.ametys.plugins.workspaces.module.Members"; 124 125 private static final String __PROJECT_MEMBER_CACHE = "projectMemberCache"; 126 127 @Obsolete // For v1 project only 128 private static final String __PROJECT_RIGHT_PROFILE = "PROJECT"; 129 130 /** Constants for users project node */ 131 private static final String __PROJECT_MEMBERS_NODE = RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":members"; 132 133 /** The type of the project users node type */ 134 private static final String __PROJECT_MEMBERS_NODE_TYPE = RepositoryConstants.NAMESPACE_PREFIX + ":unstructured"; 135 136 /** The type of a project user node type */ 137 private static final String __PROJECT_MEMBER_NODE_TYPE = RepositoryConstants.NAMESPACE_PREFIX + ":project-member"; 138 139 private static Pattern __MAIL_BETWEEN_BRACKETS_PATTERN = Pattern.compile("^[^<]*<(.*@.*)>$"); 140 141 /** Avalon context */ 142 protected Context _context; 143 144 /** Project manager */ 145 protected ProjectManager _projectManager; 146 147 /** Project rights helper */ 148 protected ProjectRightHelper _projectRightHelper; 149 150 /** Profiles right manager */ 151 protected RightProfilesDAO _rightProfilesDAO; 152 153 /** Profile assignment storage */ 154 protected ProfileAssignmentStorageExtensionPoint _profileAssignmentStorageExtensionPoint; 155 156 /** Ametys object resolver */ 157 protected AmetysObjectResolver _resolver; 158 159 /** Rights manager */ 160 protected RightManager _rightManager; 161 162 /** Current user provider */ 163 protected CurrentUserProvider _currentUserProvider; 164 165 /** Users manager */ 166 protected UserManager _userManager; 167 168 /** The observation manager */ 169 protected ObservationManager _observationManager; 170 171 /** Module managers EP */ 172 protected WorkspaceModuleExtensionPoint _moduleManagerEP; 173 174 /** The user helper */ 175 protected UserHelper _userHelper; 176 177 /** The groups manager */ 178 protected GroupManager _groupManager; 179 180 /** The population context helper */ 181 protected PopulationContextHelper _populationContextHelper; 182 183 /** The user directory helper */ 184 protected UserDirectoryHelper _userDirectoryHelper; 185 186 /** The project invitation helper */ 187 protected ProjectInvitationHelper _projectInvitationHelper; 188 189 /** The language manager */ 190 protected LanguagesManager _languagesManager; 191 192 /** The resolver for user directory pages */ 193 protected UserDirectoryPageResolver _userDirectoryPageResolver; 194 195 /** The page URI resolver. */ 196 protected URIResolverExtensionPoint _uriResolver; 197 198 /** The group directory context helper */ 199 protected GroupDirectoryContextHelper _groupDirectoryContextHelper; 200 201 /** The cache manager */ 202 protected AbstractCacheManager _abstractCacheManager; 203 204 /** The user signup manager */ 205 protected UserSignupManager _userSignupManager; 206 207 /** The project catalogue manager component */ 208 protected ProjectsCatalogueManager _projectsCatalogueManager; 209 210 /** The helper for project rights */ 211 protected ProjectRightHelper _projectRightsHelper; 212 213 /** Workspace helper */ 214 protected WorkspacesHelper _workspaceHelper; 215 216 @Override 217 public void contextualize(Context context) throws ContextException 218 { 219 _context = context; 220 } 221 222 @Override 223 public void service(ServiceManager manager) throws ServiceException 224 { 225 _abstractCacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE); 226 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 227 _projectManager = (ProjectManager) manager.lookup(ProjectManager.ROLE); 228 _projectRightHelper = (ProjectRightHelper) manager.lookup(ProjectRightHelper.ROLE); 229 _rightProfilesDAO = (RightProfilesDAO) manager.lookup(RightProfilesDAO.ROLE); 230 _profileAssignmentStorageExtensionPoint = (ProfileAssignmentStorageExtensionPoint) manager.lookup(ProfileAssignmentStorageExtensionPoint.ROLE); 231 _rightManager = (RightManager) manager.lookup(RightManager.ROLE); 232 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 233 _userManager = (UserManager) manager.lookup(UserManager.ROLE); 234 _groupManager = (GroupManager) manager.lookup(GroupManager.ROLE); 235 _userHelper = (UserHelper) manager.lookup(UserHelper.ROLE); 236 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 237 _moduleManagerEP = (WorkspaceModuleExtensionPoint) manager.lookup(WorkspaceModuleExtensionPoint.ROLE); 238 _populationContextHelper = (PopulationContextHelper) manager.lookup(org.ametys.core.user.population.PopulationContextHelper.ROLE); 239 _userDirectoryHelper = (UserDirectoryHelper) manager.lookup(UserDirectoryHelper.ROLE); 240 _projectInvitationHelper = (ProjectInvitationHelper) manager.lookup(ProjectInvitationHelper.ROLE); 241 _languagesManager = (LanguagesManager) manager.lookup(LanguagesManager.ROLE); 242 _userDirectoryPageResolver = (UserDirectoryPageResolver) manager.lookup(UserDirectoryPageResolver.ROLE); 243 _uriResolver = (URIResolverExtensionPoint) manager.lookup(URIResolverExtensionPoint.ROLE); 244 _groupDirectoryContextHelper = (GroupDirectoryContextHelper) manager.lookup(GroupDirectoryContextHelper.ROLE); 245 _userSignupManager = (UserSignupManager) manager.lookup(UserSignupManager.ROLE); 246 _projectsCatalogueManager = (ProjectsCatalogueManager) manager.lookup(ProjectsCatalogueManager.ROLE); 247 _projectRightsHelper = (ProjectRightHelper) manager.lookup(ProjectRightHelper.ROLE); 248 _workspaceHelper = (WorkspacesHelper) manager.lookup(WorkspacesHelper.ROLE); 249 } 250 251 public void initialize() throws Exception 252 { 253 _abstractCacheManager.createMemoryCache(__PROJECT_MEMBER_CACHE, 254 new I18nizableText("plugin.workspaces", "PLUGIN_WORKSPACES_CACHE_PROJECT_MEMBER_LABEL"), 255 new I18nizableText("plugin.workspaces", "PLUGIN_WORKSPACES_CACHE_PROJECT_MEMBER_DESCRIPTION"), 256 true, 257 null); 258 259 _observationManager.registerObserver(this); 260 } 261 262 public void dispose() 263 { 264 _observationManager.unregisterObserver(this); 265 } 266 267 /** 268 * Retrieve the data of a member of a project, or the default data if no user is provided 269 * @param projectName The name of the project 270 * @param identity The user or group identity. If null, return the default profiles for a new user 271 * @param type The type of the identity. Can be "user" or "group" 272 * @return The map of profiles per module for the user 273 */ 274 @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID) 275 public Map<String, Object> getProjectMemberData(String projectName, String identity, String type) 276 { 277 278 Project project = _projectManager.getProject(projectName); 279 280 if (!_projectRightHelper.canEditMember(project)) 281 { 282 throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to get member's rights without convenient right [" + projectName + ", " + identity + "]"); 283 } 284 285 Map<String, Object> result = new HashMap<>(); 286 287 boolean isTypeUser = JCRProjectMember.MemberType.USER.name().equals(type.toUpperCase()); 288 boolean isTypeGroup = JCRProjectMember.MemberType.GROUP.name().equals(type.toUpperCase()); 289 UserIdentity user = Optional.ofNullable(identity) 290 .filter(id -> id != null && isTypeUser) 291 .map(UserIdentity::stringToUserIdentity) 292 .orElse(null); 293 GroupIdentity group = Optional.ofNullable(identity) 294 .filter(id -> id != null && isTypeGroup) 295 .map(GroupIdentity::stringToGroupIdentity) 296 .orElse(null); 297 298 if (identity != null) 299 { 300 if (isTypeGroup && group == null) 301 { 302 result.put("message", "unknown-group"); 303 result.put("success", false); 304 return result; 305 } 306 else if (isTypeUser && user == null) 307 { 308 result.put("message", "unknown-user"); 309 result.put("success", false); 310 return result; 311 } 312 } 313 314 315 boolean newMember = true; 316 Map<String, String> userProfiles; 317 318 if (user != null || group != null) 319 { 320 JCRProjectMember projectMember = user != null ? _getOrCreateJCRProjectMember(project, user) : _getOrCreateJCRProjectMember(project, group); 321 322 newMember = projectMember.needsSave(); 323 324 String role = projectMember.getRole(); 325 if (role != null) 326 { 327 result.put("role", role); 328 } 329 330 userProfiles = _getMemberProfiles(projectMember, project); 331 } 332 else 333 { 334 userProfiles = new HashMap<>(); 335 } 336 337 result.put("profiles", userProfiles); 338 result.put("status", newMember ? "new" : "edit"); 339 result.put("success", true); 340 341 return result; 342 } 343 344 345 /** 346 * Get right profile of a member 347 * @param member The member 348 * @param project The project name 349 * @return a map of the right profile 350 */ 351 private Map<String, String> _getMemberProfiles(JCRProjectMember member, Project project) 352 { 353 Map<String, String> userProfiles = new HashMap<>(); 354 355 // Get allowed profile on modules (among the project members's profiles) 356 for (WorkspaceModule module : _projectManager.getModules(project)) 357 { 358 String allowedProfileOnProject = _getAllowedProfileOnModule(project, module, member); 359 userProfiles.put(module.getId(), allowedProfileOnProject); 360 } 361 362 return userProfiles; 363 } 364 365 private String _getAllowedProfileOnModule (Project project, WorkspaceModule module, JCRProjectMember member) 366 { 367 Set<String> profileIds = _projectRightHelper.getProfilesIds(); 368 369 AmetysObject moduleObject = module.getModuleRoot(project, false); 370 Set<String> allowedProfilesForMember = _getAllowedProfile(member, moduleObject); 371 372 for (String allowedProfile : allowedProfilesForMember) 373 { 374 if (profileIds.contains(allowedProfile)) 375 { 376 // Get the first allowed profile among the project's members profiles 377 return allowedProfile; 378 } 379 } 380 381 return null; 382 } 383 384 /** 385 * Add new members and invitation by email 386 * @param projectName The project name 387 * @param newMembers The members to add (users or groups) 388 * @param invitEmails The invitation emails 389 * @return the result with errors 390 */ 391 @SuppressWarnings("unchecked") 392 @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID) 393 public Map<String, Object> addMembers(String projectName, List<Map<String, String>> newMembers, List<String> invitEmails) 394 { 395 Project project = _projectManager.getProject(projectName); 396 397 if (!_projectRightHelper.canAddMember(project)) 398 { 399 throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried perform operation without convenient right"); 400 } 401 402 Map<String, Object> result = new HashMap<>(); 403 404 Request request = ContextHelper.getRequest(_context); 405 String siteName = WebHelper.getSiteName(request); 406 407 boolean hasError = false; 408 boolean inviteError = false; 409 boolean unknownProject = false; 410 List<String> unknownGroups = new ArrayList<>(); 411 List<String> unknownUsers = new ArrayList<>(); 412 List<Map<String, Object>> existingUsers = new ArrayList<>(); 413 List<Map<String, Object>> membersAdded = new ArrayList<>(); 414 415 List<String> filteredInvitEmails = new ArrayList<>(); 416 if (invitEmails != null) 417 { 418 try 419 { 420 for (String invitEmail : invitEmails) 421 { 422 423 424 String filteredInvitEmail = invitEmail; 425 426 // Regexp pattern to extract "email@domain.com" from "FirstName LastName <email@domain.com>" 427 // ^[^<]*<(.*@.*)>$ 428 // ^ => asserts position at start of a line 429 // [^<]* => match any characters that are not '<', so the matched group start at the first bracket 430 // <(.*@.*)> => match text between brackets, containing '@' 431 Matcher matcher = __MAIL_BETWEEN_BRACKETS_PATTERN.matcher(invitEmail); 432 if (matcher.matches() && matcher.groupCount() == 1) 433 { 434 filteredInvitEmail = matcher.group(1); 435 } 436 437 Optional<User> userIfExists = _userSignupManager.getUserIfHeExists(filteredInvitEmail, siteName); 438 if (userIfExists.isPresent()) 439 { 440 newMembers.add(Map.of( 441 "id", UserIdentity.userIdentityToString(userIfExists.get().getIdentity()), 442 "type", "user" 443 )); 444 } 445 else 446 { 447 filteredInvitEmails.add(filteredInvitEmail); 448 } 449 } 450 } 451 catch (UserManagementException e) 452 { 453 hasError = true; 454 inviteError = true; 455 getLogger().error("Impossible to send email invitations", e); 456 } 457 catch (NotUniqueUserException e) 458 { 459 hasError = true; 460 inviteError = true; 461 getLogger().error("Impossible to send email invitations, some user already exist", e); 462 } 463 } 464 465 for (Map<String, String> newMember : newMembers) 466 { 467 Map<String, Object> addResult = addMember(projectName, newMember.get("id"), newMember.get("type")); 468 boolean success = (boolean) addResult.get("success"); 469 if (!success) 470 { 471 String error = (String) addResult.get("message"); 472 if ("unknown-user".equals(error)) 473 { 474 hasError = true; 475 unknownUsers.add(newMember.get("id")); 476 } 477 else if ("unknown-group".equals(error)) 478 { 479 hasError = true; 480 unknownGroups.add(newMember.get("id")); 481 } 482 else if ("unknown-project".equals(error)) 483 { 484 hasError = true; 485 unknownProject = true; 486 } 487 else if ("existing-user".equals(error)) 488 { 489 existingUsers.add((Map<String, Object>) addResult.get("existing-user")); 490 } 491 } 492 else 493 { 494 membersAdded.add((Map<String, Object>) addResult.get("member")); 495 } 496 } 497 498 if (!filteredInvitEmails.isEmpty()) 499 { 500 Map<String, String> newProfiles = _getDefaultProfilesByModule(project); 501 502 try 503 { 504 Map<String, Object> inviteEmails = _projectInvitationHelper.inviteEmails(projectName, filteredInvitEmails, newProfiles); 505 List<String> errors = (List<String>) inviteEmails.get("email-error"); 506 if (!errors.isEmpty()) 507 { 508 hasError = true; 509 inviteError = true; 510 } 511 existingUsers.addAll((List<Map<String, Object>>) inviteEmails.get("existing-users")); 512 } 513 catch (UserManagementException e) 514 { 515 hasError = true; 516 inviteError = true; 517 getLogger().error("Impossible to send email invitations", e); 518 } 519 catch (NotUniqueUserException e) 520 { 521 hasError = true; 522 inviteError = true; 523 getLogger().error("Impossible to send email invitations, some user already exist", e); 524 } 525 526 } 527 528 result.put("invite-error", inviteError); 529 result.put("existing-users", existingUsers); 530 result.put("unknown-groups", unknownGroups); 531 result.put("unknown-users", unknownUsers); 532 result.put("unknown-project", unknownProject); 533 result.put("members-added", membersAdded); 534 result.put("success", !hasError); 535 536 return result; 537 } 538 539 /** 540 * Add a new member 541 * @param projectName The project name 542 * @param identity The user or group identity. 543 * @param type The type of the identity. Can be "user" or "group" 544 * @return the result 545 */ 546 @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID) 547 public Map<String, Object> addMember(String projectName, String identity, String type) 548 { 549 Project project = _projectManager.getProject(projectName); 550 Map<String, String> newProfiles = _getDefaultProfilesByModule(project); 551 552 return _setProjectMemberData(projectName, identity, type, newProfiles, null, true); 553 } 554 555 /** 556 * Get a map of each available module, with the default profile 557 * @param project The project 558 * @return A map with moduleId : profileId 559 */ 560 protected Map<String, String> _getDefaultProfilesByModule(Project project) 561 { 562 Map<String, String> newProfiles = new HashMap<>(); 563 564 String projectDefaultProfile = project.getDefaultProfile(); 565 String defaultProfile = StringUtils.isNotBlank(projectDefaultProfile) 566 ? projectDefaultProfile 567 : StringUtils.defaultString(Config.getInstance().getValue("workspaces.profile.default")); 568 for (WorkspaceModule module : _projectManager.getProjectModulesForNewMembers(project)) 569 { 570 newProfiles.put(module.getId(), defaultProfile); 571 } 572 573 return newProfiles; 574 } 575 576 /** 577 * Set the user data in the project 578 * @param projectName The project name 579 * @param identity The user or group identity. 580 * @param type The type of the identity. Can be "user" or "group" 581 * @param newProfiles The profiles to affect, mapped by module 582 * @param role The user role inside the project 583 * @return The result 584 */ 585 @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID) 586 public Map<String, Object> setProjectMemberData(String projectName, String identity, String type, Map<String, String> newProfiles, String role) 587 { 588 return _setProjectMemberData(projectName, identity, type, newProfiles, role, false); 589 } 590 591 /** 592 * Set the user data in the project 593 * @param projectName The project name 594 * @param identity The user or group identity. 595 * @param type The type of the identity. Can be "user" or "group" 596 * @param newProfiles The profiles to affect, mapped by module 597 * @param role The user role inside the project 598 * @param isNewUser <code>true</code> if the user is just added 599 * @return The result 600 */ 601 protected Map<String, Object> _setProjectMemberData(String projectName, String identity, String type, Map<String, String> newProfiles, String role, boolean isNewUser) 602 { 603 Map<String, Object> result = new HashMap<>(); 604 Project project = _projectManager.getProject(projectName); 605 606 if (isNewUser && !_projectRightHelper.canAddMember(project) || !isNewUser && !_projectRightHelper.canEditMember(project)) 607 { 608 throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to set member rights without convenient right [" + projectName + ", " + identity + "]"); 609 } 610 611 boolean isTypeUser = JCRProjectMember.MemberType.USER.name().equals(type.toUpperCase()); 612 boolean isTypeGroup = JCRProjectMember.MemberType.GROUP.name().equals(type.toUpperCase()); 613 UserIdentity user = Optional.ofNullable(identity) 614 .filter(id -> id != null && isTypeUser) 615 .map(UserIdentity::stringToUserIdentity) 616 .orElse(null); 617 GroupIdentity group = Optional.ofNullable(identity) 618 .filter(id -> id != null && isTypeGroup) 619 .map(GroupIdentity::stringToGroupIdentity) 620 .orElse(null); 621 622 if (group == null && user == null) 623 { 624 result.put("success", false); 625 result.put("message", isTypeGroup ? "unknown-group" : "unknown-user"); 626 return result; 627 } 628 if (isNewUser && isTypeUser && _getProjectMember(project, user) != null) 629 { 630 result.put("success", false); 631 result.put("message", "existing-user"); 632 result.put("existing-user", _userHelper.user2json(user, true)); 633 634 return result; 635 } 636 637 String eventId = isNewUser ? ObservationConstants.EVENT_MEMBER_ADDED : ObservationConstants.EVENT_MEMBER_UPDATED; 638 639 JCRProjectMember projectMember = isTypeUser ? addOrUpdateProjectMember(project, user, newProfiles, eventId) : addOrUpdateProjectMember(project, group, newProfiles, eventId); 640 if (projectMember != null) 641 { 642 Request request = ContextHelper.getRequest(_context); 643 String lang = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SITEMAP_NAME); 644 645 ProjectMember member = isTypeUser ? new ProjectMember(_userManager.getUser(user), projectMember.getRole(), false) : new ProjectMember(_groupManager.getGroup(group)); 646 result.put("member", _member2Json(member, lang)); 647 } 648 649 result.put("success", projectMember != null); 650 return result; 651 } 652 653 /** 654 * Add a user to a project with open inscriptions, using the default values 655 * @param project The project 656 * @param user The user 657 * @return the added member in case of success, null otherwise 658 */ 659 public JCRProjectMember joinProject(Project project, UserIdentity user) 660 { 661 InscriptionStatus inscriptionStatus = project.getInscriptionStatus(); 662 if (!inscriptionStatus.equals(InscriptionStatus.OPEN)) 663 { 664 return null; 665 } 666 667 Map<String, String> profilesByModule = _getDefaultProfilesByModule(project); 668 return addOrUpdateProjectMember(project, user, profilesByModule, ObservationConstants.EVENT_MEMBER_JOINED); 669 } 670 671 /** 672 * Add a user to a project, using the provided profile values 673 * @param project The project 674 * @param user The user 675 * @param allowedProfiles the profile values 676 * @param eventId the event identifier, ObservationConstants.EVENT_MEMBER_ADDED, ObservationConstants.EVENT_MEMBER_JOINED or ObservationConstants.EVENT_MEMBER_UPDATED 677 * @return the added member in case of success, null otherwise 678 */ 679 public JCRProjectMember addOrUpdateProjectMember(Project project, UserIdentity user, Map<String, String> allowedProfiles, String eventId) 680 { 681 return addOrUpdateProjectMember(project, user, allowedProfiles, _currentUserProvider.getUser(), eventId); 682 } 683 /** 684 * Add a user to a project, using the provided profile values 685 * @param project The project 686 * @param user The user 687 * @param allowedProfiles the profile values 688 * @param issuer identity of the user that approved the member 689 * @param eventId the event identifier, ObservationConstants.EVENT_MEMBER_ADDED, ObservationConstants.EVENT_MEMBER_JOINED or ObservationConstants.EVENT_MEMBER_UPDATED 690 * @return the added member in case of success, null otherwise 691 */ 692 public JCRProjectMember addOrUpdateProjectMember(Project project, UserIdentity user, Map<String, String> allowedProfiles, UserIdentity issuer, String eventId) 693 { 694 if (user == null) 695 { 696 return null; 697 } 698 699 JCRProjectMember projectMember = _getOrCreateJCRProjectMember(project, user); 700 _setMemberProfiles(allowedProfiles, projectMember, project); 701 _saveAndNotifyProjectMemberUpdate(project, projectMember, UserIdentity.userIdentityToString(user), issuer, eventId); 702 return projectMember; 703 } 704 705 /** 706 * Add a group to a project, using the provided profile values 707 * @param project The project 708 * @param group The group 709 * @param allowedProfiles the profile values 710 * @param eventId the event identifier, ObservationConstants.EVENT_MEMBER_ADDED, ObservationConstants.EVENT_MEMBER_JOINED or ObservationConstants.EVENT_MEMBER_UPDATED 711 * @return the added member in case of success, null otherwise 712 */ 713 public JCRProjectMember addOrUpdateProjectMember(Project project, GroupIdentity group, Map<String, String> allowedProfiles, String eventId) 714 { 715 if (group == null) 716 { 717 return null; 718 } 719 720 JCRProjectMember projectMember = _getOrCreateJCRProjectMember(project, group); 721 _setMemberProfiles(allowedProfiles, projectMember, project); 722 _saveAndNotifyProjectMemberUpdate(project, projectMember, GroupIdentity.groupIdentityToString(group), _currentUserProvider.getUser(), eventId); 723 return projectMember; 724 } 725 726 private void _saveAndNotifyProjectMemberUpdate(Project project, JCRProjectMember projectMember, String userIdentityString, UserIdentity issuer, String eventId) 727 { 728 project.saveChanges(); 729 730 _getCache().invalidate(ProjectMemberCacheKey.of(project.getId(), null)); 731 732 // Notify listeners 733 Map<String, Object> eventParams = new HashMap<>(); 734 eventParams.put(ObservationConstants.ARGS_MEMBER, projectMember); 735 eventParams.put(ObservationConstants.ARGS_MEMBER_ID, projectMember.getId()); 736 eventParams.put(ObservationConstants.ARGS_PROJECT, project); 737 eventParams.put(ObservationConstants.ARGS_PROJECT_ID, project.getId()); 738 eventParams.put(ObservationConstants.ARGS_MEMBER_IDENTITY, userIdentityString); 739 eventParams.put(ObservationConstants.ARGS_MEMBER_IDENTITY_TYPE, projectMember.getType()); 740 741 _observationManager.notify(new Event(eventId, issuer, eventParams)); 742 } 743 744 /** 745 * Set the profiles for a member 746 * @param newProfiles The allowed profile by module 747 * @param projectMember The member 748 * @param project The project 749 */ 750 private void _setMemberProfiles(Map<String, String> newProfiles, JCRProjectMember projectMember, Project project) 751 { 752 for (String moduleId : newProfiles.keySet()) 753 { 754 WorkspaceModule module = _moduleManagerEP.getModule(moduleId); 755 if (module != null) 756 { 757 String profile = newProfiles.get(module.getId()); 758 Set<String> moduleProfiles = StringUtils.isNotBlank(profile) ? Set.of(profile) : Set.of(); 759 setProfileOnModule(projectMember, project, module, moduleProfiles); 760 } 761 } 762 } 763 764 /** 765 * Affect profiles for a member on a given module 766 * @param member The member 767 * @param project The project 768 * @param module The module 769 * @param allowedProfiles The allowed profiles for the module 770 */ 771 public void setProfileOnModule(JCRProjectMember member, Project project, WorkspaceModule module, Set<String> allowedProfiles) 772 { 773 if (module != null && _projectManager.isModuleActivated(project, module.getId())) 774 { 775 AmetysObject moduleObject = module.getModuleRoot(project, false); 776 _setMemberProfiles(member, allowedProfiles, moduleObject); 777 } 778 } 779 780 private Set<String> _getAllowedProfile(JCRProjectMember member, AmetysObject object) 781 { 782 if (MemberType.GROUP == member.getType()) 783 { 784 Map<GroupIdentity, Map<UserOrGroup, Set<String>>> profilesForGroups = _profileAssignmentStorageExtensionPoint.getProfilesForGroups(object, Set.of(member.getGroup())); 785 return Optional.ofNullable(profilesForGroups.get(member.getGroup())).map(a -> a.get(UserOrGroup.ALLOWED)).orElse(Set.of()); 786 } 787 else 788 { 789 Map<UserIdentity, Map<UserOrGroup, Set<String>>> profilesForUsers = _profileAssignmentStorageExtensionPoint.getProfilesForUsers(object, member.getUser()); 790 return Optional.ofNullable(profilesForUsers.get(member.getUser())).map(a -> a.get(UserOrGroup.ALLOWED)).orElse(Set.of()); 791 } 792 } 793 794 private void _setMemberProfiles(JCRProjectMember member, Set<String> allowedProfiles, AmetysObject object) 795 { 796 Set<String> currentAllowedProfiles = _getAllowedProfile(member, object); 797 798 Collection<String> profilesToRemove = CollectionUtils.removeAll(currentAllowedProfiles, allowedProfiles); 799 800 Collection<String> profilesToAdd = CollectionUtils.removeAll(allowedProfiles, currentAllowedProfiles); 801 802 for (String profileId : profilesToRemove) 803 { 804 _removeProfile(member, profileId, object); 805 } 806 807 for (String profileId : profilesToAdd) 808 { 809 _addProfile(member, profileId, object); 810 } 811 812 Collection<String> updatedProfiles = CollectionUtils.union(profilesToAdd, profilesToRemove); 813 814 if (updatedProfiles.size() > 0) 815 { 816 _notifyAclUpdated(_currentUserProvider.getUser(), object, updatedProfiles); 817 } 818 } 819 820 private void _removeProfile(JCRProjectMember member, String profileId, AmetysObject aclObject) 821 { 822 if (MemberType.GROUP == member.getType()) 823 { 824 _profileAssignmentStorageExtensionPoint.removeAllowedProfileFromGroup(member.getGroup(), profileId, aclObject); 825 } 826 else 827 { 828 _profileAssignmentStorageExtensionPoint.removeAllowedProfileFromUser(member.getUser(), profileId, aclObject); 829 } 830 } 831 832 private void _addProfile(JCRProjectMember member, String profileId, AmetysObject aclObject) 833 { 834 if (MemberType.GROUP == member.getType()) 835 { 836 _profileAssignmentStorageExtensionPoint.allowProfileToGroup(member.getGroup(), profileId, aclObject); 837 } 838 else 839 { 840 _profileAssignmentStorageExtensionPoint.allowProfileToUser(member.getUser(), profileId, aclObject); 841 } 842 } 843 844 private void _removeMemberProfiles(JCRProjectMember member, AmetysObject object) 845 { 846 Set<String> currentAllowedProfiles = _getAllowedProfile(member, object); 847 848 for (String allowedProfile : currentAllowedProfiles) 849 { 850 _removeProfile(member, allowedProfile, object); 851 } 852 853 if (currentAllowedProfiles.size() > 0) 854 { 855 ((ModifiableAmetysObject) object).saveChanges(); 856 857 Map<String, Object> eventParams = new HashMap<>(); 858 eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT, object); 859 eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT_IDENTIFIER, object.getId()); 860 eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_PROFILES, currentAllowedProfiles); 861 eventParams.put(org.ametys.cms.ObservationConstants.ARGS_ACL_SOLR_CACHE_UNINFLUENTIAL, true); 862 863 _observationManager.notify(new Event(org.ametys.core.ObservationConstants.EVENT_ACL_UPDATED, _currentUserProvider.getUser(), eventParams)); 864 } 865 } 866 867 /** 868 * Get the members of a project, sorted by managers, non empty role and name 869 * @param projectName the project's name 870 * @param lang the language to get user content 871 * @return the members of project 872 * @throws IllegalAccessException if an error occurred 873 * @throws AmetysRepositoryException if an error occurred 874 */ 875 @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID) 876 public Map<String, Object> getProjectMembers(String projectName, String lang) throws IllegalAccessException, AmetysRepositoryException 877 { 878 return getProjectMembers(projectName, lang, false); 879 } 880 881 /** 882 * Get the members of a project, sorted by managers, non empty role and name 883 * @param projectName the project's name 884 * @param lang the language to get user content 885 * @param expandGroup true if groups are expanded 886 * @return the members of project 887 * @throws AmetysRepositoryException if an error occurred 888 */ 889 @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID) 890 public Map<String, Object> getProjectMembers(String projectName, String lang, boolean expandGroup) throws AmetysRepositoryException 891 { 892 Map<String, Object> result = new HashMap<>(); 893 894 Project project = _projectManager.getProject(projectName); 895 if (!_projectRightHelper.hasReadAccess(project)) 896 { 897 throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to do read operation without convenient right"); 898 } 899 900 List<Map<String, Object>> membersData = new ArrayList<>(); 901 902 Set<ProjectMember> projectMembers = getProjectMembers(project, expandGroup); 903 904 for (ProjectMember projectMember : projectMembers) 905 { 906 membersData.add(_member2Json(projectMember, lang)); 907 } 908 909 result.put("members", membersData); 910 result.put("success", true); 911 912 return result; 913 } 914 915 private Map<String, Object> _member2Json(ProjectMember projectMember, String lang) 916 { 917 Project project = _workspaceHelper.getProjectFromRequest(); 918 Map<String, Object> memberData = new HashMap<>(); 919 920 memberData.put("type", projectMember.getType().name().toLowerCase()); 921 memberData.put("title", projectMember.getTitle()); 922 memberData.put("sortabletitle", projectMember.getSortableTitle()); 923 memberData.put("manager", projectMember.isManager()); 924 925 String role = projectMember.getRole(); 926 if (StringUtils.isNotEmpty(role)) 927 { 928 memberData.put("role", role); 929 } 930 931 User user = projectMember.getUser(); 932 if (user != null) 933 { 934 memberData.put("id", UserIdentity.userIdentityToString(user.getIdentity())); 935 memberData.putAll(_userHelper.user2json(user)); 936 937 Content userContent = getUserContent(lang, user); 938 939 if (userContent != null) 940 { 941 if (userContent.hasValue("function")) 942 { 943 memberData.put("function", userContent.getValue("function")); 944 } 945 946 if (userContent.hasValue("organisation-accronym")) 947 { 948 memberData.put("organisationAcronym", userContent.getValue("organisation-accronym")); 949 } 950 String usersDirectorySiteName = _projectManager.getUsersDirectorySiteName(); 951 String[] contentTypes = userContent.getTypes(); 952 for (String contentType : contentTypes) 953 { 954 // Try to see if a user page exists for this content type 955 UserPage userPage = _userDirectoryPageResolver.getUserPage(userContent, usersDirectorySiteName, lang, contentType); 956 if (userPage != null) 957 { 958 memberData.put("link", _uriResolver.getResolverForType("page").resolve(userPage.getId(), false, true, false)); 959 } 960 } 961 962 } 963 else if (getLogger().isDebugEnabled()) 964 { 965 getLogger().debug("User content not found for user : " + user); 966 } 967 968 memberData.put("hasReadAccessOnTaskModule", _projectRightsHelper.hasReadAccessOnModule(project, TasksWorkspaceModule.TASK_MODULE_ID, user.getIdentity())); 969 memberData.put("hasReadAccessOnDocumentModule", _projectRightsHelper.hasReadAccessOnModule(project, DocumentWorkspaceModule.DOCUMENT_MODULE_ID, user.getIdentity())); 970 memberData.put("hasReadAccessOnForumModule", _projectRightsHelper.hasReadAccessOnModule(project, ForumWorkspaceModule.FORUM_MODULE_ID, user.getIdentity())); 971 972 } 973 974 Group group = projectMember.getGroup(); 975 if (group != null) 976 { 977 memberData.putAll(group2Json(group)); 978 } 979 980 return memberData; 981 } 982 983 /** 984 * Get user content 985 * @param lang the lang 986 * @param user the user 987 * @return the user content or null if no exist 988 */ 989 public Content getUserContent(String lang, User user) 990 { 991 Content userContent = _userDirectoryHelper.getUserContent(user.getIdentity(), lang); 992 993 if (userContent == null) 994 { 995 userContent = _userDirectoryHelper.getUserContent(user.getIdentity(), "en"); 996 } 997 998 if (userContent == null) 999 { 1000 Map<String, Language> availableLanguages = _languagesManager.getAvailableLanguages(); 1001 for (Language availableLanguage : availableLanguages.values()) 1002 { 1003 if (userContent == null) 1004 { 1005 userContent = _userDirectoryHelper.getUserContent(user.getIdentity(), availableLanguage.getCode()); 1006 } 1007 } 1008 } 1009 return userContent; 1010 } 1011 1012 /** 1013 * Get the members of a project, sorted by managers, non empty role and name 1014 * @param project the project 1015 * @param expandGroup true to expand the user of a group 1016 * @return the members of project 1017 * @throws AmetysRepositoryException if an error occurred 1018 */ 1019 public Set<ProjectMember> getProjectMembers(Project project, boolean expandGroup) throws AmetysRepositoryException 1020 { 1021 return getProjectMembers(project, expandGroup, Set.of()); 1022 } 1023 1024 /** 1025 * Get the members of a project, sorted by managers, non empty role and name 1026 * @param project the project 1027 * @param expandGroup true to expand the user of a group 1028 * @param defaultSet default set to return when project has no site 1029 * @return the members of project 1030 * @throws AmetysRepositoryException if an error occurred 1031 */ 1032 public Set<ProjectMember> getProjectMembers(Project project, boolean expandGroup, Set<ProjectMember> defaultSet) throws AmetysRepositoryException 1033 { 1034 Cache<ProjectMemberCacheKey, Set<ProjectMember>> cache = _getCache(); 1035 if (project == null) 1036 { 1037 return defaultSet; 1038 } 1039 ProjectMemberCacheKey cacheKey = ProjectMemberCacheKey.of(project.getId(), expandGroup); 1040 if (cache.hasKey(cacheKey)) 1041 { 1042 Set<ProjectMember> projectMembers = cache.get(cacheKey); 1043 return projectMembers != null ? projectMembers : defaultSet; 1044 } 1045 else 1046 { 1047 Set<ProjectMember> projectMembers = _getProjectMembers(project, expandGroup); 1048 cache.put(cacheKey, projectMembers); 1049 return projectMembers != null ? projectMembers : defaultSet; 1050 } 1051 } 1052 1053 private Set<ProjectMember> _getProjectMembers(Project project, boolean expandGroup) 1054 { 1055 Comparator<ProjectMember> managerComparator = Comparator.comparing(m -> m.isManager() ? 0 : 1); 1056 Comparator<ProjectMember> roleComparator = Comparator.comparing(m -> StringUtils.isNotBlank(m.getRole()) ? 0 : 1); 1057 // Use sortable title for sort, and concatenate it with hash code of user, so that homonyms do not appear equals 1058 Comparator<ProjectMember> nameComparator = (m1, m2) -> (m1.getSortableTitle() + m1.hashCode()).compareToIgnoreCase(m2.getSortableTitle() + m2.hashCode()); 1059 1060 Set<ProjectMember> members = new TreeSet<>(managerComparator.thenComparing(roleComparator).thenComparing(nameComparator)); 1061 1062 Map<JCRProjectMember, Object> jcrMembers = getJCRProjectMembers(project); 1063 List<UserIdentity> managers = Arrays.asList(project.getManagers()); 1064 1065 Site site = project.getSite(); 1066 if (site == null) 1067 { 1068 getLogger().error("Can not compute members in the project " + project.getName() + " because it can not be linked to an existing site"); 1069 return null; 1070 } 1071 String projectSiteName = site.getName(); 1072 1073 Set<String> projectGroupDirectory = _groupDirectoryContextHelper.getGroupDirectoriesOnContext("/sites/" + projectSiteName); 1074 1075 for (Entry<JCRProjectMember, Object> entry : jcrMembers.entrySet()) 1076 { 1077 JCRProjectMember jcrMember = entry.getKey(); 1078 if (MemberType.USER == jcrMember.getType()) 1079 { 1080 User user = (User) entry.getValue(); 1081 boolean isManager = managers.contains(jcrMember.getUser()); 1082 1083 ProjectMember projectMember = new ProjectMember(user, jcrMember.getRole(), isManager); 1084 if (!members.add(projectMember) && _projectManager.isUserInProjectPopulations(project, user.getIdentity())) 1085 { 1086 //if set already contains the user, override it (users always take over users' group) 1087 members.remove(projectMember); // remove the one in the set 1088 members.add(projectMember); // add the new one 1089 } 1090 } 1091 else if (MemberType.GROUP == jcrMember.getType()) 1092 { 1093 Group group = (Group) entry.getValue(); 1094 if (projectGroupDirectory.contains(group.getGroupDirectory().getId())) 1095 { 1096 if (expandGroup) 1097 { 1098 for (UserIdentity userIdentity : group.getUsers()) 1099 { 1100 User user = _userManager.getUser(userIdentity); 1101 if (user != null && _projectManager.isUserInProjectPopulations(project, userIdentity)) 1102 { 1103 ProjectMember projectMember = new ProjectMember(user, null, false); 1104 members.add(projectMember); // add if does not exist yet 1105 } 1106 } 1107 } 1108 else 1109 { 1110 // Add the member as group 1111 members.add(new ProjectMember(group)); 1112 } 1113 } 1114 } 1115 } 1116 return members; 1117 } 1118 1119 /** 1120 * Retrieves the rights for the current user in the project 1121 * @param projectName The project Name 1122 * @return The project 1123 */ 1124 @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID) 1125 public Map<String, Object> getMemberModuleRights(String projectName) 1126 { 1127 1128 if (!_projectRightsHelper.hasReadAccessOnModule(MembersWorkspaceModule.MEMBERS_MODULE_ID)) 1129 { 1130 throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to do read operation without convenient right"); 1131 } 1132 1133 Map<String, Object> results = new HashMap<>(); 1134 Map<String, Object> rights = new HashMap<>(); 1135 1136 Project project = _projectManager.getProject(projectName); 1137 if (project == null) 1138 { 1139 results.put("message", "unknown-project"); 1140 results.put("success", false); 1141 } 1142 else 1143 { 1144 rights.put("view", _projectRightHelper.canViewMembers(project)); 1145 rights.put("add", _projectRightHelper.canAddMember(project)); 1146 rights.put("edit", _projectRightHelper.canEditMember(project)); 1147 rights.put("delete", _projectRightHelper.canRemoveMember(project)); 1148 results.put("rights", rights); 1149 results.put("success", true); 1150 } 1151 1152 return results; 1153 } 1154 1155 /** 1156 * Get the list of users of the project 1157 * @param project The project 1158 * @return The list of users 1159 */ 1160 public Map<JCRProjectMember, Object> getJCRProjectMembers(Project project) 1161 { 1162 Map<JCRProjectMember, Object> projectMembers = new HashMap<>(); 1163 1164 if (project != null) 1165 { 1166 ModifiableTraversableAmetysObject membersNode = _getProjectMembersNode(project); 1167 1168 for (AmetysObject memberNode : membersNode.getChildren()) 1169 { 1170 if (memberNode instanceof JCRProjectMember) 1171 { 1172 JCRProjectMember jCRProjectMember = (JCRProjectMember) memberNode; 1173 if (jCRProjectMember.getType() == MemberType.USER) 1174 { 1175 UserIdentity userIdentity = jCRProjectMember.getUser(); 1176 User user = _userManager.getUser(userIdentity); 1177 if (user != null) 1178 { 1179 projectMembers.put((JCRProjectMember) memberNode, user); 1180 } 1181 } 1182 else 1183 { 1184 GroupIdentity groupIdentity = jCRProjectMember.getGroup(); 1185 Group group = _groupManager.getGroup(groupIdentity); 1186 if (group != null) 1187 { 1188 projectMembers.put((JCRProjectMember) memberNode, group); 1189 } 1190 } 1191 } 1192 } 1193 } 1194 1195 return projectMembers; 1196 } 1197 1198 /** 1199 * Test if an user is a member of a project (directly or by a group) 1200 * @param project The project 1201 * @param userIdentity The user identity 1202 * @return True if this user is a member of this project 1203 */ 1204 public boolean isProjectMember(Project project, UserIdentity userIdentity) 1205 { 1206 return getProjectMember(project, userIdentity) != null; 1207 } 1208 1209 /** 1210 * Retrieve the member of a project corresponding to the user identity 1211 * @param project The project 1212 * @param userIdentity The user identity 1213 * @return The member of this project, which can be of type "user" or "group", or null if the user is not in the project 1214 */ 1215 public ProjectMember getProjectMember(Project project, UserIdentity userIdentity) 1216 { 1217 return getProjectMember(project, userIdentity, null); 1218 } 1219 1220 /** 1221 * Retrieve the member of a project corresponding to the user identity 1222 * @param project The project 1223 * @param userIdentity The user identity 1224 * @param userGroups The user groups. If null the user's groups will be expanded. 1225 * @return The member of this project, which can be of type "user" or "group", or null if the user is not in the project 1226 */ 1227 public ProjectMember getProjectMember(Project project, UserIdentity userIdentity, Set<GroupIdentity> userGroups) 1228 { 1229 if (userIdentity == null) 1230 { 1231 return null; 1232 } 1233 1234 Set<ProjectMember> members = getProjectMembers(project, true); 1235 1236 ProjectMember projectMember = members.stream() 1237 .filter(member -> MemberType.USER == member.getType()) 1238 .filter(member -> userIdentity.equals(member.getUser().getIdentity())) 1239 .findFirst() 1240 .orElse(null); 1241 1242 if (projectMember != null) 1243 { 1244 return projectMember; 1245 } 1246 1247 Set<GroupIdentity> groups = userGroups == null ? _groupManager.getUserGroups(userIdentity) : userGroups; // get user's groups 1248 1249 if (!groups.isEmpty()) 1250 { 1251 return members.stream() 1252 .filter(member -> MemberType.GROUP == member.getType()) 1253 .filter(member -> groups.contains(member.getGroup().getIdentity())) 1254 .findFirst() 1255 .orElse(null); 1256 } 1257 1258 return null; 1259 } 1260 1261 /** 1262 * Set the manager of a project 1263 * @param projectName The project name 1264 * @param profileId The profile id to affect 1265 * @param managers The managers' user identity 1266 */ 1267 public void setProjectManager(String projectName, String profileId, List<UserIdentity> managers) 1268 { 1269 Project project = _projectManager.getProject(projectName); 1270 if (project == null) 1271 { 1272 return; 1273 } 1274 1275 project.setManagers(managers.toArray(new UserIdentity[managers.size()])); 1276 1277 for (UserIdentity userIdentity : managers) 1278 { 1279 JCRProjectMember member = _getOrCreateJCRProjectMember(project, userIdentity); 1280 1281 Set<String> allowedProfiles = Set.of(profileId); 1282 for (WorkspaceModule module : _projectManager.getModules(project)) 1283 { 1284 setProfileOnModule(member, project, module, allowedProfiles); 1285 } 1286 } 1287 1288 project.saveChanges(); 1289 1290 _getCache().invalidate(ProjectMemberCacheKey.of(project.getId(), null)); 1291 1292 // Clear rights manager cache (if I remove my own rights) 1293 _rightManager.clearCaches(); 1294 1295// Request request = ContextHelper.getRequest(_context); 1296// if (request != null) 1297// { 1298// request.removeAttribute(RightManager.CACHE_REQUEST_ATTRIBUTE_NAME); 1299// } 1300 } 1301 1302 private void _notifyAclUpdated(UserIdentity userIdentity, AmetysObject aclContext, Collection<String> aclProfiles) 1303 { 1304 Map<String, Object> eventParams = new HashMap<>(); 1305 eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT, aclContext); 1306 eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT_IDENTIFIER, aclContext.getId()); 1307 eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_PROFILES, aclProfiles); 1308 eventParams.put(org.ametys.cms.ObservationConstants.ARGS_ACL_SOLR_CACHE_UNINFLUENTIAL, true); 1309 1310 _observationManager.notify(new Event(org.ametys.core.ObservationConstants.EVENT_ACL_UPDATED, userIdentity, eventParams)); 1311 } 1312 1313 /** 1314 * Retrieve or create a user in a project 1315 * @param project The project 1316 * @param userIdentity the user 1317 * @return The user 1318 */ 1319 private JCRProjectMember _getOrCreateJCRProjectMember(Project project, UserIdentity userIdentity) 1320 { 1321 Predicate<? super AmetysObject> findMemberPredicate = memberNode -> MemberType.USER == ((JCRProjectMember) memberNode).getType() 1322 && userIdentity.equals(((JCRProjectMember) memberNode).getUser()); 1323 JCRProjectMember projectMember = _getOrCreateJCRProjectMember(project, findMemberPredicate); 1324 1325 if (projectMember.needsSave()) 1326 { 1327 projectMember.setUser(userIdentity); 1328 projectMember.setType(MemberType.USER); 1329 } 1330 1331 return projectMember; 1332 } 1333 1334 /** 1335 * Retrieve or create a group as a member in a project 1336 * @param project The project 1337 * @param groupIdentity the group 1338 * @return The user 1339 */ 1340 private JCRProjectMember _getOrCreateJCRProjectMember(Project project, GroupIdentity groupIdentity) 1341 { 1342 Predicate<? super AmetysObject> findMemberPredicate = memberNode -> MemberType.GROUP == ((JCRProjectMember) memberNode).getType() 1343 && groupIdentity.equals(((JCRProjectMember) memberNode).getGroup()); 1344 JCRProjectMember projectMember = _getOrCreateJCRProjectMember(project, findMemberPredicate); 1345 1346 if (projectMember.needsSave()) 1347 { 1348 projectMember.setGroup(groupIdentity); 1349 projectMember.setType(MemberType.GROUP); 1350 } 1351 1352 return projectMember; 1353 } 1354 1355 /** 1356 * Retrieve or create a member in a project 1357 * @param project The project 1358 * @param findMemberPredicate The predicate to find the member node 1359 * @return The member node. A new node is created if the member node was not found 1360 */ 1361 protected JCRProjectMember _getOrCreateJCRProjectMember(Project project, Predicate<? super AmetysObject> findMemberPredicate) 1362 { 1363 ModifiableTraversableAmetysObject membersNode = _getProjectMembersNode(project); 1364 1365 Optional<AmetysObject> member = _getProjectMembersNode(project).getChildren() 1366 .stream() 1367 .filter(memberNode -> memberNode instanceof JCRProjectMember) 1368 .filter(findMemberPredicate) 1369 .findFirst(); 1370 if (member.isPresent()) 1371 { 1372 return (JCRProjectMember) member.get(); 1373 } 1374 1375 String baseName = "member"; 1376 String name = baseName; 1377 int index = 1; 1378 while (membersNode.hasChild(name)) 1379 { 1380 index++; 1381 name = baseName + "-" + index; 1382 } 1383 1384 JCRProjectMember jcrProjectMember = membersNode.createChild(name, __PROJECT_MEMBER_NODE_TYPE); 1385 1386 // we invalidate the cache has we had to create a new user 1387 _getCache().invalidate(ProjectMemberCacheKey.of(project.getId(), null)); 1388 1389 return jcrProjectMember; 1390 } 1391 1392 1393 /** 1394 * Remove a user from a project 1395 * @param projectName The project name 1396 * @param identity The identity of the user or group, who must be a member of the project 1397 * @param type The type of the member, user or group 1398 * @return The error code, if an error occurred 1399 */ 1400 @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID) 1401 public Map<String, Object> removeMember(String projectName, String identity, String type) 1402 { 1403 Project project = _projectManager.getProject(projectName); 1404 if (!_projectRightHelper.canRemoveMember(project)) 1405 { 1406 throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to remove member without convenient right [" + projectName + ", " + identity + "]"); 1407 } 1408 return _removeMember(projectName, identity, type, true); 1409 } 1410 1411 private Map<String, Object> _removeMember(String projectName, String identity, String type, boolean checkCurrentUser) 1412 { 1413 Map<String, Object> result = new HashMap<>(); 1414 1415 MemberType memberType = MemberType.valueOf(type.toUpperCase()); 1416 boolean isTypeUser = MemberType.USER == memberType; 1417 boolean isTypeGroup = MemberType.GROUP == memberType; 1418 UserIdentity user = Optional.ofNullable(identity) 1419 .filter(id -> id != null && isTypeUser) 1420 .map(UserIdentity::stringToUserIdentity) 1421 .orElse(null); 1422 GroupIdentity group = Optional.ofNullable(identity) 1423 .filter(id -> id != null && isTypeGroup) 1424 .map(GroupIdentity::stringToGroupIdentity) 1425 .orElse(null); 1426 1427 if (isTypeGroup && group == null 1428 || isTypeUser && user == null) 1429 { 1430 result.put("success", false); 1431 result.put("message", isTypeGroup ? "unknown-group" : "unknown-user"); 1432 return result; 1433 } 1434 1435 Project project = _projectManager.getProject(projectName); 1436 if (project == null) 1437 { 1438 result.put("success", false); 1439 result.put("message", "unknown-project"); 1440 return result; 1441 } 1442 1443 if (checkCurrentUser && _isCurrentUser(isTypeUser, user)) 1444 { 1445 result.put("success", false); 1446 result.put("message", "current-user"); 1447 return result; 1448 } 1449 1450 // If there is only one manager, do not remove him from the project's members 1451 if (isTypeUser && isOnlyManager(project, user)) 1452 { 1453 result.put("success", false); 1454 result.put("message", "only-manager"); 1455 return result; 1456 } 1457 1458 JCRProjectMember projectMember = null; 1459 if (isTypeUser) 1460 { 1461 projectMember = _getProjectMember(project, user); 1462 } 1463 else if (isTypeGroup) 1464 { 1465 projectMember = _getProjectMember(project, group); 1466 } 1467 1468 if (projectMember == null) 1469 { 1470 result.put("success", false); 1471 result.put("message", "unknown-member"); 1472 return result; 1473 } 1474 1475 _removeMember(projectMember, project); 1476 1477 result.put("success", true); 1478 return result; 1479 } 1480 1481 private void _removeMember(JCRProjectMember projectMember, Project project) 1482 { 1483 _removeManager(project, projectMember); 1484 _removeMemberProfiles(project, projectMember); 1485 MemberType memberType = projectMember.getType(); 1486 1487 Map<String, Object> eventParams = new HashMap<>(); 1488 eventParams.put(ObservationConstants.ARGS_MEMBER_IDENTITY, MemberType.USER.equals(memberType) 1489 ? UserIdentity.userIdentityToString(projectMember.getUser()) 1490 : GroupIdentity.groupIdentityToString(projectMember.getGroup())); 1491 eventParams.put(ObservationConstants.ARGS_MEMBER_IDENTITY_TYPE, memberType); 1492 eventParams.put(ObservationConstants.ARGS_PROJECT, project); 1493 eventParams.put(ObservationConstants.ARGS_PROJECT_ID, project.getId()); 1494 1495 projectMember.remove(); 1496 project.saveChanges(); 1497 1498 _getCache().invalidate(ProjectMemberCacheKey.of(project.getId(), null)); 1499 _observationManager.notify(new Event(ObservationConstants.EVENT_MEMBER_DELETED, _currentUserProvider.getUser(), eventParams)); 1500 } 1501 1502 private boolean _isCurrentUser(boolean isTypeUser, UserIdentity user) 1503 { 1504 return isTypeUser && _currentUserProvider.getUser().equals(user); 1505 } 1506 1507 /** 1508 * Check if a user is the only manager of a project 1509 * @param project the project 1510 * @param user the user 1511 * @return true if the user is the only manager of the project 1512 */ 1513 public boolean isOnlyManager(Project project, UserIdentity user) 1514 { 1515 UserIdentity[] managers = project.getManagers(); 1516 return managers.length == 1 && managers[0].equals(user); 1517 } 1518 1519 private JCRProjectMember _getProjectMember(Project project, GroupIdentity group) 1520 { 1521 JCRProjectMember projectMember = null; 1522 ModifiableTraversableAmetysObject membersNode = _getProjectMembersNode(project); 1523 1524 for (AmetysObject memberNode : membersNode.getChildren()) 1525 { 1526 if (memberNode instanceof JCRProjectMember) 1527 { 1528 JCRProjectMember member = (JCRProjectMember) memberNode; 1529 if (MemberType.GROUP == member.getType() && group.equals(member.getGroup())) 1530 { 1531 projectMember = (JCRProjectMember) memberNode; 1532 } 1533 1534 } 1535 } 1536 return projectMember; 1537 } 1538 1539 private JCRProjectMember _getProjectMember(Project project, UserIdentity user) 1540 { 1541 JCRProjectMember projectMember = null; 1542 ModifiableTraversableAmetysObject membersNode = _getProjectMembersNode(project); 1543 1544 for (AmetysObject memberNode : membersNode.getChildren()) 1545 { 1546 if (memberNode instanceof JCRProjectMember) 1547 { 1548 JCRProjectMember member = (JCRProjectMember) memberNode; 1549 if (MemberType.USER == member.getType() && user.equals(member.getUser())) 1550 { 1551 projectMember = (JCRProjectMember) memberNode; 1552 } 1553 } 1554 } 1555 return projectMember; 1556 } 1557 1558 private void _removeManager(Project project, JCRProjectMember projectMember) 1559 { 1560 if (MemberType.USER.equals(projectMember.getType())) 1561 { 1562 UserIdentity user = projectMember.getUser(); 1563 UserIdentity[] oldManagers = project.getManagers(); 1564 1565 // Remove the user from the project's managers 1566 UserIdentity[] managers = Arrays.stream(oldManagers) 1567 .filter(manager -> !manager.equals(user)) 1568 .toArray(UserIdentity[]::new); 1569 1570 project.setManagers(managers); 1571 } 1572 } 1573 1574 private void _removeMemberProfiles(Project project, JCRProjectMember projectMember) 1575 { 1576 for (WorkspaceModule module : _projectManager.getModules(project)) 1577 { 1578 ModifiableResourceCollection moduleRootNode = module.getModuleRoot(project, false); 1579 _removeMemberProfiles(projectMember, moduleRootNode); 1580 } 1581 } 1582 1583 /** 1584 * Retrieves the users node of the project 1585 * The users node will be created if necessary 1586 * @param project The project 1587 * @return The users node of the project 1588 */ 1589 protected ModifiableTraversableAmetysObject _getProjectMembersNode(Project project) 1590 { 1591 if (project == null) 1592 { 1593 throw new AmetysRepositoryException("Error getting the project users node, project is null"); 1594 } 1595 1596 try 1597 { 1598 ModifiableTraversableAmetysObject membersNode; 1599 if (project.hasChild(__PROJECT_MEMBERS_NODE)) 1600 { 1601 membersNode = project.getChild(__PROJECT_MEMBERS_NODE); 1602 } 1603 else 1604 { 1605 membersNode = project.createChild(__PROJECT_MEMBERS_NODE, __PROJECT_MEMBERS_NODE_TYPE); 1606 } 1607 1608 return membersNode; 1609 } 1610 catch (AmetysRepositoryException e) 1611 { 1612 throw new AmetysRepositoryException("Error getting the project users node", e); 1613 } 1614 } 1615 1616 /** 1617 * Get the JSON representation of a group 1618 * @param group The group 1619 * @return The group 1620 */ 1621 protected Map<String, Object> group2Json(Group group) 1622 { 1623 Map<String, Object> infos = new HashMap<>(); 1624 infos.put("id", GroupIdentity.groupIdentityToString(group.getIdentity())); 1625 infos.put("groupId", group.getIdentity().getId()); 1626 infos.put("label", group.getLabel()); 1627 infos.put("sortablename", group.getLabel()); 1628 infos.put("groupDirectory", group.getIdentity().getDirectoryId()); 1629 return infos; 1630 } 1631 1632 /** 1633 * Count the total of unique users in the project and in the project's group 1634 * @param project The project 1635 * @return The total of members 1636 */ 1637 public Long getMembersCount(Project project) 1638 { 1639 Set<ProjectMember> projectMembers = getProjectMembers(project, true); 1640 1641 return (long) projectMembers.size(); 1642 } 1643 1644 /** 1645 * Get the users from a group that are part of the project. They can be filtered with a predicate 1646 * @param group The group 1647 * @param project The project 1648 * @param filteringPredicate The predicate to filter 1649 * @return The list of users 1650 */ 1651 public List<User> getGroupUsersFromProject(Group group, Project project, BiPredicate<Project, UserIdentity> filteringPredicate) 1652 { 1653 Set<String> projectPopulations = _populationContextHelper.getUserPopulationsOnContexts(List.of("/sites/" + project.getSite().getName()), false, false); 1654 1655 return group.getUsers().stream() 1656 .filter(user -> projectPopulations.contains(user.getPopulationId())) 1657 .filter(user -> filteringPredicate.test(project, user)) 1658 .map(_userManager::getUser) 1659 .filter(Objects::nonNull) 1660 .collect(Collectors.toList()); 1661 } 1662 1663 /** 1664 * Make the current user leave the project 1665 * @param projectName The project name 1666 * @return The error code, if an error occurred 1667 */ 1668 @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID) 1669 public Map<String, Object> leaveProject(String projectName) 1670 { 1671 Project project = _projectManager.getProject(projectName); 1672 if (!isProjectMember(project, _currentUserProvider.getUser())) 1673 { 1674 throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried perform operation without convenient right"); 1675 } 1676 UserIdentity currentUser = _currentUserProvider.getUser(); 1677 String identity = UserIdentity.userIdentityToString(currentUser); 1678 1679 Map<String, Object> result = _removeMember(projectName, identity, MemberType.USER.toString(), false); 1680 1681 return result; 1682 } 1683 1684 private Cache<ProjectMemberCacheKey, Set<ProjectMember>> _getCache() 1685 { 1686 return _abstractCacheManager.get(__PROJECT_MEMBER_CACHE); 1687 } 1688 1689 /** 1690 * This class represents a member of a project. Could be a user or a group 1691 * 1692 */ 1693 public static class ProjectMember 1694 { 1695 private String _title; 1696 private String _sortableTitle; 1697 private MemberType _type; 1698 private String _role; 1699 private User _user; 1700 private Group _group; 1701 private boolean _isManager; 1702 1703 /** 1704 * Create a project member as a group 1705 * @param group the group attached to this member. Cannot be null. 1706 */ 1707 public ProjectMember(Group group) 1708 { 1709 _title = group.getLabel(); 1710 _sortableTitle = group.getLabel(); 1711 _type = MemberType.GROUP; 1712 _role = null; 1713 _isManager = false; 1714 _user = null; 1715 _group = group; 1716 } 1717 1718 /** 1719 * Create a project member as a group 1720 * @param role the role 1721 * @param isManager true if the member is a manager of the project 1722 * @param user the user attached to this member. Cannot be null. 1723 */ 1724 public ProjectMember(User user, String role, boolean isManager) 1725 { 1726 _title = user.getFullName(); 1727 _sortableTitle = user.getSortableName(); 1728 _type = MemberType.USER; 1729 _role = role; 1730 _isManager = isManager; 1731 _user = user; 1732 _group = null; 1733 } 1734 1735 /** 1736 * Get the title of the member. 1737 * @return The title of the member 1738 */ 1739 public String getTitle() 1740 { 1741 return _title; 1742 } 1743 1744 /** 1745 * Get the sortable title of the member. 1746 * @return The sortable title of the member 1747 */ 1748 public String getSortableTitle() 1749 { 1750 return _sortableTitle; 1751 } 1752 1753 /** 1754 * Get the type of the member. It can be a user or a group 1755 * @return The type of the member 1756 */ 1757 public MemberType getType() 1758 { 1759 return _type; 1760 } 1761 1762 /** 1763 * Get the role of the member. 1764 * @return The role of the member 1765 */ 1766 public String getRole() 1767 { 1768 return _role; 1769 } 1770 1771 /** 1772 * Test if the member is a manager of the project 1773 * @return True if this user is a manager of the project 1774 */ 1775 public boolean isManager() 1776 { 1777 return _isManager; 1778 } 1779 1780 /** 1781 * Get the user of the member. 1782 * @return The user of the member 1783 */ 1784 public User getUser() 1785 { 1786 return _user; 1787 } 1788 1789 /** 1790 * Get the group of the member. 1791 * @return The group of the member 1792 */ 1793 public Group getGroup() 1794 { 1795 return _group; 1796 } 1797 1798 @Override 1799 public boolean equals(Object obj) 1800 { 1801 if (obj == null || !(obj instanceof ProjectMember)) 1802 { 1803 return false; 1804 } 1805 1806 ProjectMember otherMember = (ProjectMember) obj; 1807 1808 if (getType() != otherMember.getType()) 1809 { 1810 return false; 1811 } 1812 1813 if (getType() == MemberType.USER) 1814 { 1815 return getUser().equals(otherMember.getUser()); 1816 } 1817 else 1818 { 1819 return getGroup().equals(otherMember.getGroup()); 1820 } 1821 } 1822 1823 @Override 1824 public int hashCode() 1825 { 1826 return getType() == MemberType.USER ? getUser().getIdentity().hashCode() : getGroup().getIdentity().hashCode(); 1827 } 1828 } 1829 1830 private static final class ProjectMemberCacheKey extends AbstractCacheKey 1831 { 1832 public ProjectMemberCacheKey(String projectId, Boolean extendGroup) 1833 { 1834 super(projectId, extendGroup); 1835 } 1836 1837 public static ProjectMemberCacheKey of(String projectId, Boolean withExpandedGroup) 1838 { 1839 return new ProjectMemberCacheKey(projectId, withExpandedGroup); 1840 } 1841 } 1842 1843 public int getPriority() 1844 { 1845 return Integer.MAX_VALUE; 1846 } 1847 1848 public boolean supports(Event event) 1849 { 1850 String eventId = event.getId(); 1851 return org.ametys.core.ObservationConstants.EVENT_USER_DELETED.equals(eventId) 1852 || org.ametys.core.ObservationConstants.EVENT_GROUP_DELETED.equals(eventId); 1853 } 1854 1855 public void observe(Event event, Map<String, Object> transientVars) throws Exception 1856 { 1857 // handle manually the removal of member nodes. 1858 // All method providing access to project member needs to resolve the User or Group 1859 String query; 1860 switch (event.getId()) 1861 { 1862 case org.ametys.core.ObservationConstants.EVENT_USER_DELETED: 1863 UserIdentity userIdentity = (UserIdentity) event.getArguments().get(org.ametys.core.ObservationConstants.ARGS_USER); 1864 query = "//element(*, " + JCRProjectMemberFactory.MEMBER_NODETYPE + ")[" 1865 + new UserExpression(JCRProjectMember.ATTRIBUTE_USER, Operator.EQ, userIdentity).build() + "]"; 1866 break; 1867 case org.ametys.core.ObservationConstants.EVENT_GROUP_DELETED: 1868 GroupIdentity groupIdentity = (GroupIdentity) event.getArguments().get(org.ametys.core.ObservationConstants.ARGS_GROUP); 1869 query = "//element(*, " + JCRProjectMemberFactory.MEMBER_NODETYPE + ")[" 1870 + JCRProjectMember.ATTRIBUTE_GROUP + "/" + JCRProjectMember.ATTRIBUTE_GROUP_ID + "=" + groupIdentity.getId() 1871 + " and " + JCRProjectMember.ATTRIBUTE_GROUP + "/" + JCRProjectMember.ATTRIBUTE_GROUP_DIRECTORY + "=" + groupIdentity.getDirectoryId() 1872 + "]"; 1873 break; 1874 default: 1875 throw new IllegalStateException("Event id '" + event.getId() + "' is not supported"); 1876 } 1877 1878 try (AmetysObjectIterable<JCRProjectMember> members = _resolver.query(query)) 1879 { 1880 for (JCRProjectMember member: members) 1881 { 1882 AmetysObject parent = member.getParent().getParent(); 1883 if (parent instanceof Project project) 1884 { 1885 _removeMember(member, project); 1886 } 1887 } 1888 } 1889 } 1890}