Preferences: Add pagination to org configuration page (#60896)

* Add auth labels and access control metadata to org users search results

* Fix search result JSON model

* Org users: Use API for pagination

* Fix default page size

* Refactor: UsersListPage to functional component

* Refactor: update UsersTable component code style

* Add pagination to the /orgs/{org_id}/users endpoint

* Use pagination on the AdminEditOrgPage

* Add /orgs/{org_id}/users/search endpoint to prevent breaking API

* Use existing search store method

* Remove unnecessary error

* Remove unused

* Add query param to search endpoint

* Fix endpoint docs

* Minor refactor

* Fix number of pages calculation

* Use SearchOrgUsers for all org users methods

* Refactor: GetOrgUsers as a service method

* Minor refactor: rename orgId => orgID

* Fix integration tests

* Fix tests
This commit is contained in:
Alexander Zobnin 2023-01-09 11:54:33 +03:00 committed by GitHub
parent d44de7f20a
commit f1b5014efd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 454 additions and 410 deletions

View File

@ -367,6 +367,7 @@ func (hs *HTTPServer) registerRoutes() {
orgsRoute.Put("/address", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgsWrite)), routing.Wrap(hs.UpdateOrgAddress)) orgsRoute.Put("/address", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgsWrite)), routing.Wrap(hs.UpdateOrgAddress))
orgsRoute.Delete("/", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgsDelete)), routing.Wrap(hs.DeleteOrgByID)) orgsRoute.Delete("/", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgsDelete)), routing.Wrap(hs.DeleteOrgByID))
orgsRoute.Get("/users", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.GetOrgUsers)) orgsRoute.Get("/users", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.GetOrgUsers))
orgsRoute.Get("/users/search", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.SearchOrgUsers))
orgsRoute.Post("/users", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), routing.Wrap(hs.AddOrgUser)) orgsRoute.Post("/users", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), routing.Wrap(hs.AddOrgUser))
orgsRoute.Patch("/users/:userId", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersWrite, userIDScope)), routing.Wrap(hs.UpdateOrgUser)) orgsRoute.Patch("/users/:userId", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersWrite, userIDScope)), routing.Wrap(hs.UpdateOrgUser))
orgsRoute.Delete("/users/:userId", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersRemove, userIDScope)), routing.Wrap(hs.RemoveOrgUser)) orgsRoute.Delete("/users/:userId", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersRemove, userIDScope)), routing.Wrap(hs.RemoveOrgUser))

View File

@ -398,6 +398,7 @@ func setupHTTPServerWithCfgDb(
userMock.ExpectedUser = &user.User{ID: 1} userMock.ExpectedUser = &user.User{ID: 1}
orgMock := orgtest.NewOrgServiceFake() orgMock := orgtest.NewOrgServiceFake()
orgMock.ExpectedOrg = &org.Org{} orgMock.ExpectedOrg = &org.Org{}
orgMock.ExpectedSearchOrgUsersResult = &org.SearchOrgUsersQueryResult{}
// Defining the accesscontrol service has to be done before registering routes // Defining the accesscontrol service has to be done before registering routes
if useFakeAccessControl { if useFakeAccessControl {

View File

@ -115,18 +115,18 @@ func (hs *HTTPServer) addOrgUserHelper(c *models.ReqContext, cmd org.AddOrgUserC
// 403: forbiddenError // 403: forbiddenError
// 500: internalServerError // 500: internalServerError
func (hs *HTTPServer) GetOrgUsersForCurrentOrg(c *models.ReqContext) response.Response { func (hs *HTTPServer) GetOrgUsersForCurrentOrg(c *models.ReqContext) response.Response {
result, err := hs.getOrgUsersHelper(c, &org.GetOrgUsersQuery{ result, err := hs.searchOrgUsersHelper(c, &org.SearchOrgUsersQuery{
OrgID: c.OrgID, OrgID: c.OrgID,
Query: c.Query("query"), Query: c.Query("query"),
Limit: c.QueryInt("limit"), Limit: c.QueryInt("limit"),
User: c.SignedInUser, User: c.SignedInUser,
}, c.SignedInUser) })
if err != nil { if err != nil {
return response.Error(500, "Failed to get users for current organization", err) return response.Error(500, "Failed to get users for current organization", err)
} }
return response.JSON(http.StatusOK, result) return response.JSON(http.StatusOK, result.OrgUsers)
} }
// swagger:route GET /org/users/lookup org getOrgUsersForCurrentOrgLookup // swagger:route GET /org/users/lookup org getOrgUsersForCurrentOrgLookup
@ -144,13 +144,13 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrg(c *models.ReqContext) response.Re
// 500: internalServerError // 500: internalServerError
func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *models.ReqContext) response.Response { func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *models.ReqContext) response.Response {
orgUsers, err := hs.getOrgUsersHelper(c, &org.GetOrgUsersQuery{ orgUsersResult, err := hs.searchOrgUsersHelper(c, &org.SearchOrgUsersQuery{
OrgID: c.OrgID, OrgID: c.OrgID,
Query: c.Query("query"), Query: c.Query("query"),
Limit: c.QueryInt("limit"), Limit: c.QueryInt("limit"),
User: c.SignedInUser, User: c.SignedInUser,
DontEnforceAccessControl: !hs.License.FeatureEnabled("accesscontrol.enforcement"), DontEnforceAccessControl: !hs.License.FeatureEnabled("accesscontrol.enforcement"),
}, c.SignedInUser) })
if err != nil { if err != nil {
return response.Error(500, "Failed to get users for current organization", err) return response.Error(500, "Failed to get users for current organization", err)
@ -158,7 +158,7 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *models.ReqContext) respo
result := make([]*dtos.UserLookupDTO, 0) result := make([]*dtos.UserLookupDTO, 0)
for _, u := range orgUsers { for _, u := range orgUsersResult.OrgUsers {
result = append(result, &dtos.UserLookupDTO{ result = append(result, &dtos.UserLookupDTO{
UserID: u.UserID, UserID: u.UserID,
Login: u.Login, Login: u.Login,
@ -190,12 +190,58 @@ func (hs *HTTPServer) GetOrgUsers(c *models.ReqContext) response.Response {
return response.Error(http.StatusBadRequest, "orgId is invalid", err) return response.Error(http.StatusBadRequest, "orgId is invalid", err)
} }
result, err := hs.getOrgUsersHelper(c, &org.GetOrgUsersQuery{ result, err := hs.searchOrgUsersHelper(c, &org.SearchOrgUsersQuery{
OrgID: orgId, OrgID: orgId,
Query: "", Query: "",
Limit: 0, Limit: 0,
User: c.SignedInUser, User: c.SignedInUser,
}, c.SignedInUser) })
if err != nil {
return response.Error(500, "Failed to get users for organization", err)
}
return response.JSON(http.StatusOK, result.OrgUsers)
}
// swagger:route GET /orgs/{org_id}/users/search orgs searchOrgUsers
//
// Search Users in Organization.
//
// If you are running Grafana Enterprise and have Fine-grained access control enabled
// you need to have a permission with action: `org.users:read` with scope `users:*`.
//
// Security:
// - basic:
//
// Responses:
// 200: searchOrgUsersResponse
// 401: unauthorisedError
// 403: forbiddenError
// 500: internalServerError
func (hs *HTTPServer) SearchOrgUsers(c *models.ReqContext) response.Response {
orgID, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "orgId is invalid", err)
}
perPage := c.QueryInt("perpage")
if perPage <= 0 {
perPage = 1000
}
page := c.QueryInt("page")
if page < 1 {
page = 1
}
result, err := hs.searchOrgUsersHelper(c, &org.SearchOrgUsersQuery{
OrgID: orgID,
Query: c.Query("query"),
Page: page,
Limit: perPage,
User: c.SignedInUser,
})
if err != nil { if err != nil {
return response.Error(500, "Failed to get users for organization", err) return response.Error(500, "Failed to get users for organization", err)
@ -204,17 +250,46 @@ func (hs *HTTPServer) GetOrgUsers(c *models.ReqContext) response.Response {
return response.JSON(http.StatusOK, result) return response.JSON(http.StatusOK, result)
} }
func (hs *HTTPServer) getOrgUsersHelper(c *models.ReqContext, query *org.GetOrgUsersQuery, signedInUser *user.SignedInUser) ([]*org.OrgUserDTO, error) { // SearchOrgUsersWithPaging is an HTTP handler to search for org users with paging.
result, err := hs.orgService.GetOrgUsers(c.Req.Context(), query) // GET /api/org/users/search
func (hs *HTTPServer) SearchOrgUsersWithPaging(c *models.ReqContext) response.Response {
perPage := c.QueryInt("perpage")
if perPage <= 0 {
perPage = 1000
}
page := c.QueryInt("page")
if page < 1 {
page = 1
}
query := &org.SearchOrgUsersQuery{
OrgID: c.OrgID,
Query: c.Query("query"),
Page: page,
Limit: perPage,
User: c.SignedInUser,
}
result, err := hs.searchOrgUsersHelper(c, query)
if err != nil {
return response.Error(500, "Failed to get users for current organization", err)
}
return response.JSON(http.StatusOK, result)
}
func (hs *HTTPServer) searchOrgUsersHelper(c *models.ReqContext, query *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) {
result, err := hs.orgService.SearchOrgUsers(c.Req.Context(), query)
if err != nil { if err != nil {
return nil, err return nil, err
} }
filteredUsers := make([]*org.OrgUserDTO, 0, len(result)) filteredUsers := make([]*org.OrgUserDTO, 0, len(result.OrgUsers))
userIDs := map[string]bool{} userIDs := map[string]bool{}
authLabelsUserIDs := make([]int64, 0, len(result)) authLabelsUserIDs := make([]int64, 0, len(result.OrgUsers))
for _, user := range result { for _, user := range result.OrgUsers {
if dtos.IsHiddenUser(user.Login, signedInUser, hs.Cfg) { if dtos.IsHiddenUser(user.Login, c.SignedInUser, hs.Cfg) {
continue continue
} }
user.AvatarURL = dtos.GetGravatarUrl(user.Email) user.AvatarURL = dtos.GetGravatarUrl(user.Email)
@ -241,51 +316,10 @@ func (hs *HTTPServer) getOrgUsersHelper(c *models.ReqContext, query *org.GetOrgU
} }
} }
return filteredUsers, nil
}
// SearchOrgUsersWithPaging is an HTTP handler to search for org users with paging.
// GET /api/org/users/search
func (hs *HTTPServer) SearchOrgUsersWithPaging(c *models.ReqContext) response.Response {
ctx := c.Req.Context()
perPage := c.QueryInt("perpage")
if perPage <= 0 {
perPage = 1000
}
page := c.QueryInt("page")
if page < 1 {
page = 1
}
query := &org.SearchOrgUsersQuery{
OrgID: c.OrgID,
Query: c.Query("query"),
Page: page,
Limit: perPage,
User: c.SignedInUser,
}
result, err := hs.orgService.SearchOrgUsers(ctx, query)
if err != nil {
return response.Error(500, "Failed to get users for current organization", err)
}
filteredUsers := make([]*org.OrgUserDTO, 0, len(result.OrgUsers))
for _, user := range result.OrgUsers {
if dtos.IsHiddenUser(user.Login, c.SignedInUser, hs.Cfg) {
continue
}
user.AvatarURL = dtos.GetGravatarUrl(user.Email)
filteredUsers = append(filteredUsers, user)
}
result.OrgUsers = filteredUsers result.OrgUsers = filteredUsers
result.Page = page result.Page = query.Page
result.PerPage = perPage result.PerPage = query.Limit
return result, nil
return response.JSON(http.StatusOK, result)
} }
// swagger:route PATCH /org/users/{user_id} org updateOrgUserForCurrentOrg // swagger:route PATCH /org/users/{user_id} org updateOrgUserForCurrentOrg

View File

@ -63,12 +63,15 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
orgService.ExpectedSearchOrgUsersResult = &org.SearchOrgUsersQueryResult{} orgService.ExpectedSearchOrgUsersResult = &org.SearchOrgUsersQueryResult{}
hs.orgService = orgService hs.orgService = orgService
mock := mockstore.NewSQLStoreMock() mock := mockstore.NewSQLStoreMock()
loggedInUserScenario(t, "When calling GET on", "api/org/users", "api/org/users", func(sc *scenarioContext) { loggedInUserScenario(t, "When calling GET on", "api/org/users", "api/org/users", func(sc *scenarioContext) {
setUpGetOrgUsersDB(t, sqlStore) setUpGetOrgUsersDB(t, sqlStore)
orgService.ExpectedOrgUsers = []*org.OrgUserDTO{ orgService.ExpectedSearchOrgUsersResult = &org.SearchOrgUsersQueryResult{
OrgUsers: []*org.OrgUserDTO{
{Login: testUserLogin, Email: "testUser@grafana.com"}, {Login: testUserLogin, Email: "testUser@grafana.com"},
{Login: "user1", Email: "user1@grafana.com"}, {Login: "user1", Email: "user1@grafana.com"},
{Login: "user2", Email: "user2@grafana.com"}, {Login: "user2", Email: "user2@grafana.com"},
},
} }
sc.handlerFunc = hs.GetOrgUsersForCurrentOrg sc.handlerFunc = hs.GetOrgUsersForCurrentOrg
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
@ -153,6 +156,13 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
loggedInUserScenario(t, "When calling GET on", "api/org/users", "api/org/users", func(sc *scenarioContext) { loggedInUserScenario(t, "When calling GET on", "api/org/users", "api/org/users", func(sc *scenarioContext) {
setUpGetOrgUsersDB(t, sqlStore) setUpGetOrgUsersDB(t, sqlStore)
orgService.ExpectedSearchOrgUsersResult = &org.SearchOrgUsersQueryResult{
OrgUsers: []*org.OrgUserDTO{
{Login: testUserLogin, Email: "testUser@grafana.com"},
{Login: "user1", Email: "user1@grafana.com"},
{Login: "user2", Email: "user2@grafana.com"},
},
}
sc.handlerFunc = hs.GetOrgUsersForCurrentOrg sc.handlerFunc = hs.GetOrgUsersForCurrentOrg
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()

View File

@ -162,6 +162,7 @@ type GetOrgUsersQuery struct {
UserID int64 `xorm:"user_id"` UserID int64 `xorm:"user_id"`
OrgID int64 `xorm:"org_id"` OrgID int64 `xorm:"org_id"`
Query string Query string
Page int
Limit int Limit int
// Flag used to allow oss edition to query users without access control // Flag used to allow oss edition to query users without access control
DontEnforceAccessControl bool DontEnforceAccessControl bool
@ -170,17 +171,20 @@ type GetOrgUsersQuery struct {
} }
type SearchOrgUsersQuery struct { type SearchOrgUsersQuery struct {
UserID int64 `xorm:"user_id"`
OrgID int64 `xorm:"org_id"` OrgID int64 `xorm:"org_id"`
Query string Query string
Page int Page int
Limit int Limit int
// Flag used to allow oss edition to query users without access control
DontEnforceAccessControl bool
User *user.SignedInUser User *user.SignedInUser
} }
type SearchOrgUsersQueryResult struct { type SearchOrgUsersQueryResult struct {
TotalCount int64 `json:"totalCount"` TotalCount int64 `json:"totalCount"`
OrgUsers []*OrgUserDTO `json:"OrgUsers"` OrgUsers []*OrgUserDTO `json:"orgUsers"`
Page int `json:"page"` Page int `json:"page"`
PerPage int `json:"perPage"` PerPage int `json:"perPage"`
} }

View File

@ -194,7 +194,19 @@ func (s *Service) RemoveOrgUser(ctx context.Context, cmd *org.RemoveOrgUserComma
// TODO: refactor service to call store CRUD method // TODO: refactor service to call store CRUD method
func (s *Service) GetOrgUsers(ctx context.Context, query *org.GetOrgUsersQuery) ([]*org.OrgUserDTO, error) { func (s *Service) GetOrgUsers(ctx context.Context, query *org.GetOrgUsersQuery) ([]*org.OrgUserDTO, error) {
return s.store.GetOrgUsers(ctx, query) result, err := s.store.SearchOrgUsers(ctx, &org.SearchOrgUsersQuery{
UserID: query.UserID,
OrgID: query.OrgID,
Query: query.Query,
Page: query.Page,
Limit: query.Limit,
DontEnforceAccessControl: query.DontEnforceAccessControl,
User: query.User,
})
if err != nil {
return nil, err
}
return result.OrgUsers, nil
} }
// TODO: refactor service to call store CRUD method // TODO: refactor service to call store CRUD method

View File

@ -39,7 +39,6 @@ type store interface {
CreateWithMember(context.Context, *org.CreateOrgCommand) (*org.Org, error) CreateWithMember(context.Context, *org.CreateOrgCommand) (*org.Org, error)
AddOrgUser(context.Context, *org.AddOrgUserCommand) error AddOrgUser(context.Context, *org.AddOrgUserCommand) error
UpdateOrgUser(context.Context, *org.UpdateOrgUserCommand) error UpdateOrgUser(context.Context, *org.UpdateOrgUserCommand) error
GetOrgUsers(context.Context, *org.GetOrgUsersQuery) ([]*org.OrgUserDTO, error)
GetByID(context.Context, *org.GetOrgByIDQuery) (*org.Org, error) GetByID(context.Context, *org.GetOrgByIDQuery) (*org.Org, error)
GetByName(context.Context, *org.GetOrgByNameQuery) (*org.Org, error) GetByName(context.Context, *org.GetOrgByNameQuery) (*org.Org, error)
SearchOrgUsers(context.Context, *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) SearchOrgUsers(context.Context, *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error)
@ -512,8 +511,29 @@ func validateOneAdminLeftInOrg(orgID int64, sess *db.Session) error {
return err return err
} }
func (ss *sqlStore) GetOrgUsers(ctx context.Context, query *org.GetOrgUsersQuery) ([]*org.OrgUserDTO, error) { func (ss *sqlStore) GetByID(ctx context.Context, query *org.GetOrgByIDQuery) (*org.Org, error) {
result := make([]*org.OrgUserDTO, 0) var orga org.Org
err := ss.db.WithDbSession(ctx, func(dbSession *db.Session) error {
exists, err := dbSession.ID(query.ID).Get(&orga)
if err != nil {
return err
}
if !exists {
return org.ErrOrgNotFound
}
return nil
})
if err != nil {
return nil, err
}
return &orga, nil
}
func (ss *sqlStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) {
result := org.SearchOrgUsersQueryResult{
OrgUsers: make([]*org.OrgUserDTO, 0),
}
err := ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { err := ss.db.WithDbSession(ctx, func(dbSession *db.Session) error {
sess := dbSession.Table("org_user") sess := dbSession.Table("org_user")
sess.Join("INNER", ss.dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", ss.dialect.Quote("user"))) sess.Join("INNER", ss.dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", ss.dialect.Quote("user")))
@ -556,7 +576,8 @@ func (ss *sqlStore) GetOrgUsers(ctx context.Context, query *org.GetOrgUsersQuery
} }
if query.Limit > 0 { if query.Limit > 0 {
sess.Limit(query.Limit, 0) offset := query.Limit * (query.Page - 1)
sess.Limit(query.Limit, offset)
} }
sess.Cols( sess.Cols(
@ -573,92 +594,6 @@ func (ss *sqlStore) GetOrgUsers(ctx context.Context, query *org.GetOrgUsersQuery
) )
sess.Asc("user.email", "user.login") sess.Asc("user.email", "user.login")
if err := sess.Find(&result); err != nil {
return err
}
for _, user := range result {
user.LastSeenAtAge = util.GetAgeString(user.LastSeenAt)
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (ss *sqlStore) GetByID(ctx context.Context, query *org.GetOrgByIDQuery) (*org.Org, error) {
var orga org.Org
err := ss.db.WithDbSession(ctx, func(dbSession *db.Session) error {
exists, err := dbSession.ID(query.ID).Get(&orga)
if err != nil {
return err
}
if !exists {
return org.ErrOrgNotFound
}
return nil
})
if err != nil {
return nil, err
}
return &orga, nil
}
func (ss *sqlStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) {
result := org.SearchOrgUsersQueryResult{
OrgUsers: make([]*org.OrgUserDTO, 0),
}
err := ss.db.WithDbSession(ctx, func(dbSession *db.Session) error {
sess := dbSession.Table("org_user")
sess.Join("INNER", ss.dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", ss.dialect.Quote("user")))
whereConditions := make([]string, 0)
whereParams := make([]interface{}, 0)
whereConditions = append(whereConditions, "org_user.org_id = ?")
whereParams = append(whereParams, query.OrgID)
whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = %s", ss.dialect.Quote("user"), ss.dialect.BooleanStr(false)))
if !accesscontrol.IsDisabled(ss.cfg) {
acFilter, err := accesscontrol.Filter(query.User, "org_user.user_id", "users:id:", accesscontrol.ActionOrgUsersRead)
if err != nil {
return err
}
whereConditions = append(whereConditions, acFilter.Where)
whereParams = append(whereParams, acFilter.Args...)
}
if query.Query != "" {
queryWithWildcards := "%" + query.Query + "%"
whereConditions = append(whereConditions, "(email "+ss.dialect.LikeStr()+" ? OR name "+ss.dialect.LikeStr()+" ? OR login "+ss.dialect.LikeStr()+" ?)")
whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards)
}
if len(whereConditions) > 0 {
sess.Where(strings.Join(whereConditions, " AND "), whereParams...)
}
if query.Limit > 0 {
offset := query.Limit * (query.Page - 1)
sess.Limit(query.Limit, offset)
}
sess.Cols(
"org_user.org_id",
"org_user.user_id",
"user.email",
"user.name",
"user.login",
"org_user.role",
"user.last_seen_at",
)
sess.Asc("user.email", "user.login")
if err := sess.Find(&result.OrgUsers); err != nil { if err := sess.Find(&result.OrgUsers); err != nil {
return err return err
} }

View File

@ -327,35 +327,35 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) {
err = orgUserStore.UpdateOrgUser(context.Background(), &updateCmd) err = orgUserStore.UpdateOrgUser(context.Background(), &updateCmd)
require.NoError(t, err) require.NoError(t, err)
orgUsersQuery := org.GetOrgUsersQuery{ orgUsersQuery := org.SearchOrgUsersQuery{
OrgID: ac1.OrgID, OrgID: ac1.OrgID,
User: &user.SignedInUser{ User: &user.SignedInUser{
OrgID: ac1.OrgID, OrgID: ac1.OrgID,
Permissions: map[int64]map[string][]string{ac1.OrgID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, Permissions: map[int64]map[string][]string{ac1.OrgID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}},
}, },
} }
result, err := orgUserStore.GetOrgUsers(context.Background(), &orgUsersQuery) result, err := orgUserStore.SearchOrgUsers(context.Background(), &orgUsersQuery)
require.NoError(t, err) require.NoError(t, err)
require.EqualValues(t, result[1].Role, org.RoleAdmin) require.EqualValues(t, result.OrgUsers[1].Role, org.RoleAdmin)
}) })
t.Run("Can get organization users", func(t *testing.T) { t.Run("Can get organization users", func(t *testing.T) {
query := org.GetOrgUsersQuery{ query := org.SearchOrgUsersQuery{
OrgID: ac1.OrgID, OrgID: ac1.OrgID,
User: &user.SignedInUser{ User: &user.SignedInUser{
OrgID: ac1.OrgID, OrgID: ac1.OrgID,
Permissions: map[int64]map[string][]string{ac1.OrgID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, Permissions: map[int64]map[string][]string{ac1.OrgID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}},
}, },
} }
result, err := orgUserStore.GetOrgUsers(context.Background(), &query) result, err := orgUserStore.SearchOrgUsers(context.Background(), &query)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, len(result), 2) require.Equal(t, len(result.OrgUsers), 2)
require.Equal(t, result[0].Role, "Admin") require.Equal(t, result.OrgUsers[0].Role, "Admin")
}) })
t.Run("Can get organization users with query", func(t *testing.T) { t.Run("Can get organization users with query", func(t *testing.T) {
query := org.GetOrgUsersQuery{ query := org.SearchOrgUsersQuery{
OrgID: ac1.OrgID, OrgID: ac1.OrgID,
Query: "ac1", Query: "ac1",
User: &user.SignedInUser{ User: &user.SignedInUser{
@ -363,14 +363,14 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) {
Permissions: map[int64]map[string][]string{ac1.OrgID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, Permissions: map[int64]map[string][]string{ac1.OrgID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}},
}, },
} }
result, err := orgUserStore.GetOrgUsers(context.Background(), &query) result, err := orgUserStore.SearchOrgUsers(context.Background(), &query)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, len(result), 1) require.Equal(t, len(result.OrgUsers), 1)
require.Equal(t, result[0].Email, ac1.Email) require.Equal(t, result.OrgUsers[0].Email, ac1.Email)
}) })
t.Run("Can get organization users with query and limit", func(t *testing.T) { t.Run("Can get organization users with query and limit", func(t *testing.T) {
query := org.GetOrgUsersQuery{ query := org.SearchOrgUsersQuery{
OrgID: ac1.OrgID, OrgID: ac1.OrgID,
Query: "ac", Query: "ac",
Limit: 1, Limit: 1,
@ -379,11 +379,11 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) {
Permissions: map[int64]map[string][]string{ac1.OrgID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, Permissions: map[int64]map[string][]string{ac1.OrgID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}},
}, },
} }
result, err := orgUserStore.GetOrgUsers(context.Background(), &query) result, err := orgUserStore.SearchOrgUsers(context.Background(), &query)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, len(result), 1) require.Equal(t, len(result.OrgUsers), 1)
require.Equal(t, result[0].Email, ac1.Email) require.Equal(t, result.OrgUsers[0].Email, ac1.Email)
}) })
t.Run("Cannot update role so no one is admin user", func(t *testing.T) { t.Run("Cannot update role so no one is admin user", func(t *testing.T) {
remCmd := org.RemoveOrgUserCommand{OrgID: ac1.OrgID, UserID: ac2.ID, ShouldDeleteOrphanedUser: true} remCmd := org.RemoveOrgUserCommand{OrgID: ac1.OrgID, UserID: ac2.ID, ShouldDeleteOrphanedUser: true}
@ -532,12 +532,12 @@ func TestIntegration_SQLStore_GetOrgUsers(t *testing.T) {
} }
tests := []struct { tests := []struct {
desc string desc string
query *org.GetOrgUsersQuery query *org.SearchOrgUsersQuery
expectedNumUsers int expectedNumUsers int
}{ }{
{ {
desc: "should return all users", desc: "should return all users",
query: &org.GetOrgUsersQuery{ query: &org.SearchOrgUsersQuery{
OrgID: 1, OrgID: 1,
User: &user.SignedInUser{ User: &user.SignedInUser{
OrgID: 1, OrgID: 1,
@ -548,7 +548,7 @@ func TestIntegration_SQLStore_GetOrgUsers(t *testing.T) {
}, },
{ {
desc: "should return no users", desc: "should return no users",
query: &org.GetOrgUsersQuery{ query: &org.SearchOrgUsersQuery{
OrgID: 1, OrgID: 1,
User: &user.SignedInUser{ User: &user.SignedInUser{
OrgID: 1, OrgID: 1,
@ -559,7 +559,7 @@ func TestIntegration_SQLStore_GetOrgUsers(t *testing.T) {
}, },
{ {
desc: "should return some users", desc: "should return some users",
query: &org.GetOrgUsersQuery{ query: &org.SearchOrgUsersQuery{
OrgID: 1, OrgID: 1,
User: &user.SignedInUser{ User: &user.SignedInUser{
OrgID: 1, OrgID: 1,
@ -589,12 +589,12 @@ func TestIntegration_SQLStore_GetOrgUsers(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) { t.Run(tt.desc, func(t *testing.T) {
result, err := orgUserStore.GetOrgUsers(context.Background(), tt.query) result, err := orgUserStore.SearchOrgUsers(context.Background(), tt.query)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, result, tt.expectedNumUsers) require.Len(t, result.OrgUsers, tt.expectedNumUsers)
if !hasWildcardScope(tt.query.User, accesscontrol.ActionOrgUsersRead) { if !hasWildcardScope(tt.query.User, accesscontrol.ActionOrgUsersRead) {
for _, u := range result { for _, u := range result.OrgUsers {
assert.Contains(t, tt.query.User.Permissions[tt.query.User.OrgID][accesscontrol.ActionOrgUsersRead], fmt.Sprintf("users:id:%d", u.UserID)) assert.Contains(t, tt.query.User.Permissions[tt.query.User.OrgID][accesscontrol.ActionOrgUsersRead], fmt.Sprintf("users:id:%d", u.UserID))
} }
} }
@ -675,7 +675,7 @@ func TestIntegration_SQLStore_GetOrgUsers_PopulatesCorrectly(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
query := &org.GetOrgUsersQuery{ query := &org.SearchOrgUsersQuery{
OrgID: 1, OrgID: 1,
UserID: newUser.ID, UserID: newUser.ID,
User: &user.SignedInUser{ User: &user.SignedInUser{
@ -683,11 +683,11 @@ func TestIntegration_SQLStore_GetOrgUsers_PopulatesCorrectly(t *testing.T) {
Permissions: map[int64]map[string][]string{1: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, Permissions: map[int64]map[string][]string{1: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}},
}, },
} }
result, err := orgUserStore.GetOrgUsers(context.Background(), query) result, err := orgUserStore.SearchOrgUsers(context.Background(), query)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, result, 1) require.Len(t, result.OrgUsers, 1)
actual := result[0] actual := result.OrgUsers[0]
assert.Equal(t, int64(1), actual.OrgID) assert.Equal(t, int64(1), actual.OrgID)
assert.Equal(t, int64(1), actual.UserID) assert.Equal(t, int64(1), actual.UserID)
assert.Equal(t, "viewer@localhost", actual.Email) assert.Equal(t, "viewer@localhost", actual.Email)

View File

@ -6,8 +6,8 @@ import (
context "context" context "context"
models "github.com/grafana/grafana/pkg/models" models "github.com/grafana/grafana/pkg/models"
mock "github.com/stretchr/testify/mock"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
mock "github.com/stretchr/testify/mock"
) )
// Store is an autogenerated mock type for the Store type // Store is an autogenerated mock type for the Store type
@ -16,7 +16,7 @@ type Store struct {
} }
// GetOrgByNameHandler provides a mock function with given fields: ctx, query // GetOrgByNameHandler provides a mock function with given fields: ctx, query
func (_m *Store) GetOrgByNameHandler(ctx context.Context, query * org.GetOrgByNameQuery) error { func (_m *Store) GetOrgByNameHandler(ctx context.Context, query *org.GetOrgByNameQuery) error {
ret := _m.Called(ctx, query) ret := _m.Called(ctx, query)
var r0 error var r0 error

View File

@ -1,69 +1,90 @@
import { css } from '@emotion/css';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAsyncFn } from 'react-use'; import { useAsyncFn } from 'react-use';
import { NavModelItem, UrlQueryValue } from '@grafana/data'; import { NavModelItem, UrlQueryValue } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime'; import { getBackendSrv } from '@grafana/runtime';
import { Form, Field, Input, Button, Legend, Alert } from '@grafana/ui'; import { Form, Field, Input, Button, Legend, Alert, VerticalGroup, HorizontalGroup, Pagination } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { accessControlQueryParam } from 'app/core/utils/accessControl'; import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { OrgUser, AccessControlAction } from 'app/types'; import { OrgUser, AccessControlAction, OrgRole } from 'app/types';
import UsersTable from '../users/UsersTable'; import { UsersTable } from '../users/UsersTable';
const perPage = 30;
interface OrgNameDTO { interface OrgNameDTO {
orgName: string; orgName: string;
} }
const getOrg = async (orgId: UrlQueryValue) => { const getOrg = async (orgId: UrlQueryValue) => {
return await getBackendSrv().get('/api/orgs/' + orgId); return await getBackendSrv().get(`/api/orgs/${orgId}`);
}; };
const getOrgUsers = async (orgId: UrlQueryValue) => { const getOrgUsers = async (orgId: UrlQueryValue, page: number) => {
if (contextSrv.hasPermission(AccessControlAction.OrgUsersRead)) { if (contextSrv.hasPermission(AccessControlAction.OrgUsersRead)) {
return await getBackendSrv().get(`/api/orgs/${orgId}/users`, accessControlQueryParam()); return getBackendSrv().get(`/api/orgs/${orgId}/users/search`, accessControlQueryParam({ perpage: perPage, page }));
} }
return []; return { orgUsers: [] };
}; };
const updateOrgUserRole = async (orgUser: OrgUser, orgId: UrlQueryValue) => { const updateOrgUserRole = (orgUser: OrgUser, orgId: UrlQueryValue) => {
await getBackendSrv().patch('/api/orgs/' + orgId + '/users/' + orgUser.userId, orgUser); return getBackendSrv().patch(`/api/orgs/${orgId}/users/${orgUser.userId}`, orgUser);
}; };
const removeOrgUser = async (orgUser: OrgUser, orgId: UrlQueryValue) => { const removeOrgUser = (orgUser: OrgUser, orgId: UrlQueryValue) => {
return await getBackendSrv().delete('/api/orgs/' + orgId + '/users/' + orgUser.userId); return getBackendSrv().delete(`/api/orgs/${orgId}/users/${orgUser.userId}`);
}; };
interface Props extends GrafanaRouteComponentProps<{ id: string }> {} interface Props extends GrafanaRouteComponentProps<{ id: string }> {}
export default function AdminEditOrgPage({ match }: Props) { const AdminEditOrgPage = ({ match }: Props) => {
const orgId = parseInt(match.params.id, 10); const orgId = parseInt(match.params.id, 10);
const canWriteOrg = contextSrv.hasPermission(AccessControlAction.OrgsWrite); const canWriteOrg = contextSrv.hasPermission(AccessControlAction.OrgsWrite);
const canReadUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead); const canReadUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead);
const [users, setUsers] = useState<OrgUser[]>([]); const [users, setUsers] = useState<OrgUser[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [orgState, fetchOrg] = useAsyncFn(() => getOrg(orgId), []); const [orgState, fetchOrg] = useAsyncFn(() => getOrg(orgId), []);
const [, fetchOrgUsers] = useAsyncFn(() => getOrgUsers(orgId), []); const [, fetchOrgUsers] = useAsyncFn(async (page) => {
const result = await getOrgUsers(orgId, page);
const totalPages = result?.perPage !== 0 ? Math.ceil(result.totalCount / result.perPage) : 0;
setTotalPages(totalPages);
setUsers(result.orgUsers);
return result.orgUsers;
}, []);
useEffect(() => { useEffect(() => {
fetchOrg(); fetchOrg();
fetchOrgUsers().then((res) => setUsers(res)); fetchOrgUsers(page);
}, [fetchOrg, fetchOrgUsers]); }, [fetchOrg, fetchOrgUsers, page]);
const updateOrgName = async (name: string) => { const updateOrgName = async (name: string) => {
return await getBackendSrv().put('/api/orgs/' + orgId, { ...orgState.value, name }); return await getBackendSrv().put(`/api/orgs/${orgId}`, { ...orgState.value, name });
}; };
const renderMissingUserListRightsMessage = () => { const renderMissingPermissionMessage = () => (
return (
<Alert severity="info" title="Access denied"> <Alert severity="info" title="Access denied">
You do not have permission to see users in this organization. To update this organization, contact your server You do not have permission to see users in this organization. To update this organization, contact your server
administrator. administrator.
</Alert> </Alert>
); );
const onPageChange = (toPage: number) => {
setPage(toPage);
};
const onRemoveUser = async (orgUser: OrgUser) => {
await removeOrgUser(orgUser, orgId);
fetchOrgUsers(page);
};
const onRoleChange = async (role: OrgRole, orgUser: OrgUser) => {
await updateOrgUserRole({ ...orgUser, role }, orgId);
fetchOrgUsers(page);
}; };
const pageNav: NavModelItem = { const pageNav: NavModelItem = {
@ -81,7 +102,7 @@ export default function AdminEditOrgPage({ match }: Props) {
{orgState.value && ( {orgState.value && (
<Form <Form
defaultValues={{ orgName: orgState.value.name }} defaultValues={{ orgName: orgState.value.name }}
onSubmit={async (values: OrgNameDTO) => await updateOrgName(values.orgName)} onSubmit={(values: OrgNameDTO) => updateOrgName(values.orgName)}
> >
{({ register, errors }) => ( {({ register, errors }) => (
<> <>
@ -96,39 +117,27 @@ export default function AdminEditOrgPage({ match }: Props) {
</Form> </Form>
)} )}
<div <div style={{ marginTop: '20px' }}>
className={css`
margin-top: 20px;
`}
>
<Legend>Organization users</Legend> <Legend>Organization users</Legend>
{!canReadUsers && renderMissingUserListRightsMessage()} {!canReadUsers && renderMissingPermissionMessage()}
{canReadUsers && !!users.length && ( {canReadUsers && !!users.length && (
<UsersTable <VerticalGroup spacing="md">
users={users} <UsersTable users={users} orgId={orgId} onRoleChange={onRoleChange} onRemoveUser={onRemoveUser} />
orgId={orgId} <HorizontalGroup justify="flex-end">
onRoleChange={(role, orgUser) => { <Pagination
updateOrgUserRole({ ...orgUser, role }, orgId); onNavigate={onPageChange}
setUsers( currentPage={page}
users.map((user) => { numberOfPages={totalPages}
if (orgUser.userId === user.userId) { hideWhenSinglePage={true}
return { ...orgUser, role };
}
return user;
})
);
fetchOrgUsers();
}}
onRemoveUser={(orgUser) => {
removeOrgUser(orgUser, orgId);
setUsers(users.filter((user) => orgUser.userId !== user.userId));
fetchOrgUsers();
}}
/> />
</HorizontalGroup>
</VerticalGroup>
)} )}
</div> </div>
</> </>
</Page.Contents> </Page.Contents>
</Page> </Page>
); );
} };
export default AdminEditOrgPage;

View File

@ -2,8 +2,8 @@ import { render, screen } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { mockToolkitActionCreator } from 'test/core/redux/mocks'; import { mockToolkitActionCreator } from 'test/core/redux/mocks';
import { Props, UsersActionBar } from './UsersActionBar'; import { Props, UsersActionBarUnconnected } from './UsersActionBar';
import { setUsersSearchQuery } from './state/reducers'; import { searchQueryChanged } from './state/reducers';
jest.mock('app/core/core', () => ({ jest.mock('app/core/core', () => ({
contextSrv: { contextSrv: {
@ -15,7 +15,7 @@ jest.mock('app/core/core', () => ({
const setup = (propOverrides?: object) => { const setup = (propOverrides?: object) => {
const props: Props = { const props: Props = {
searchQuery: '', searchQuery: '',
setUsersSearchQuery: mockToolkitActionCreator(setUsersSearchQuery), changeSearchQuery: mockToolkitActionCreator(searchQueryChanged),
onShowInvites: jest.fn(), onShowInvites: jest.fn(),
pendingInvitesCount: 0, pendingInvitesCount: 0,
canInvite: false, canInvite: false,
@ -26,7 +26,7 @@ const setup = (propOverrides?: object) => {
Object.assign(props, propOverrides); Object.assign(props, propOverrides);
const { rerender } = render(<UsersActionBar {...props} />); const { rerender } = render(<UsersActionBarUnconnected {...props} />);
return { rerender, props }; return { rerender, props };
}; };

View File

@ -1,5 +1,5 @@
import React, { PureComponent } from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import { RadioButtonGroup, LinkButton, FilterInput } from '@grafana/ui'; import { RadioButtonGroup, LinkButton, FilterInput } from '@grafana/ui';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
@ -7,32 +7,42 @@ import { AccessControlAction, StoreState } from 'app/types';
import { selectTotal } from '../invites/state/selectors'; import { selectTotal } from '../invites/state/selectors';
import { setUsersSearchQuery } from './state/reducers'; import { changeSearchQuery } from './state/actions';
import { getUsersSearchQuery } from './state/selectors'; import { getUsersSearchQuery } from './state/selectors';
export interface Props { export interface OwnProps {
searchQuery: string;
setUsersSearchQuery: typeof setUsersSearchQuery;
onShowInvites: () => void;
pendingInvitesCount: number;
canInvite: boolean;
showInvites: boolean; showInvites: boolean;
externalUserMngLinkUrl: string; onShowInvites: () => void;
externalUserMngLinkName: string;
} }
export class UsersActionBar extends PureComponent<Props> { function mapStateToProps(state: StoreState) {
render() { return {
const { searchQuery: getUsersSearchQuery(state.users),
pendingInvitesCount: selectTotal(state.invites),
externalUserMngLinkName: state.users.externalUserMngLinkName,
externalUserMngLinkUrl: state.users.externalUserMngLinkUrl,
canInvite: state.users.canInvite,
};
}
const mapDispatchToProps = {
changeSearchQuery,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = ConnectedProps<typeof connector> & OwnProps;
export const UsersActionBarUnconnected = ({
canInvite, canInvite,
externalUserMngLinkName, externalUserMngLinkName,
externalUserMngLinkUrl, externalUserMngLinkUrl,
searchQuery, searchQuery,
pendingInvitesCount, pendingInvitesCount,
setUsersSearchQuery, changeSearchQuery,
onShowInvites, onShowInvites,
showInvites, showInvites,
} = this.props; }: Props): JSX.Element => {
const options = [ const options = [
{ label: 'Users', value: 'users' }, { label: 'Users', value: 'users' },
{ label: `Pending Invites (${pendingInvitesCount})`, value: 'invites' }, { label: `Pending Invites (${pendingInvitesCount})`, value: 'invites' },
@ -44,7 +54,7 @@ export class UsersActionBar extends PureComponent<Props> {
<div className="gf-form gf-form--grow"> <div className="gf-form gf-form--grow">
<FilterInput <FilterInput
value={searchQuery} value={searchQuery}
onChange={setUsersSearchQuery} onChange={changeSearchQuery}
placeholder="Search user by login, email or name" placeholder="Search user by login, email or name"
/> />
</div> </div>
@ -61,21 +71,6 @@ export class UsersActionBar extends PureComponent<Props> {
)} )}
</div> </div>
); );
}
}
function mapStateToProps(state: StoreState) {
return {
searchQuery: getUsersSearchQuery(state.users),
pendingInvitesCount: selectTotal(state.invites),
externalUserMngLinkName: state.users.externalUserMngLinkName,
externalUserMngLinkUrl: state.users.externalUserMngLinkUrl,
canInvite: state.users.canInvite,
};
}
const mapDispatchToProps = {
setUsersSearchQuery,
}; };
export default connect(mapStateToProps, mapDispatchToProps)(UsersActionBar); export const UsersActionBar = connector(UsersActionBarUnconnected);

View File

@ -7,7 +7,7 @@ import { configureStore } from 'app/store/configureStore';
import { Invitee, OrgUser } from 'app/types'; import { Invitee, OrgUser } from 'app/types';
import { Props, UsersListPageUnconnected } from './UsersListPage'; import { Props, UsersListPageUnconnected } from './UsersListPage';
import { setUsersSearchPage, setUsersSearchQuery } from './state/reducers'; import { pageChanged } from './state/reducers';
jest.mock('../../core/app_events', () => ({ jest.mock('../../core/app_events', () => ({
emit: jest.fn(), emit: jest.fn(),
@ -27,15 +27,16 @@ const setup = (propOverrides?: object) => {
users: [] as OrgUser[], users: [] as OrgUser[],
invitees: [] as Invitee[], invitees: [] as Invitee[],
searchQuery: '', searchQuery: '',
searchPage: 1, page: 1,
totalPages: 1,
perPage: 30,
externalUserMngInfo: '', externalUserMngInfo: '',
fetchInvitees: jest.fn(), fetchInvitees: jest.fn(),
loadUsers: jest.fn(), loadUsers: jest.fn(),
updateUser: jest.fn(), updateUser: jest.fn(),
removeUser: jest.fn(), removeUser: jest.fn(),
setUsersSearchQuery: mockToolkitActionCreator(setUsersSearchQuery), changePage: mockToolkitActionCreator(pageChanged),
setUsersSearchPage: mockToolkitActionCreator(setUsersSearchPage), isLoading: false,
hasFetched: false,
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);

View File

@ -1,4 +1,4 @@
import React, { PureComponent } from 'react'; import React, { useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import { renderMarkdown } from '@grafana/data'; import { renderMarkdown } from '@grafana/data';
@ -11,29 +11,29 @@ import InviteesTable from '../invites/InviteesTable';
import { fetchInvitees } from '../invites/state/actions'; import { fetchInvitees } from '../invites/state/actions';
import { selectInvitesMatchingQuery } from '../invites/state/selectors'; import { selectInvitesMatchingQuery } from '../invites/state/selectors';
import UsersActionBar from './UsersActionBar'; import { UsersActionBar } from './UsersActionBar';
import UsersTable from './UsersTable'; import { UsersTable } from './UsersTable';
import { loadUsers, removeUser, updateUser } from './state/actions'; import { loadUsers, removeUser, updateUser, changePage } from './state/actions';
import { setUsersSearchQuery, setUsersSearchPage } from './state/reducers'; import { getUsers, getUsersSearchQuery } from './state/selectors';
import { getUsers, getUsersSearchQuery, getUsersSearchPage } from './state/selectors';
function mapStateToProps(state: StoreState) { function mapStateToProps(state: StoreState) {
const searchQuery = getUsersSearchQuery(state.users); const searchQuery = getUsersSearchQuery(state.users);
return { return {
users: getUsers(state.users), users: getUsers(state.users),
searchQuery: getUsersSearchQuery(state.users), searchQuery: getUsersSearchQuery(state.users),
searchPage: getUsersSearchPage(state.users), page: state.users.page,
totalPages: state.users.totalPages,
perPage: state.users.perPage,
invitees: selectInvitesMatchingQuery(state.invites, searchQuery), invitees: selectInvitesMatchingQuery(state.invites, searchQuery),
externalUserMngInfo: state.users.externalUserMngInfo, externalUserMngInfo: state.users.externalUserMngInfo,
hasFetched: state.users.hasFetched, isLoading: state.users.isLoading,
}; };
} }
const mapDispatchToProps = { const mapDispatchToProps = {
loadUsers, loadUsers,
fetchInvitees, fetchInvitees,
setUsersSearchQuery, changePage,
setUsersSearchPage,
updateUser, updateUser,
removeUser, removeUser,
}; };
@ -46,73 +46,51 @@ export interface State {
showInvites: boolean; showInvites: boolean;
} }
const pageLimit = 30; export const UsersListPageUnconnected = ({
users,
page,
totalPages,
invitees,
externalUserMngInfo,
isLoading,
loadUsers,
fetchInvitees,
changePage,
updateUser,
removeUser,
}: Props): JSX.Element => {
const [showInvites, setShowInvites] = useState(false);
const externalUserMngInfoHtml = externalUserMngInfo ? renderMarkdown(externalUserMngInfo) : '';
export class UsersListPageUnconnected extends PureComponent<Props, State> { useEffect(() => {
declare externalUserMngInfoHtml: string; loadUsers();
fetchInvitees();
}, [fetchInvitees, loadUsers]);
constructor(props: Props) { const onRoleChange = (role: OrgRole, user: OrgUser) => {
super(props); updateUser({ ...user, role: role });
if (this.props.externalUserMngInfo) {
this.externalUserMngInfoHtml = renderMarkdown(this.props.externalUserMngInfo);
}
this.state = {
showInvites: false,
};
}
componentDidMount() {
this.fetchUsers();
this.fetchInvitees();
}
async fetchUsers() {
return await this.props.loadUsers();
}
async fetchInvitees() {
return await this.props.fetchInvitees();
}
onRoleChange = (role: OrgRole, user: OrgUser) => {
const updatedUser = { ...user, role: role };
this.props.updateUser(updatedUser);
}; };
onShowInvites = () => { const onShowInvites = () => {
this.setState((prevState) => ({ setShowInvites(!showInvites);
showInvites: !prevState.showInvites,
}));
}; };
getPaginatedUsers = (users: OrgUser[]) => { const renderTable = () => {
const offset = (this.props.searchPage - 1) * pageLimit; if (showInvites) {
return users.slice(offset, offset + pageLimit);
};
renderTable() {
const { invitees, users, setUsersSearchPage } = this.props;
const paginatedUsers = this.getPaginatedUsers(users);
const totalPages = Math.ceil(users.length / pageLimit);
if (this.state.showInvites) {
return <InviteesTable invitees={invitees} />; return <InviteesTable invitees={invitees} />;
} else { } else {
return ( return (
<VerticalGroup spacing="md"> <VerticalGroup spacing="md">
<UsersTable <UsersTable
users={paginatedUsers} users={users}
orgId={contextSrv.user.orgId} orgId={contextSrv.user.orgId}
onRoleChange={(role, user) => this.onRoleChange(role, user)} onRoleChange={(role, user) => onRoleChange(role, user)}
onRemoveUser={(user) => this.props.removeUser(user.userId)} onRemoveUser={(user) => removeUser(user.userId)}
/> />
<HorizontalGroup justify="flex-end"> <HorizontalGroup justify="flex-end">
<Pagination <Pagination
onNavigate={setUsersSearchPage} onNavigate={changePage}
currentPage={this.props.searchPage} currentPage={page}
numberOfPages={totalPages} numberOfPages={totalPages}
hideWhenSinglePage={true} hideWhenSinglePage={true}
/> />
@ -120,23 +98,18 @@ export class UsersListPageUnconnected extends PureComponent<Props, State> {
</VerticalGroup> </VerticalGroup>
); );
} }
} };
render() {
const { hasFetched } = this.props;
const externalUserMngInfoHtml = this.externalUserMngInfoHtml;
return ( return (
<Page.Contents isLoading={!hasFetched}> <Page.Contents isLoading={!isLoading}>
<UsersActionBar onShowInvites={this.onShowInvites} showInvites={this.state.showInvites} /> <UsersActionBar onShowInvites={onShowInvites} showInvites={showInvites} />
{externalUserMngInfoHtml && ( {externalUserMngInfoHtml && (
<div className="grafana-info-box" dangerouslySetInnerHTML={{ __html: externalUserMngInfoHtml }} /> <div className="grafana-info-box" dangerouslySetInnerHTML={{ __html: externalUserMngInfoHtml }} />
)} )}
{hasFetched && this.renderTable()} {isLoading && renderTable()}
</Page.Contents> </Page.Contents>
); );
} };
}
export const UsersListPageContent = connector(UsersListPageUnconnected); export const UsersListPageContent = connector(UsersListPageUnconnected);

View File

@ -4,7 +4,7 @@ import React from 'react';
import { OrgUser } from 'app/types'; import { OrgUser } from 'app/types';
import UsersTable, { Props } from './UsersTable'; import { UsersTable, Props } from './UsersTable';
import { getMockUsers } from './__mocks__/userMocks'; import { getMockUsers } from './__mocks__/userMocks';
jest.mock('app/core/core', () => ({ jest.mock('app/core/core', () => ({

View File

@ -1,4 +1,4 @@
import React, { FC, useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { OrgRole } from '@grafana/data'; import { OrgRole } from '@grafana/data';
import { Button, ConfirmModal } from '@grafana/ui'; import { Button, ConfirmModal } from '@grafana/ui';
@ -17,8 +17,7 @@ export interface Props {
onRemoveUser: (user: OrgUser) => void; onRemoveUser: (user: OrgUser) => void;
} }
const UsersTable: FC<Props> = (props) => { export const UsersTable = ({ users, orgId, onRoleChange, onRemoveUser }: Props) => {
const { users, orgId, onRoleChange, onRemoveUser } = props;
const [userToRemove, setUserToRemove] = useState<OrgUser | null>(null); const [userToRemove, setUserToRemove] = useState<OrgUser | null>(null);
const [roleOptions, setRoleOptions] = useState<Role[]>([]); const [roleOptions, setRoleOptions] = useState<Role[]>([]);
@ -148,5 +147,3 @@ const UsersTable: FC<Props> = (props) => {
</> </>
); );
}; };
export default UsersTable;

View File

@ -1,6 +1,19 @@
import { OrgRole, OrgUser } from 'app/types'; import { OrgRole, OrgUser } from 'app/types';
export const getMockUsers = (amount: number) => { import { UsersFetchResult, initialState } from '../state/reducers';
export const getFetchUsersMock = (amount: number): UsersFetchResult => {
const users = getMockUsers(amount);
return {
orgUsers: users as OrgUser[],
perPage: initialState.perPage,
page: initialState.page,
totalCount: initialState.totalPages,
};
};
export const getMockUsers = (amount: number): OrgUser[] => {
const users = []; const users = [];
for (let i = 0; i <= amount; i++) { for (let i = 0; i <= amount; i++) {

View File

@ -1,18 +1,30 @@
import { debounce } from 'lodash';
import { getBackendSrv } from '@grafana/runtime'; import { getBackendSrv } from '@grafana/runtime';
import { accessControlQueryParam } from 'app/core/utils/accessControl'; import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { OrgUser } from 'app/types'; import { OrgUser } from 'app/types';
import { ThunkResult } from '../../../types'; import { ThunkResult } from '../../../types';
import { usersLoaded } from './reducers'; import { usersLoaded, pageChanged, usersFetchBegin, usersFetchEnd, searchQueryChanged } from './reducers';
export function loadUsers(): ThunkResult<void> { export function loadUsers(): ThunkResult<void> {
return async (dispatch) => { return async (dispatch, getState) => {
const users = await getBackendSrv().get('/api/org/users', accessControlQueryParam()); try {
const { perPage, page, searchQuery } = getState().users;
const users = await getBackendSrv().get(
`/api/org/users/search`,
accessControlQueryParam({ perpage: perPage, page, query: searchQuery })
);
dispatch(usersLoaded(users)); dispatch(usersLoaded(users));
} catch (error) {
usersFetchEnd();
}
}; };
} }
const fetchUsersWithDebounce = debounce((dispatch) => dispatch(loadUsers()), 300);
export function updateUser(user: OrgUser): ThunkResult<void> { export function updateUser(user: OrgUser): ThunkResult<void> {
return async (dispatch) => { return async (dispatch) => {
await getBackendSrv().patch(`/api/org/users/${user.userId}`, { role: user.role }); await getBackendSrv().patch(`/api/org/users/${user.userId}`, { role: user.role });
@ -26,3 +38,19 @@ export function removeUser(userId: number): ThunkResult<void> {
dispatch(loadUsers()); dispatch(loadUsers());
}; };
} }
export function changePage(page: number): ThunkResult<void> {
return async (dispatch) => {
dispatch(usersFetchBegin());
dispatch(pageChanged(page));
dispatch(loadUsers());
};
}
export function changeSearchQuery(query: string): ThunkResult<void> {
return async (dispatch) => {
dispatch(usersFetchBegin());
dispatch(searchQueryChanged(query));
fetchUsersWithDebounce(dispatch);
};
}

View File

@ -1,28 +1,28 @@
import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { UsersState } from '../../../types'; import { UsersState } from '../../../types';
import { getMockUsers } from '../__mocks__/userMocks'; import { getMockUsers, getFetchUsersMock } from '../__mocks__/userMocks';
import { initialState, setUsersSearchQuery, usersLoaded, usersReducer } from './reducers'; import { initialState, searchQueryChanged, usersLoaded, usersReducer } from './reducers';
describe('usersReducer', () => { describe('usersReducer', () => {
describe('when usersLoaded is dispatched', () => { describe('when usersLoaded is dispatched', () => {
it('then state should be correct', () => { it('then state should be correct', () => {
reducerTester<UsersState>() reducerTester<UsersState>()
.givenReducer(usersReducer, { ...initialState }) .givenReducer(usersReducer, { ...initialState })
.whenActionIsDispatched(usersLoaded(getMockUsers(1))) .whenActionIsDispatched(usersLoaded(getFetchUsersMock(1)))
.thenStateShouldEqual({ .thenStateShouldEqual({
...initialState, ...initialState,
users: getMockUsers(1), users: getMockUsers(1),
hasFetched: true, isLoading: true,
}); });
}); });
}); });
describe('when setUsersSearchQuery is dispatched', () => { describe('when searchQueryChanged is dispatched', () => {
it('then state should be correct', () => { it('then state should be correct', () => {
reducerTester<UsersState>() reducerTester<UsersState>()
.givenReducer(usersReducer, { ...initialState }) .givenReducer(usersReducer, { ...initialState })
.whenActionIsDispatched(setUsersSearchQuery('a query')) .whenActionIsDispatched(searchQueryChanged('a query'))
.thenStateShouldEqual({ .thenStateShouldEqual({
...initialState, ...initialState,
searchQuery: 'a query', searchQuery: 'a query',

View File

@ -6,32 +6,62 @@ import { OrgUser, UsersState } from 'app/types';
export const initialState: UsersState = { export const initialState: UsersState = {
users: [] as OrgUser[], users: [] as OrgUser[],
searchQuery: '', searchQuery: '',
searchPage: 1, page: 0,
perPage: 30,
totalPages: 1,
canInvite: !config.externalUserMngLinkName, canInvite: !config.externalUserMngLinkName,
externalUserMngInfo: config.externalUserMngInfo, externalUserMngInfo: config.externalUserMngInfo,
externalUserMngLinkName: config.externalUserMngLinkName, externalUserMngLinkName: config.externalUserMngLinkName,
externalUserMngLinkUrl: config.externalUserMngLinkUrl, externalUserMngLinkUrl: config.externalUserMngLinkUrl,
hasFetched: false, isLoading: false,
}; };
export interface UsersFetchResult {
orgUsers: OrgUser[];
perPage: number;
page: number;
totalCount: number;
}
const usersSlice = createSlice({ const usersSlice = createSlice({
name: 'users', name: 'users',
initialState, initialState,
reducers: { reducers: {
usersLoaded: (state, action: PayloadAction<OrgUser[]>): UsersState => { usersLoaded: (state, action: PayloadAction<UsersFetchResult>): UsersState => {
return { ...state, hasFetched: true, users: action.payload }; const { totalCount, perPage, page, orgUsers } = action.payload;
const totalPages = Math.ceil(totalCount / perPage);
return {
...state,
isLoading: true,
users: orgUsers,
perPage,
page,
totalPages,
};
}, },
setUsersSearchQuery: (state, action: PayloadAction<string>): UsersState => { searchQueryChanged: (state, action: PayloadAction<string>): UsersState => {
// reset searchPage otherwise search results won't appear // reset searchPage otherwise search results won't appear
return { ...state, searchQuery: action.payload, searchPage: initialState.searchPage }; return { ...state, searchQuery: action.payload, page: initialState.page };
}, },
setUsersSearchPage: (state, action: PayloadAction<number>): UsersState => { setUsersSearchPage: (state, action: PayloadAction<number>): UsersState => {
return { ...state, searchPage: action.payload }; return { ...state, page: action.payload };
},
pageChanged: (state, action: PayloadAction<number>) => ({
...state,
page: action.payload,
}),
usersFetchBegin: (state) => {
return { ...state, isLoading: true };
},
usersFetchEnd: (state) => {
return { ...state, isLoading: false };
}, },
}, },
}); });
export const { setUsersSearchQuery, setUsersSearchPage, usersLoaded } = usersSlice.actions; export const { searchQueryChanged, setUsersSearchPage, usersLoaded, usersFetchBegin, usersFetchEnd, pageChanged } =
usersSlice.actions;
export const usersReducer = usersSlice.reducer; export const usersReducer = usersSlice.reducer;

View File

@ -9,4 +9,3 @@ export const getUsers = (state: UsersState) => {
}; };
export const getUsersSearchQuery = (state: UsersState) => state.searchQuery; export const getUsersSearchQuery = (state: UsersState) => state.searchQuery;
export const getUsersSearchPage = (state: UsersState) => state.searchPage;

View File

@ -68,12 +68,14 @@ export interface Invitee {
export interface UsersState { export interface UsersState {
users: OrgUser[]; users: OrgUser[];
searchQuery: string; searchQuery: string;
searchPage: number;
canInvite: boolean; canInvite: boolean;
externalUserMngLinkUrl: string; externalUserMngLinkUrl: string;
externalUserMngLinkName: string; externalUserMngLinkName: string;
externalUserMngInfo: string; externalUserMngInfo: string;
hasFetched: boolean; isLoading: boolean;
page: number;
perPage: number;
totalPages: number;
} }
export interface UserSession { export interface UserSession {