diff --git a/api4/user.go b/api4/user.go index 383bb2f5920c3..65463a4fab4b8 100644 --- a/api4/user.go +++ b/api4/user.go @@ -259,6 +259,7 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { inTeamId := r.URL.Query().Get("in_team") inChannelId := r.URL.Query().Get("in_channel") notInChannelId := r.URL.Query().Get("not_in_channel") + withoutTeam := r.URL.Query().Get("without_team") if len(notInChannelId) > 0 && len(inTeamId) == 0 { c.SetInvalidParam("team_id") @@ -269,7 +270,15 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { var err *model.AppError etag := "" - if len(notInChannelId) > 0 { + if withoutTeamBool, err := strconv.ParseBool(withoutTeam); err == nil && withoutTeamBool { + // Use a special permission for now + if !app.SessionHasPermissionTo(c.Session, model.PERMISSION_LIST_USERS_WITHOUT_TEAM) { + c.SetPermissionError(model.PERMISSION_LIST_USERS_WITHOUT_TEAM) + return + } + + profiles, err = app.GetUsersWithoutTeamPage(c.Params.Page, c.Params.PerPage, c.IsSystemAdmin()) + } else if len(notInChannelId) > 0 { if !app.SessionHasPermissionToChannel(c.Session, notInChannelId, model.PERMISSION_READ_CHANNEL) { c.SetPermissionError(model.PERMISSION_READ_CHANNEL) return diff --git a/api4/user_test.go b/api4/user_test.go index 923caa761f21b..01ecb0c4eace2 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -690,6 +690,57 @@ func TestGetUsers(t *testing.T) { CheckUnauthorizedStatus(t, resp) } +func TestGetUsersWithoutTeam(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + defer TearDown() + Client := th.Client + SystemAdminClient := th.SystemAdminClient + + if _, resp := Client.GetUsersWithoutTeam(0, 100, ""); resp.Error == nil { + t.Fatal("should prevent non-admin user from getting users without a team") + } + + // These usernames need to appear in the first 100 users for this to work + + user, resp := Client.CreateUser(&model.User{ + Username: "a000000000" + model.NewId(), + Email: "success+" + model.NewId() + "@simulator.amazonses.com", + Password: "Password1", + }) + CheckNoError(t, resp) + LinkUserToTeam(user, th.BasicTeam) + defer app.Srv.Store.User().PermanentDelete(user.Id) + + user2, resp := Client.CreateUser(&model.User{ + Username: "a000000001" + model.NewId(), + Email: "success+" + model.NewId() + "@simulator.amazonses.com", + Password: "Password1", + }) + CheckNoError(t, resp) + defer app.Srv.Store.User().PermanentDelete(user2.Id) + + if rusers, resp := SystemAdminClient.GetUsersWithoutTeam(0, 100, ""); resp.Error != nil { + t.Fatal(resp.Error) + } else { + found1 := false + found2 := false + + for _, u := range rusers { + if u.Id == user.Id { + found1 = true + } else if u.Id == user2.Id { + found2 = true + } + } + + if found1 { + t.Fatal("shouldn't have returned user that has a team") + } else if !found2 { + t.Fatal("should've returned user that has no teams") + } + } +} + func TestGetUsersInTeam(t *testing.T) { th := Setup().InitBasic().InitSystemAdmin() defer TearDown() diff --git a/app/user.go b/app/user.go index c877640d6324b..a5a57b5291fa7 100644 --- a/app/user.go +++ b/app/user.go @@ -540,6 +540,27 @@ func GetUsersNotInChannelPage(teamId string, channelId string, page int, perPage return users, nil } +func GetUsersWithoutTeamPage(page int, perPage int, asAdmin bool) ([]*model.User, *model.AppError) { + users, err := GetUsersWithoutTeam(page*perPage, perPage) + if err != nil { + return nil, err + } + + for _, user := range users { + SanitizeProfile(user, asAdmin) + } + + return users, nil +} + +func GetUsersWithoutTeam(offset int, limit int) ([]*model.User, *model.AppError) { + if result := <-Srv.Store.User().GetProfilesWithoutTeam(offset, limit); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.User), nil + } +} + func GetUsersByIds(userIds []string, asAdmin bool) ([]*model.User, *model.AppError) { if result := <-Srv.Store.User().GetProfileByIds(userIds, true); result.Err != nil { return nil, result.Err diff --git a/model/authorization.go b/model/authorization.go index eec0f79ee942b..175e724af56ba 100644 --- a/model/authorization.go +++ b/model/authorization.go @@ -57,6 +57,7 @@ var PERMISSION_CREATE_TEAM *Permission var PERMISSION_MANAGE_TEAM *Permission var PERMISSION_IMPORT_TEAM *Permission var PERMISSION_VIEW_TEAM *Permission +var PERMISSION_LIST_USERS_WITHOUT_TEAM *Permission // General permission that encompases all system admin functions // in the future this could be broken up to allow access to some @@ -286,6 +287,11 @@ func InitalizePermissions() { "authentication.permissions.view_team.name", "authentication.permissions.view_team.description", } + PERMISSION_LIST_USERS_WITHOUT_TEAM = &Permission{ + "list_users_without_team", + "authentication.permisssions.list_users_without_team.name", + "authentication.permisssions.list_users_without_team.description", + } } func InitalizeRoles() { @@ -400,6 +406,7 @@ func InitalizeRoles() { PERMISSION_DELETE_POST.Id, PERMISSION_DELETE_OTHERS_POSTS.Id, PERMISSION_CREATE_TEAM.Id, + PERMISSION_LIST_USERS_WITHOUT_TEAM.Id, }, ROLE_TEAM_USER.Permissions..., ), diff --git a/model/client4.go b/model/client4.go index f6d665e1c6f31..e60a2dab39568 100644 --- a/model/client4.go +++ b/model/client4.go @@ -477,6 +477,17 @@ func (c *Client4) GetUsersNotInChannel(teamId, channelId string, page int, perPa } } +// GetUsersWithoutTeam returns a page of users on the system that aren't on any teams. Page counting starts at 0. +func (c *Client4) GetUsersWithoutTeam(page int, perPage int, etag string) ([]*User, *Response) { + query := fmt.Sprintf("?without_team=1&page=%v&per_page=%v", page, perPage) + if r, err := c.DoApiGet(c.GetUsersRoute()+query, etag); err != nil { + return nil, &Response{StatusCode: r.StatusCode, Error: err} + } else { + defer closeBody(r) + return UserListFromJson(r.Body), BuildResponse(r) + } +} + // GetUsersByIds returns a list of users based on the provided user ids. func (c *Client4) GetUsersByIds(userIds []string) ([]*User, *Response) { if r, err := c.DoApiPost(c.GetUsersRoute()+"/ids", ArrayToJson(userIds)); err != nil { diff --git a/store/sql_user_store.go b/store/sql_user_store.go index 23b0c36967818..4c0ecb071b3f1 100644 --- a/store/sql_user_store.go +++ b/store/sql_user_store.go @@ -726,6 +726,54 @@ func (us SqlUserStore) GetProfilesNotInChannel(teamId string, channelId string, return storeChannel } +func (us SqlUserStore) GetProfilesWithoutTeam(offset int, limit int) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var users []*model.User + + query := ` + SELECT + * + FROM + Users + WHERE + (SELECT + COUNT(0) + FROM + TeamMembers + WHERE + TeamMembers.UserId = Users.Id + AND TeamMembers.DeleteAt = 0) = 0 + ORDER BY + Username ASC + LIMIT + :Limit + OFFSET + :Offset` + + if _, err := us.GetReplica().Select(&users, query, map[string]interface{}{"Offset": offset, "Limit": limit}); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.GetProfilesWithoutTeam", "store.sql_user.get_profiles.app_error", nil, err.Error()) + } else { + + for _, u := range users { + u.Password = "" + u.AuthData = new(string) + *u.AuthData = "" + } + + result.Data = users + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (us SqlUserStore) GetProfilesByUsernames(usernames []string, teamId string) StoreChannel { storeChannel := make(StoreChannel) diff --git a/store/sql_user_store_test.go b/store/sql_user_store_test.go index 104735455449a..60a7409199909 100644 --- a/store/sql_user_store_test.go +++ b/store/sql_user_store_test.go @@ -373,6 +373,49 @@ func TestUserStoreGetProfilesInChannel(t *testing.T) { } } +func TestUserStoreGetProfilesWithoutTeam(t *testing.T) { + Setup() + + teamId := model.NewId() + + // These usernames need to appear in the first 100 users for this to work + + u1 := &model.User{} + u1.Username = "a000000000" + model.NewId() + u1.Email = model.NewId() + Must(store.User().Save(u1)) + Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u1.Id})) + defer store.User().PermanentDelete(u1.Id) + + u2 := &model.User{} + u2.Username = "a000000001" + model.NewId() + u2.Email = model.NewId() + Must(store.User().Save(u2)) + defer store.User().PermanentDelete(u2.Id) + + if r1 := <-store.User().GetProfilesWithoutTeam(0, 100); r1.Err != nil { + t.Fatal(r1.Err) + } else { + users := r1.Data.([]*model.User) + + found1 := false + found2 := false + for _, u := range users { + if u.Id == u1.Id { + found1 = true + } else if u.Id == u2.Id { + found2 = true + } + } + + if found1 { + t.Fatal("shouldn't have returned user on team") + } else if !found2 { + t.Fatal("should've returned user without any teams") + } + } +} + func TestUserStoreGetAllProfilesInChannel(t *testing.T) { Setup() diff --git a/store/store.go b/store/store.go index 72572b1e038bf..cc9dc73a1d53d 100644 --- a/store/store.go +++ b/store/store.go @@ -176,6 +176,7 @@ type UserStore interface { GetProfilesInChannel(channelId string, offset int, limit int) StoreChannel GetAllProfilesInChannel(channelId string, allowFromCache bool) StoreChannel GetProfilesNotInChannel(teamId string, channelId string, offset int, limit int) StoreChannel + GetProfilesWithoutTeam(offset int, limit int) StoreChannel GetProfilesByUsernames(usernames []string, teamId string) StoreChannel GetAllProfiles(offset int, limit int) StoreChannel GetProfiles(teamId string, offset int, limit int) StoreChannel