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