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