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();
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        Map<String, String> newProfiles = _getDefaultProfilesByModule();
549        
550        return _setProjectMemberData(projectName, identity, type, newProfiles, null, true);
551    }
552    
553    /**
554     * Get a map of each available module, with the default profile
555     * @return A map with moduleId : profileId
556     */
557    protected Map<String, String> _getDefaultProfilesByModule()
558    {
559        Map<String, String> newProfiles = new HashMap<>();
560        
561        String defaultProfile = StringUtils.defaultString(Config.getInstance().getValue("workspaces.profile.default"));
562        for (String moduleId : _moduleManagerEP.getExtensionsIds())
563        {
564            newProfiles.put(moduleId, defaultProfile);
565        }
566        
567        return newProfiles;
568    }
569    
570    /**
571     * Set the user data in the project
572     * @param projectName The project name
573     * @param identity The user or group identity.
574     * @param type The type of the identity. Can be "user" or "group"
575     * @param newProfiles The profiles to affect, mapped by module
576     * @param role The user role inside the project
577     * @return The result
578     */
579    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
580    public Map<String, Object> setProjectMemberData(String projectName, String identity, String type, Map<String, String> newProfiles, String role)
581    {
582        return _setProjectMemberData(projectName, identity, type, newProfiles, role, false);
583    }
584    
585    /**
586     * Set the user data in the project
587     * @param projectName The project name
588     * @param identity The user or group identity.
589     * @param type The type of the identity. Can be "user" or "group"
590     * @param newProfiles The profiles to affect, mapped by module
591     * @param role The user role inside the project
592     * @param isNewUser <code>true</code> if the user is just added
593     * @return The result
594     */
595    protected Map<String, Object> _setProjectMemberData(String projectName, String identity, String type, Map<String, String> newProfiles, String role, boolean isNewUser)
596    {
597        Map<String, Object> result = new HashMap<>();
598        Project project = _projectManager.getProject(projectName);
599        
600        if (isNewUser && !_projectRightHelper.canAddMember(project) || !isNewUser && !_projectRightHelper.canEditMember(project))
601        {
602            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to set member rights without convenient right [" + projectName + ", " + identity + "]");
603        }
604        
605        boolean isTypeUser = JCRProjectMember.MemberType.USER.name().equals(type.toUpperCase());
606        boolean isTypeGroup = JCRProjectMember.MemberType.GROUP.name().equals(type.toUpperCase());
607        UserIdentity user = Optional.ofNullable(identity)
608                                    .filter(id -> id != null && isTypeUser)
609                                    .map(UserIdentity::stringToUserIdentity)
610                                    .orElse(null);
611        GroupIdentity group = Optional.ofNullable(identity)
612                                      .filter(id -> id != null && isTypeGroup)
613                                      .map(GroupIdentity::stringToGroupIdentity)
614                                      .orElse(null);
615        
616        if (group == null && user == null)
617        {
618            result.put("success", false);
619            result.put("message", isTypeGroup ? "unknown-group" : "unknown-user");
620            return result;
621        }
622        if (isNewUser && isTypeUser && _getProjectMember(project, user) != null)
623        {
624            result.put("success", false);
625            result.put("message", "existing-user");
626            result.put("existing-user", _userHelper.user2json(user, true));
627            
628            return result;
629        }
630        
631        JCRProjectMember projectMember  = isTypeUser ? addOrUpdateProjectMember(project, user, newProfiles) : addOrUpdateProjectMember(project, group, newProfiles);
632        if (projectMember != null)
633        {
634            Request request = ContextHelper.getRequest(_context);
635            String lang = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SITEMAP_NAME);
636            
637            ProjectMember member = isTypeUser ? new ProjectMember(_userManager.getUser(user), projectMember.getRole(), false) : new ProjectMember(_groupManager.getGroup(group));
638            result.put("member", _member2Json(member, lang));
639        }
640        
641        result.put("success", projectMember != null);
642        return result;
643    }
644    
645    /**
646     * Add a user to a project with open inscriptions, using the default values
647     * @param project The project
648     * @param user The user
649     * @return the added member in case of success, null otherwise
650     */
651    public JCRProjectMember addProjectMember(Project project, UserIdentity user)
652    {
653        InscriptionStatus inscriptionStatus = project.getInscriptionStatus();
654        if (!inscriptionStatus.equals(InscriptionStatus.OPEN))
655        {
656            return null;
657        }
658        
659        return addOrUpdateProjectMember(project, user, Map.of());
660    }
661    
662    /**
663     * Add a user to a project, using the provided profile values
664     * @param project The project
665     * @param user The user
666     * @param allowedProfiles the profile values
667     * @return the added member in case of success, null otherwise
668     */
669    public JCRProjectMember addOrUpdateProjectMember(Project project, UserIdentity user, Map<String, String> allowedProfiles)
670    {
671        return addOrUpdateProjectMember(project, user, allowedProfiles, _currentUserProvider.getUser());
672    }
673    /**
674     * Add a user to a project, using the provided profile values
675     * @param project The project
676     * @param user The user
677     * @param allowedProfiles the profile values
678     * @param issuer identity of the user that approved the member
679     * @return the added member in case of success, null otherwise
680     */
681    public JCRProjectMember addOrUpdateProjectMember(Project project, UserIdentity user, Map<String, String> allowedProfiles, UserIdentity issuer)
682    {
683        if (user == null)
684        {
685            return null;
686        }
687        
688        JCRProjectMember projectMember = _getOrCreateJCRProjectMember(project, user);
689        _setMemberProfiles(allowedProfiles, projectMember, project);
690        _saveAndNotifyProjectMemberUpdate(project, projectMember, UserIdentity.userIdentityToString(user), issuer);
691        return projectMember;
692    }
693    
694    /**
695     * Add a group to a project, using the provided profile values
696     * @param project The project
697     * @param group The group
698     * @param allowedProfiles the profile values
699     * @return the added member in case of success, null otherwise
700     */
701    public JCRProjectMember addOrUpdateProjectMember(Project project, GroupIdentity group, Map<String, String> allowedProfiles)
702    {
703        if (group == null)
704        {
705            return null;
706        }
707        
708        JCRProjectMember projectMember = _getOrCreateJCRProjectMember(project, group);
709        _setMemberProfiles(allowedProfiles, projectMember, project);
710        _saveAndNotifyProjectMemberUpdate(project, projectMember, GroupIdentity.groupIdentityToString(group), _currentUserProvider.getUser());
711        return projectMember;
712    }
713
714    private void _saveAndNotifyProjectMemberUpdate(Project project, JCRProjectMember projectMember, String userIdentityString, UserIdentity issuer)
715    {
716        project.saveChanges();
717        
718        _getCache().invalidate(ProjectMemberCacheKey.of(project.getId(), null));
719        
720        // Notify listeners
721        Map<String, Object> eventParams = new HashMap<>();
722        eventParams.put(ObservationConstants.ARGS_MEMBER, projectMember);
723        eventParams.put(ObservationConstants.ARGS_MEMBER_ID, projectMember.getId());
724        eventParams.put(ObservationConstants.ARGS_PROJECT, project);
725        eventParams.put(ObservationConstants.ARGS_PROJECT_ID, project.getId());
726        eventParams.put(ObservationConstants.ARGS_MEMBER_IDENTITY, userIdentityString);
727        eventParams.put(ObservationConstants.ARGS_MEMBER_IDENTITY_TYPE, projectMember.getType());
728        
729        _observationManager.notify(new Event(ObservationConstants.EVENT_MEMBER_ADDED, issuer, eventParams));
730    }
731
732    /**
733     * Set the profiles for a member
734     * @param newProfiles The allowed profile by module
735     * @param projectMember The member
736     * @param project The project
737     */
738    private void _setMemberProfiles(Map<String, String> newProfiles, JCRProjectMember projectMember, Project project)
739    {
740        String defaultProfile = project.getDefaultProfile();
741        Set<String> defaultProfiles;
742        if (StringUtils.isEmpty(defaultProfile))
743        {
744            defaultProfiles = Set.of();
745        }
746        else
747        {
748            defaultProfiles = Set.of(defaultProfile);
749        }
750        
751        for (WorkspaceModule module : _moduleManagerEP.getModules())
752        {
753            Set<String> moduleProfiles;
754            if (newProfiles.containsKey(module.getId()))
755            {
756                String profile = newProfiles.get(module.getId());
757                moduleProfiles = StringUtils.isEmpty(profile) ? Set.of() : Set.of(profile);
758            }
759            else
760            {
761                moduleProfiles = defaultProfiles;
762            }
763            setProfileOnModule(projectMember, project, module, moduleProfiles);
764        }
765    }
766    
767    /**
768     * Affect profiles for a member on a given module
769     * @param member The member
770     * @param project The project
771     * @param module The module
772     * @param allowedProfiles The allowed profiles for the module
773     */
774    public void setProfileOnModule(JCRProjectMember member, Project project, WorkspaceModule module, Set<String> allowedProfiles)
775    {
776        if (module != null && _projectManager.isModuleActivated(project, module.getId()))
777        {
778            AmetysObject moduleObject = module.getModuleRoot(project, false);
779            _setMemberProfiles(member, allowedProfiles, moduleObject);
780        }
781    }
782    
783    private Set<String> _getAllowedProfile(JCRProjectMember member, AmetysObject object)
784    {
785        if (MemberType.GROUP == member.getType())
786        {
787            Map<GroupIdentity, Map<UserOrGroup, Set<String>>> profilesForGroups = _profileAssignmentStorageExtensionPoint.getProfilesForGroups(object, Set.of(member.getGroup()));
788            return Optional.ofNullable(profilesForGroups.get(member.getGroup())).map(a -> a.get(UserOrGroup.ALLOWED)).orElse(Set.of());
789        }
790        else
791        {
792            Map<UserIdentity, Map<UserOrGroup, Set<String>>> profilesForUsers = _profileAssignmentStorageExtensionPoint.getProfilesForUsers(object, member.getUser());
793            return Optional.ofNullable(profilesForUsers.get(member.getUser())).map(a -> a.get(UserOrGroup.ALLOWED)).orElse(Set.of());
794        }
795    }
796
797    private void _setMemberProfiles(JCRProjectMember member, Set<String> allowedProfiles, AmetysObject object)
798    {
799        Set<String> currentAllowedProfiles = _getAllowedProfile(member, object);
800        
801        Collection<String> profilesToRemove  = CollectionUtils.removeAll(currentAllowedProfiles, allowedProfiles);
802        
803        Collection<String> profilesToAdd  = CollectionUtils.removeAll(allowedProfiles, currentAllowedProfiles);
804        
805        for (String profileId : profilesToRemove)
806        {
807            _removeProfile(member, profileId, object);
808        }
809        
810        for (String profileId : profilesToAdd)
811        {
812            _addProfile(member, profileId, object);
813        }
814        
815        Collection<String> updatedProfiles = CollectionUtils.union(profilesToAdd, profilesToRemove);
816        
817        if (updatedProfiles.size() > 0)
818        {
819            _notifyAclUpdated(_currentUserProvider.getUser(), object, updatedProfiles);
820        }
821    }
822    
823    private void _removeProfile(JCRProjectMember member, String profileId, AmetysObject aclObject)
824    {
825        if (MemberType.GROUP == member.getType())
826        {
827            _profileAssignmentStorageExtensionPoint.removeAllowedProfileFromGroup(member.getGroup(), profileId, aclObject);
828        }
829        else
830        {
831            _profileAssignmentStorageExtensionPoint.removeAllowedProfileFromUser(member.getUser(), profileId, aclObject);
832        }
833    }
834    
835    private void _addProfile(JCRProjectMember member, String profileId, AmetysObject aclObject)
836    {
837        if (MemberType.GROUP == member.getType())
838        {
839            _profileAssignmentStorageExtensionPoint.allowProfileToGroup(member.getGroup(), profileId, aclObject);
840        }
841        else
842        {
843            _profileAssignmentStorageExtensionPoint.allowProfileToUser(member.getUser(), profileId, aclObject);
844        }
845    }
846
847    private void _removeMemberProfiles(JCRProjectMember member, AmetysObject object)
848    {
849        Set<String> currentAllowedProfiles = _getAllowedProfile(member, object);
850        
851        for (String allowedProfile : currentAllowedProfiles)
852        {
853            _removeProfile(member, allowedProfile, object);
854        }
855        
856        if (currentAllowedProfiles.size() > 0)
857        {
858            ((ModifiableAmetysObject) object).saveChanges();
859            
860            Map<String, Object> eventParams = new HashMap<>();
861            eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT, object);
862            eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT_IDENTIFIER, object.getId());
863            eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_PROFILES, currentAllowedProfiles);
864            eventParams.put(org.ametys.cms.ObservationConstants.ARGS_ACL_SOLR_CACHE_UNINFLUENTIAL, true);
865            
866            _observationManager.notify(new Event(org.ametys.core.ObservationConstants.EVENT_ACL_UPDATED, _currentUserProvider.getUser(), eventParams));
867        }
868    }
869    
870    /**
871     * Get the members of a project, sorted by managers, non empty role and name
872     * @param projectName the project's name
873     * @param lang the language to get user content
874     * @return the members of project
875     * @throws IllegalAccessException if an error occurred
876     * @throws AmetysRepositoryException if an error occurred
877     */
878    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
879    public Map<String, Object> getProjectMembers(String projectName, String lang) throws IllegalAccessException, AmetysRepositoryException
880    {
881        return getProjectMembers(projectName, lang, false);
882    }
883    
884    /**
885     * Get the members of a project, sorted by managers, non empty role and name
886     * @param projectName the project's name
887     * @param lang the language to get user content
888     * @param expandGroup true if groups are expanded
889     * @return the members of project
890     * @throws AmetysRepositoryException if an error occurred
891     */
892    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
893    public Map<String, Object> getProjectMembers(String projectName, String lang, boolean expandGroup) throws AmetysRepositoryException
894    {
895        Map<String, Object> result = new HashMap<>();
896        
897        Project project = _projectManager.getProject(projectName);
898        if (!_projectRightHelper.hasReadAccess(project))
899        {
900            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to do read operation without convenient right");
901        }
902        
903        List<Map<String, Object>> membersData = new ArrayList<>();
904        
905        Set<ProjectMember> projectMembers = getProjectMembers(project, expandGroup);
906        
907        for (ProjectMember projectMember : projectMembers)
908        {
909            membersData.add(_member2Json(projectMember, lang));
910        }
911        
912        result.put("members", membersData);
913        result.put("success", true);
914        
915        return result;
916    }
917    
918    private Map<String, Object> _member2Json(ProjectMember projectMember, String lang)
919    {
920        Project project = _workspaceHelper.getProjectFromRequest();
921        Map<String, Object> memberData = new HashMap<>();
922        
923        memberData.put("type", projectMember.getType().name().toLowerCase());
924        memberData.put("title", projectMember.getTitle());
925        memberData.put("sortabletitle", projectMember.getSortableTitle());
926        memberData.put("manager", projectMember.isManager());
927        
928        String role = projectMember.getRole();
929        if (StringUtils.isNotEmpty(role))
930        {
931            memberData.put("role", role);
932        }
933        
934        User user = projectMember.getUser();
935        if (user != null)
936        {
937            memberData.put("id", UserIdentity.userIdentityToString(user.getIdentity()));
938            memberData.putAll(_userHelper.user2json(user));
939
940            Content userContent = getUserContent(lang, user);
941            
942            if (userContent != null)
943            {
944                if (userContent.hasValue("function"))
945                {
946                    memberData.put("function", userContent.getValue("function"));
947                }
948
949                if (userContent.hasValue("organisation-accronym"))
950                {
951                    memberData.put("organisationAcronym", userContent.getValue("organisation-accronym"));
952                }
953                String usersDirectorySiteName = _projectManager.getUsersDirectorySiteName();
954                String[] contentTypes = userContent.getTypes();
955                for (String contentType : contentTypes)
956                {
957                    // Try to see if a user page exists for this content type
958                    UserPage userPage = _userDirectoryPageResolver.getUserPage(userContent, usersDirectorySiteName, lang, contentType);
959                    if (userPage != null)
960                    {
961                        memberData.put("link", _uriResolver.getResolverForType("page").resolve(userPage.getId(), false, true, false));
962                    }
963                }
964                    
965            }
966            else if (getLogger().isDebugEnabled())
967            {
968                getLogger().debug("User content not found for user : " + user);
969            }
970
971            memberData.put("hasReadAccessOnTaskModule", _projectRightsHelper.hasReadAccessOnModule(project, TasksWorkspaceModule.TASK_MODULE_ID, user.getIdentity()));
972            memberData.put("hasReadAccessOnDocumentModule", _projectRightsHelper.hasReadAccessOnModule(project, DocumentWorkspaceModule.DOCUMENT_MODULE_ID, user.getIdentity()));
973            memberData.put("hasReadAccessOnForumModule", _projectRightsHelper.hasReadAccessOnModule(project, ForumWorkspaceModule.FORUM_MODULE_ID, user.getIdentity()));
974            
975        }
976        
977        Group group = projectMember.getGroup();
978        if (group != null)
979        {
980            memberData.putAll(group2Json(group));
981        }
982        
983        return memberData;
984    }
985
986    /**
987     * Get user content
988     * @param lang the lang
989     * @param user the user
990     * @return the user content or null if no exist
991     */
992    public Content getUserContent(String lang, User user)
993    {
994        Content userContent = _userDirectoryHelper.getUserContent(user.getIdentity(), lang);
995
996        if (userContent == null)
997        {
998            userContent = _userDirectoryHelper.getUserContent(user.getIdentity(), "en");
999        }
1000        
1001        if (userContent == null)
1002        {
1003            Map<String, Language> availableLanguages = _languagesManager.getAvailableLanguages();
1004            for (Language availableLanguage : availableLanguages.values())
1005            {
1006                if (userContent == null)
1007                {
1008                    userContent = _userDirectoryHelper.getUserContent(user.getIdentity(), availableLanguage.getCode());
1009                }
1010            }
1011        }
1012        return userContent;
1013    }
1014
1015    /**
1016     * Get the members of a project, sorted by managers, non empty role and name
1017     * @param project the project
1018     * @param expandGroup true to expand the user of a group
1019     * @return the members of project
1020     * @throws AmetysRepositoryException if an error occurred
1021     */
1022    public Set<ProjectMember> getProjectMembers(Project project, boolean expandGroup) throws AmetysRepositoryException
1023    {
1024        return getProjectMembers(project, expandGroup, Set.of());
1025    }
1026
1027    /**
1028     * Get the members of a project, sorted by managers, non empty role and name
1029     * @param project the project
1030     * @param expandGroup true to expand the user of a group
1031     * @param defaultSet default set to return when project has no site
1032     * @return the members of project
1033     * @throws AmetysRepositoryException if an error occurred
1034     */
1035    public Set<ProjectMember> getProjectMembers(Project project, boolean expandGroup, Set<ProjectMember> defaultSet) throws AmetysRepositoryException
1036    {
1037        Cache<ProjectMemberCacheKey, Set<ProjectMember>> cache = _getCache();
1038        if (project == null)
1039        {
1040            return defaultSet;
1041        }
1042        ProjectMemberCacheKey cacheKey = ProjectMemberCacheKey.of(project.getId(), expandGroup);
1043        if (cache.hasKey(cacheKey))
1044        {
1045            Set<ProjectMember> projectMembers = cache.get(cacheKey);
1046            return projectMembers != null ? projectMembers : defaultSet;
1047        }
1048        else
1049        {
1050            Set<ProjectMember> projectMembers = _getProjectMembers(project, expandGroup);
1051            cache.put(cacheKey, projectMembers);
1052            return projectMembers != null ? projectMembers : defaultSet;
1053        }
1054    }
1055
1056    private Set<ProjectMember> _getProjectMembers(Project project, boolean expandGroup)
1057    {
1058        Comparator<ProjectMember> managerComparator = Comparator.comparing(m -> m.isManager() ? 0 : 1);
1059        Comparator<ProjectMember> roleComparator = Comparator.comparing(m -> StringUtils.isNotBlank(m.getRole()) ? 0 : 1);
1060        // Use sortable title for sort, and concatenate it with hash code of user, so that homonyms do not appear equals
1061        Comparator<ProjectMember> nameComparator = (m1, m2) -> (m1.getSortableTitle() + m1.hashCode()).compareToIgnoreCase(m2.getSortableTitle() + m2.hashCode());
1062        
1063        Set<ProjectMember> members = new TreeSet<>(managerComparator.thenComparing(roleComparator).thenComparing(nameComparator));
1064        
1065        Map<JCRProjectMember, Object> jcrMembers = getJCRProjectMembers(project);
1066        List<UserIdentity> managers = Arrays.asList(project.getManagers());
1067        
1068        Site site = project.getSite();
1069        if (site == null)
1070        {
1071            getLogger().error("Can not compute members in the project " + project.getName() + " because it can not be linked to an existing site");
1072            return null;
1073        }
1074        String projectSiteName = site.getName();
1075
1076        Set<String> projectGroupDirectory = _groupDirectoryContextHelper.getGroupDirectoriesOnContext("/sites/" + projectSiteName);
1077        
1078        for (Entry<JCRProjectMember, Object> entry : jcrMembers.entrySet())
1079        {
1080            JCRProjectMember jcrMember = entry.getKey();
1081            if (MemberType.USER == jcrMember.getType())
1082            {
1083                User user = (User) entry.getValue();
1084                boolean isManager = managers.contains(jcrMember.getUser());
1085                
1086                ProjectMember projectMember = new ProjectMember(user, jcrMember.getRole(), isManager);
1087                if (!members.add(projectMember) && _projectManager.isUserInProjectPopulations(project, user.getIdentity()))
1088                {
1089                    //if set already contains the user, override it (users always take over users' group)
1090                    members.remove(projectMember); // remove the one in  the set
1091                    members.add(projectMember); // add the new one
1092                }
1093            }
1094            else if (MemberType.GROUP == jcrMember.getType())
1095            {
1096                Group group = (Group) entry.getValue();
1097                if (projectGroupDirectory.contains(group.getGroupDirectory().getId()))
1098                {
1099                    if (expandGroup)
1100                    {
1101                        for (UserIdentity userIdentity : group.getUsers())
1102                        {
1103                            User user = _userManager.getUser(userIdentity);
1104                            if (user != null && _projectManager.isUserInProjectPopulations(project, userIdentity))
1105                            {
1106                                ProjectMember projectMember = new ProjectMember(user, null, false);
1107                                members.add(projectMember); // add if does not exist yet
1108                            }
1109                        }
1110                    }
1111                    else
1112                    {
1113                        // Add the member as group
1114                        members.add(new ProjectMember(group));
1115                    }
1116                }
1117            }
1118        }
1119        return members;
1120    }
1121    
1122    /**
1123     * Retrieves the rights for the current user in the project
1124     * @param projectName The project Name
1125     * @return The project
1126     */
1127    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
1128    public Map<String, Object> getMemberModuleRights(String projectName)
1129    {
1130
1131        if (!_projectRightsHelper.hasReadAccessOnModule(MembersWorkspaceModule.MEMBERS_MODULE_ID))
1132        {
1133            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to do read operation without convenient right");
1134        }
1135        
1136        Map<String, Object> results = new HashMap<>();
1137        Map<String, Object> rights = new HashMap<>();
1138        
1139        Project project = _projectManager.getProject(projectName);
1140        if (project == null)
1141        {
1142            results.put("message", "unknown-project");
1143            results.put("success", false);
1144        }
1145        else
1146        {
1147            rights.put("view", _projectRightHelper.canViewMembers(project));
1148            rights.put("add", _projectRightHelper.canAddMember(project));
1149            rights.put("edit", _projectRightHelper.canEditMember(project));
1150            rights.put("delete", _projectRightHelper.canRemoveMember(project));
1151            results.put("rights", rights);
1152            results.put("success", true);
1153        }
1154        
1155        return results;
1156    }
1157    
1158    /**
1159     * Get the list of users of the project
1160     * @param project The project
1161     * @return The list of users
1162     */
1163    public Map<JCRProjectMember, Object> getJCRProjectMembers(Project project)
1164    {
1165        Map<JCRProjectMember, Object> projectMembers = new HashMap<>();
1166        
1167        if (project != null)
1168        {
1169            ModifiableTraversableAmetysObject membersNode = _getProjectMembersNode(project);
1170            
1171            for (AmetysObject memberNode : membersNode.getChildren())
1172            {
1173                if (memberNode instanceof JCRProjectMember)
1174                {
1175                    JCRProjectMember jCRProjectMember = (JCRProjectMember) memberNode;
1176                    if (jCRProjectMember.getType() == MemberType.USER)
1177                    {
1178                        UserIdentity userIdentity = jCRProjectMember.getUser();
1179                        User user = _userManager.getUser(userIdentity);
1180                        if (user != null)
1181                        {
1182                            projectMembers.put((JCRProjectMember) memberNode, user);
1183                        }
1184                    }
1185                    else
1186                    {
1187                        GroupIdentity groupIdentity = jCRProjectMember.getGroup();
1188                        Group group = _groupManager.getGroup(groupIdentity);
1189                        if (group != null)
1190                        {
1191                            projectMembers.put((JCRProjectMember) memberNode, group);
1192                        }
1193                    }
1194                }
1195            }
1196        }
1197        
1198        return projectMembers;
1199    }
1200    
1201    /**
1202     * Test if an user is a member of a project (directly or by a group)
1203     * @param project The project
1204     * @param userIdentity The user identity
1205     * @return True if this user is a member of this project
1206     */
1207    public boolean isProjectMember(Project project, UserIdentity userIdentity)
1208    {
1209        return getProjectMember(project, userIdentity) != null;
1210    }
1211    
1212    /**
1213     * Retrieve the member of a project corresponding to the user identity
1214     * @param project The project
1215     * @param userIdentity The user identity
1216     * @return The member of this project, which can be of type "user" or "group", or null if the user is not in the project
1217     */
1218    public ProjectMember getProjectMember(Project project, UserIdentity userIdentity)
1219    {
1220        return getProjectMember(project, userIdentity, null);
1221    }
1222    
1223    /**
1224     * Retrieve the member of a project corresponding to the user identity
1225     * @param project The project
1226     * @param userIdentity The user identity
1227     * @param userGroups The user groups. If null the user's groups will be expanded.
1228     * @return The member of this project, which can be of type "user" or "group", or null if the user is not in the project
1229     */
1230    public ProjectMember getProjectMember(Project project, UserIdentity userIdentity, Set<GroupIdentity> userGroups)
1231    {
1232        if (userIdentity == null)
1233        {
1234            return null;
1235        }
1236                
1237        Set<ProjectMember> members = getProjectMembers(project, true);
1238        
1239        ProjectMember projectMember = members.stream()
1240                .filter(member -> MemberType.USER == member.getType())
1241                .filter(member -> userIdentity.equals(member.getUser().getIdentity()))
1242                .findFirst()
1243                .orElse(null);
1244        
1245        if (projectMember != null)
1246        {
1247            return projectMember;
1248        }
1249        
1250        Set<GroupIdentity> groups = userGroups == null ? _groupManager.getUserGroups(userIdentity) : userGroups; // get user's groups
1251
1252        if (!groups.isEmpty())
1253        {
1254            return members.stream()
1255                    .filter(member -> MemberType.GROUP == member.getType())
1256                    .filter(member -> groups.contains(member.getGroup().getIdentity()))
1257                    .findFirst()
1258                    .orElse(null);
1259        }
1260        
1261        return null;
1262    }
1263    
1264    /**
1265     * Set the manager of a project
1266     * @param projectName The project name
1267     * @param profileId The profile id to affect
1268     * @param managers The managers' user identity
1269     */
1270    public void setProjectManager(String projectName, String profileId, List<UserIdentity> managers)
1271    {
1272        Project project = _projectManager.getProject(projectName);
1273        if (project == null)
1274        {
1275            return;
1276        }
1277        
1278        project.setManagers(managers.toArray(new UserIdentity[managers.size()]));
1279        
1280        for (UserIdentity userIdentity : managers)
1281        {
1282            JCRProjectMember member = _getOrCreateJCRProjectMember(project, userIdentity);
1283            
1284            Set<String> allowedProfiles = Set.of(profileId);
1285            for (WorkspaceModule module : _projectManager.getModules(project))
1286            {
1287                setProfileOnModule(member, project, module, allowedProfiles);
1288            }
1289        }
1290        
1291        project.saveChanges();
1292        
1293        _getCache().invalidate(ProjectMemberCacheKey.of(project.getId(), null));
1294        
1295        // Clear rights manager cache (if I remove my own rights)
1296        _rightManager.clearCaches();
1297        
1298//        Request request = ContextHelper.getRequest(_context);
1299//        if (request != null)
1300//        {
1301//            request.removeAttribute(RightManager.CACHE_REQUEST_ATTRIBUTE_NAME);
1302//        }
1303    }
1304    
1305    private void _notifyAclUpdated(UserIdentity userIdentity, AmetysObject aclContext, Collection<String> aclProfiles)
1306    {
1307        Map<String, Object> eventParams = new HashMap<>();
1308        eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT, aclContext);
1309        eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT_IDENTIFIER, aclContext.getId());
1310        eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_PROFILES, aclProfiles);
1311        eventParams.put(org.ametys.cms.ObservationConstants.ARGS_ACL_SOLR_CACHE_UNINFLUENTIAL, true);
1312        
1313        _observationManager.notify(new Event(org.ametys.core.ObservationConstants.EVENT_ACL_UPDATED, userIdentity, eventParams));
1314    }
1315
1316    /**
1317     * Retrieve or create a user in a project
1318     * @param project The project
1319     * @param userIdentity the user
1320     * @return The user
1321     */
1322    private JCRProjectMember _getOrCreateJCRProjectMember(Project project, UserIdentity userIdentity)
1323    {
1324        Predicate<? super AmetysObject> findMemberPredicate = memberNode -> MemberType.USER == ((JCRProjectMember) memberNode).getType()
1325                && userIdentity.equals(((JCRProjectMember) memberNode).getUser());
1326        JCRProjectMember projectMember = _getOrCreateJCRProjectMember(project, findMemberPredicate);
1327        
1328        if (projectMember.needsSave())
1329        {
1330            projectMember.setUser(userIdentity);
1331            projectMember.setType(MemberType.USER);
1332        }
1333        
1334        return projectMember;
1335    }
1336
1337    /**
1338     * Retrieve or create a group as a member in a project
1339     * @param project The project
1340     * @param groupIdentity the group
1341     * @return The user
1342     */
1343    private JCRProjectMember _getOrCreateJCRProjectMember(Project project, GroupIdentity groupIdentity)
1344    {
1345        Predicate<? super AmetysObject> findMemberPredicate = memberNode -> MemberType.GROUP == ((JCRProjectMember) memberNode).getType()
1346                && groupIdentity.equals(((JCRProjectMember) memberNode).getGroup());
1347        JCRProjectMember projectMember = _getOrCreateJCRProjectMember(project, findMemberPredicate);
1348        
1349        if (projectMember.needsSave())
1350        {
1351            projectMember.setGroup(groupIdentity);
1352            projectMember.setType(MemberType.GROUP);
1353        }
1354        
1355        return projectMember;
1356    }
1357    
1358    /**
1359     * Retrieve or create a member in a project
1360     * @param project The project
1361     * @param findMemberPredicate The predicate to find the member node
1362     * @return The member node. A new node is created if the member node was not found
1363     */
1364    protected JCRProjectMember _getOrCreateJCRProjectMember(Project project, Predicate<? super AmetysObject> findMemberPredicate)
1365    {
1366        ModifiableTraversableAmetysObject membersNode = _getProjectMembersNode(project);
1367        
1368        Optional<AmetysObject> member = _getProjectMembersNode(project).getChildren()
1369                                                                       .stream()
1370                                                                       .filter(memberNode -> memberNode instanceof JCRProjectMember)
1371                                                                       .filter(findMemberPredicate)
1372                                                                       .findFirst();
1373        if (member.isPresent())
1374        {
1375            return (JCRProjectMember) member.get();
1376        }
1377        
1378        String baseName = "member";
1379        String name = baseName;
1380        int index = 1;
1381        while (membersNode.hasChild(name))
1382        {
1383            index++;
1384            name = baseName + "-" + index;
1385        }
1386        
1387        JCRProjectMember jcrProjectMember = membersNode.createChild(name, __PROJECT_MEMBER_NODE_TYPE);
1388        
1389        // we invalidate the cache has we had to create a new user
1390        _getCache().invalidate(ProjectMemberCacheKey.of(project.getId(), null));
1391        
1392        return jcrProjectMember;
1393    }
1394
1395    
1396    /**
1397     * Remove a user from a project
1398     * @param projectName The project name
1399     * @param identity The identity of the user or group, who must be a member of the project
1400     * @param type The type of the member, user or group
1401     * @return The error code, if an error occurred
1402     */
1403    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
1404    public Map<String, Object> removeMember(String projectName, String identity, String type)
1405    {
1406        Project project = _projectManager.getProject(projectName);
1407        if (!_projectRightHelper.canRemoveMember(project))
1408        {
1409            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to remove member without convenient right [" + projectName + ", " + identity + "]");
1410        }
1411        return _removeMember(projectName, identity, type, true);
1412    }
1413    
1414    private Map<String, Object> _removeMember(String projectName, String identity, String type, boolean checkCurrentUser)
1415    {
1416        Map<String, Object> result = new HashMap<>();
1417        
1418        MemberType memberType = MemberType.valueOf(type.toUpperCase());
1419        boolean isTypeUser = MemberType.USER == memberType;
1420        boolean isTypeGroup = MemberType.GROUP == memberType;
1421        UserIdentity user = Optional.ofNullable(identity)
1422                                    .filter(id -> id != null && isTypeUser)
1423                                    .map(UserIdentity::stringToUserIdentity)
1424                                    .orElse(null);
1425        GroupIdentity group = Optional.ofNullable(identity)
1426                                      .filter(id -> id != null && isTypeGroup)
1427                                      .map(GroupIdentity::stringToGroupIdentity)
1428                                      .orElse(null);
1429        
1430        if (isTypeGroup && group == null
1431            || isTypeUser && user == null)
1432        {
1433            result.put("success", false);
1434            result.put("message", isTypeGroup ? "unknown-group" : "unknown-user");
1435            return result;
1436        }
1437        
1438        Project project = _projectManager.getProject(projectName);
1439        if (project == null)
1440        {
1441            result.put("success", false);
1442            result.put("message", "unknown-project");
1443            return result;
1444        }
1445        
1446        if (checkCurrentUser && _isCurrentUser(isTypeUser, user))
1447        {
1448            result.put("success", false);
1449            result.put("message", "current-user");
1450            return result;
1451        }
1452        
1453        // If there is only one manager, do not remove him from the project's members
1454        if (isTypeUser && isOnlyManager(project, user))
1455        {
1456            result.put("success", false);
1457            result.put("message", "only-manager");
1458            return result;
1459        }
1460        
1461        JCRProjectMember projectMember = null;
1462        if (isTypeUser)
1463        {
1464            projectMember = _getProjectMember(project, user);
1465        }
1466        else if (isTypeGroup)
1467        {
1468            projectMember = _getProjectMember(project, group);
1469        }
1470        
1471        if (projectMember == null)
1472        {
1473            result.put("success", false);
1474            result.put("message", "unknown-member");
1475            return result;
1476        }
1477        
1478        _removeMember(projectMember, project);
1479        
1480        result.put("success", true);
1481        return result;
1482    }
1483
1484    private void _removeMember(JCRProjectMember projectMember, Project project)
1485    {
1486        _removeManager(project, projectMember);
1487        _removeMemberProfiles(project, projectMember);
1488        MemberType memberType = projectMember.getType();
1489        
1490        Map<String, Object> eventParams = new HashMap<>();
1491        eventParams.put(ObservationConstants.ARGS_MEMBER_IDENTITY, MemberType.USER.equals(memberType)
1492                ? UserIdentity.userIdentityToString(projectMember.getUser())
1493                        : GroupIdentity.groupIdentityToString(projectMember.getGroup()));
1494        eventParams.put(ObservationConstants.ARGS_MEMBER_IDENTITY_TYPE, memberType);
1495        eventParams.put(ObservationConstants.ARGS_PROJECT, project);
1496        eventParams.put(ObservationConstants.ARGS_PROJECT_ID, project.getId());
1497
1498        projectMember.remove();
1499        project.saveChanges();
1500        
1501        _getCache().invalidate(ProjectMemberCacheKey.of(project.getId(), null));
1502        _observationManager.notify(new Event(ObservationConstants.EVENT_MEMBER_DELETED, _currentUserProvider.getUser(), eventParams));
1503    }
1504
1505    private boolean _isCurrentUser(boolean isTypeUser, UserIdentity user)
1506    {
1507        return isTypeUser && _currentUserProvider.getUser().equals(user);
1508    }
1509
1510    /**
1511     * Check if a user is the only manager of a project
1512     * @param project the project
1513     * @param user the user
1514     * @return true if the user is the only manager of the project
1515     */
1516    public boolean isOnlyManager(Project project, UserIdentity user)
1517    {
1518        UserIdentity[] managers = project.getManagers();
1519        return managers.length == 1 && managers[0].equals(user);
1520    }
1521
1522    private JCRProjectMember _getProjectMember(Project project, GroupIdentity group)
1523    {
1524        JCRProjectMember projectMember = null;
1525        ModifiableTraversableAmetysObject membersNode = _getProjectMembersNode(project);
1526        
1527        for (AmetysObject memberNode : membersNode.getChildren())
1528        {
1529            if (memberNode instanceof JCRProjectMember)
1530            {
1531                JCRProjectMember member = (JCRProjectMember) memberNode;
1532                if (MemberType.GROUP == member.getType() && group.equals(member.getGroup()))
1533                {
1534                    projectMember = (JCRProjectMember) memberNode;
1535                }
1536
1537            }
1538        }
1539        return projectMember;
1540    }
1541
1542    private JCRProjectMember _getProjectMember(Project project, UserIdentity user)
1543    {
1544        JCRProjectMember projectMember = null;
1545        ModifiableTraversableAmetysObject membersNode = _getProjectMembersNode(project);
1546        
1547        for (AmetysObject memberNode : membersNode.getChildren())
1548        {
1549            if (memberNode instanceof JCRProjectMember)
1550            {
1551                JCRProjectMember member = (JCRProjectMember) memberNode;
1552                if (MemberType.USER == member.getType() && user.equals(member.getUser()))
1553                {
1554                    projectMember = (JCRProjectMember) memberNode;
1555                }
1556            }
1557        }
1558        return projectMember;
1559    }
1560    
1561    private void _removeManager(Project project, JCRProjectMember projectMember)
1562    {
1563        if (MemberType.USER.equals(projectMember.getType()))
1564        {
1565            UserIdentity user = projectMember.getUser();
1566            UserIdentity[] oldManagers = project.getManagers();
1567            
1568            // Remove the user from the project's managers
1569            UserIdentity[] managers = Arrays.stream(oldManagers)
1570                  .filter(manager -> !manager.equals(user))
1571                  .toArray(UserIdentity[]::new);
1572            
1573            project.setManagers(managers);
1574        }
1575    }
1576
1577    private void _removeMemberProfiles(Project project, JCRProjectMember projectMember)
1578    {
1579        for (WorkspaceModule module : _projectManager.getModules(project))
1580        {
1581            ModifiableResourceCollection moduleRootNode = module.getModuleRoot(project, false);
1582            _removeMemberProfiles(projectMember, moduleRootNode);
1583        }
1584    }
1585    
1586    /**
1587     * Retrieves the users node of the project
1588     * The users node will be created if necessary
1589     * @param project The project
1590     * @return The users node of the project
1591     */
1592    protected ModifiableTraversableAmetysObject _getProjectMembersNode(Project project)
1593    {
1594        if (project == null)
1595        {
1596            throw new AmetysRepositoryException("Error getting the project users node, project is null");
1597        }
1598        
1599        try
1600        {
1601            ModifiableTraversableAmetysObject membersNode;
1602            if (project.hasChild(__PROJECT_MEMBERS_NODE))
1603            {
1604                membersNode = project.getChild(__PROJECT_MEMBERS_NODE);
1605            }
1606            else
1607            {
1608                membersNode = project.createChild(__PROJECT_MEMBERS_NODE, __PROJECT_MEMBERS_NODE_TYPE);
1609            }
1610            
1611            return membersNode;
1612        }
1613        catch (AmetysRepositoryException e)
1614        {
1615            throw new AmetysRepositoryException("Error getting the project users node", e);
1616        }
1617    }
1618    
1619    /**
1620     * Get the JSON representation of a group
1621     * @param group The group
1622     * @return The group
1623     */
1624    protected Map<String, Object> group2Json(Group group)
1625    {
1626        Map<String, Object> infos = new HashMap<>();
1627        infos.put("id", GroupIdentity.groupIdentityToString(group.getIdentity()));
1628        infos.put("groupId", group.getIdentity().getId());
1629        infos.put("label", group.getLabel());
1630        infos.put("sortablename", group.getLabel());
1631        infos.put("groupDirectory", group.getIdentity().getDirectoryId());
1632        return infos;
1633    }
1634
1635    /**
1636     * Count the total of unique users in the project and in the project's group
1637     * @param project The project
1638     * @return The total of members
1639     */
1640    public Long getMembersCount(Project project)
1641    {
1642        Set<ProjectMember> projectMembers = getProjectMembers(project, true);
1643                
1644        return (long) projectMembers.size();
1645    }
1646
1647    /**
1648     * Get the users from a group that are part of the project. They can be filtered with a predicate
1649     * @param group The group
1650     * @param project The project
1651     * @param filteringPredicate The predicate to filter
1652     * @return The list of users
1653     */
1654    public List<User> getGroupUsersFromProject(Group group, Project project, BiPredicate<Project, UserIdentity> filteringPredicate)
1655    {
1656        Set<String> projectPopulations = _populationContextHelper.getUserPopulationsOnContexts(List.of("/sites/" + project.getSite().getName()), false, false);
1657        
1658        return group.getUsers().stream()
1659                .filter(user -> projectPopulations.contains(user.getPopulationId()))
1660                .filter(user -> filteringPredicate.test(project, user))
1661                .map(_userManager::getUser)
1662                .filter(Objects::nonNull)
1663                .collect(Collectors.toList());
1664    }
1665
1666    /**
1667     * Make the current user leave the project
1668     * @param projectName The project name
1669     * @return The error code, if an error occurred
1670     */
1671    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
1672    public Map<String, Object> leaveProject(String projectName)
1673    {
1674        Project project = _projectManager.getProject(projectName);
1675        if (!isProjectMember(project, _currentUserProvider.getUser()))
1676        {
1677            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried perform operation without convenient right");
1678        }
1679        UserIdentity currentUser = _currentUserProvider.getUser();
1680        String identity = UserIdentity.userIdentityToString(currentUser);
1681        
1682        Map<String, Object> result = _removeMember(projectName, identity, MemberType.USER.toString(), false);
1683        
1684        return result;
1685    }
1686    
1687    private Cache<ProjectMemberCacheKey, Set<ProjectMember>> _getCache()
1688    {
1689        return _abstractCacheManager.get(__PROJECT_MEMBER_CACHE);
1690    }
1691    
1692    /**
1693     * This class represents a member of a project. Could be a user or a group
1694     *
1695     */
1696    public static class ProjectMember
1697    {
1698        private String _title;
1699        private String _sortableTitle;
1700        private MemberType _type;
1701        private String _role;
1702        private User _user;
1703        private Group _group;
1704        private boolean _isManager;
1705        
1706        /**
1707         * Create a project member as a group
1708         * @param group the group attached to this member. Cannot be null.
1709         */
1710        public ProjectMember(Group group)
1711        {
1712            _title = group.getLabel();
1713            _sortableTitle = group.getLabel();
1714            _type = MemberType.GROUP;
1715            _role = null;
1716            _isManager = false;
1717            _user = null;
1718            _group = group;
1719        }
1720        
1721        /**
1722         * Create a project member as a group
1723         * @param role the role
1724         * @param isManager true if the member is a manager of the project
1725         * @param user the user attached to this member. Cannot be null.
1726         */
1727        public ProjectMember(User user, String role, boolean isManager)
1728        {
1729            _title = user.getFullName();
1730            _sortableTitle = user.getSortableName();
1731            _type = MemberType.USER;
1732            _role = role;
1733            _isManager = isManager;
1734            _user = user;
1735            _group = null;
1736        }
1737        
1738        /**
1739         * Get the title of the member.
1740         * @return The title of the member
1741         */
1742        public String getTitle()
1743        {
1744            return _title;
1745        }
1746        
1747        /**
1748         * Get the sortable title of the member.
1749         * @return The sortable title of the member
1750         */
1751        public String getSortableTitle()
1752        {
1753            return _sortableTitle;
1754        }
1755
1756        /**
1757         * Get the type of the member. It can be a user or a group
1758         * @return The type of the member
1759         */
1760        public MemberType getType()
1761        {
1762            return _type;
1763        }
1764
1765        /**
1766         * Get the role of the member.
1767         * @return The role of the member
1768         */
1769        public String getRole()
1770        {
1771            return _role;
1772        }
1773
1774        /**
1775         * Test if the member is a manager of the project
1776         * @return True if this user is a manager of the project
1777         */
1778        public boolean isManager()
1779        {
1780            return _isManager;
1781        }
1782        
1783        /**
1784         * Get the user of the member.
1785         * @return The user of the member
1786         */
1787        public User getUser()
1788        {
1789            return _user;
1790        }
1791        
1792        /**
1793         * Get the group of the member.
1794         * @return The group of the member
1795         */
1796        public Group getGroup()
1797        {
1798            return _group;
1799        }
1800        
1801        @Override
1802        public boolean equals(Object obj)
1803        {
1804            if (obj == null || !(obj instanceof ProjectMember))
1805            {
1806                return false;
1807            }
1808            
1809            ProjectMember otherMember = (ProjectMember) obj;
1810            
1811            if (getType() != otherMember.getType())
1812            {
1813                return false;
1814            }
1815            
1816            if (getType() == MemberType.USER)
1817            {
1818                return getUser().equals(otherMember.getUser());
1819            }
1820            else
1821            {
1822                return getGroup().equals(otherMember.getGroup());
1823            }
1824        }
1825        
1826        @Override
1827        public int hashCode()
1828        {
1829            return getType() == MemberType.USER ? getUser().getIdentity().hashCode() : getGroup().getIdentity().hashCode();
1830        }
1831    }
1832    
1833    private static final class ProjectMemberCacheKey extends AbstractCacheKey
1834    {
1835        public ProjectMemberCacheKey(String projectId, Boolean extendGroup)
1836        {
1837            super(projectId, extendGroup);
1838        }
1839        
1840        public static ProjectMemberCacheKey of(String projectId, Boolean withExpandedGroup)
1841        {
1842            return new ProjectMemberCacheKey(projectId, withExpandedGroup);
1843        }
1844    }
1845    
1846    public int getPriority()
1847    {
1848        return Integer.MAX_VALUE;
1849    }
1850    
1851    public boolean supports(Event event)
1852    {
1853        String eventId = event.getId();
1854        return org.ametys.core.ObservationConstants.EVENT_USER_DELETED.equals(eventId)
1855            || org.ametys.core.ObservationConstants.EVENT_GROUP_DELETED.equals(eventId);
1856    }
1857    
1858    public void observe(Event event, Map<String, Object> transientVars) throws Exception
1859    {
1860        // handle manually the removal of member nodes.
1861        // All method providing access to project member needs to resolve the User or Group
1862        String query;
1863        switch (event.getId())
1864        {
1865            case org.ametys.core.ObservationConstants.EVENT_USER_DELETED:
1866                UserIdentity userIdentity = (UserIdentity) event.getArguments().get(org.ametys.core.ObservationConstants.ARGS_USER);
1867                query = "//element(*, " + JCRProjectMemberFactory.MEMBER_NODETYPE + ")["
1868                        + new UserExpression(JCRProjectMember.METADATA_USER, Operator.EQ, userIdentity).build() + "]";
1869                break;
1870            case org.ametys.core.ObservationConstants.EVENT_GROUP_DELETED:
1871                GroupIdentity groupIdentity = (GroupIdentity) event.getArguments().get(org.ametys.core.ObservationConstants.ARGS_GROUP);
1872                query = "//element(*, " + JCRProjectMemberFactory.MEMBER_NODETYPE + ")["
1873                    + JCRProjectMember.METADATA_GROUP + "/" + JCRProjectMember.METADATA_GROUP_ID + "=" + groupIdentity.getId()
1874                    + " and " + JCRProjectMember.METADATA_GROUP + "/" + JCRProjectMember.METADATA_GROUP_DIRECTORY + "=" + groupIdentity.getDirectoryId()
1875                    + "]";
1876                break;
1877            default:
1878                throw new IllegalStateException("Event id '" + event.getId() + "' is not supported");
1879        }
1880        
1881        try (AmetysObjectIterable<JCRProjectMember> members = _resolver.query(query))
1882        {
1883            for (JCRProjectMember member: members)
1884            {
1885                AmetysObject parent = member.getParent().getParent();
1886                if (parent instanceof Project project)
1887                {
1888                    _removeMember(member, project);
1889                }
1890            }
1891        }
1892    }
1893}