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