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