mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
d44de7f20a
commit
f1b5014efd
@ -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))
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
|
@ -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 };
|
||||||
};
|
};
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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', () => ({
|
||||||
|
@ -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;
|
|
||||||
|
@ -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++) {
|
||||||
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -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',
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
|
@ -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 {
|
||||||
|
Loading…
Reference in New Issue
Block a user