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.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/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.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))
|
||||
|
@ -398,6 +398,7 @@ func setupHTTPServerWithCfgDb(
|
||||
userMock.ExpectedUser = &user.User{ID: 1}
|
||||
orgMock := orgtest.NewOrgServiceFake()
|
||||
orgMock.ExpectedOrg = &org.Org{}
|
||||
orgMock.ExpectedSearchOrgUsersResult = &org.SearchOrgUsersQueryResult{}
|
||||
|
||||
// Defining the accesscontrol service has to be done before registering routes
|
||||
if useFakeAccessControl {
|
||||
|
@ -115,18 +115,18 @@ func (hs *HTTPServer) addOrgUserHelper(c *models.ReqContext, cmd org.AddOrgUserC
|
||||
// 403: forbiddenError
|
||||
// 500: internalServerError
|
||||
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,
|
||||
Query: c.Query("query"),
|
||||
Limit: c.QueryInt("limit"),
|
||||
User: c.SignedInUser,
|
||||
}, c.SignedInUser)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
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
|
||||
@ -144,13 +144,13 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrg(c *models.ReqContext) response.Re
|
||||
// 500: internalServerError
|
||||
|
||||
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,
|
||||
Query: c.Query("query"),
|
||||
Limit: c.QueryInt("limit"),
|
||||
User: c.SignedInUser,
|
||||
DontEnforceAccessControl: !hs.License.FeatureEnabled("accesscontrol.enforcement"),
|
||||
}, c.SignedInUser)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
for _, u := range orgUsers {
|
||||
for _, u := range orgUsersResult.OrgUsers {
|
||||
result = append(result, &dtos.UserLookupDTO{
|
||||
UserID: u.UserID,
|
||||
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)
|
||||
}
|
||||
|
||||
result, err := hs.getOrgUsersHelper(c, &org.GetOrgUsersQuery{
|
||||
result, err := hs.searchOrgUsersHelper(c, &org.SearchOrgUsersQuery{
|
||||
OrgID: orgId,
|
||||
Query: "",
|
||||
Limit: 0,
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) getOrgUsersHelper(c *models.ReqContext, query *org.GetOrgUsersQuery, signedInUser *user.SignedInUser) ([]*org.OrgUserDTO, error) {
|
||||
result, err := hs.orgService.GetOrgUsers(c.Req.Context(), query)
|
||||
// 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 {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filteredUsers := make([]*org.OrgUserDTO, 0, len(result))
|
||||
filteredUsers := make([]*org.OrgUserDTO, 0, len(result.OrgUsers))
|
||||
userIDs := map[string]bool{}
|
||||
authLabelsUserIDs := make([]int64, 0, len(result))
|
||||
for _, user := range result {
|
||||
if dtos.IsHiddenUser(user.Login, signedInUser, hs.Cfg) {
|
||||
authLabelsUserIDs := make([]int64, 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)
|
||||
@ -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.Page = page
|
||||
result.PerPage = perPage
|
||||
|
||||
return response.JSON(http.StatusOK, result)
|
||||
result.Page = query.Page
|
||||
result.PerPage = query.Limit
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// swagger:route PATCH /org/users/{user_id} org updateOrgUserForCurrentOrg
|
||||
|
@ -63,12 +63,15 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||
orgService.ExpectedSearchOrgUsersResult = &org.SearchOrgUsersQueryResult{}
|
||||
hs.orgService = orgService
|
||||
mock := mockstore.NewSQLStoreMock()
|
||||
|
||||
loggedInUserScenario(t, "When calling GET on", "api/org/users", "api/org/users", func(sc *scenarioContext) {
|
||||
setUpGetOrgUsersDB(t, sqlStore)
|
||||
orgService.ExpectedOrgUsers = []*org.OrgUserDTO{
|
||||
{Login: testUserLogin, Email: "testUser@grafana.com"},
|
||||
{Login: "user1", Email: "user1@grafana.com"},
|
||||
{Login: "user2", Email: "user2@grafana.com"},
|
||||
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.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) {
|
||||
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.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
|
@ -162,6 +162,7 @@ type GetOrgUsersQuery struct {
|
||||
UserID int64 `xorm:"user_id"`
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
Query string
|
||||
Page int
|
||||
Limit int
|
||||
// Flag used to allow oss edition to query users without access control
|
||||
DontEnforceAccessControl bool
|
||||
@ -170,17 +171,20 @@ type GetOrgUsersQuery struct {
|
||||
}
|
||||
|
||||
type SearchOrgUsersQuery struct {
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
Query string
|
||||
Page int
|
||||
Limit int
|
||||
UserID int64 `xorm:"user_id"`
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
Query string
|
||||
Page int
|
||||
Limit int
|
||||
// Flag used to allow oss edition to query users without access control
|
||||
DontEnforceAccessControl bool
|
||||
|
||||
User *user.SignedInUser
|
||||
}
|
||||
|
||||
type SearchOrgUsersQueryResult struct {
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
OrgUsers []*OrgUserDTO `json:"OrgUsers"`
|
||||
OrgUsers []*OrgUserDTO `json:"orgUsers"`
|
||||
Page int `json:"page"`
|
||||
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
|
||||
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
|
||||
|
@ -39,7 +39,6 @@ type store interface {
|
||||
CreateWithMember(context.Context, *org.CreateOrgCommand) (*org.Org, error)
|
||||
AddOrgUser(context.Context, *org.AddOrgUserCommand) error
|
||||
UpdateOrgUser(context.Context, *org.UpdateOrgUserCommand) error
|
||||
GetOrgUsers(context.Context, *org.GetOrgUsersQuery) ([]*org.OrgUserDTO, error)
|
||||
GetByID(context.Context, *org.GetOrgByIDQuery) (*org.Org, error)
|
||||
GetByName(context.Context, *org.GetOrgByNameQuery) (*org.Org, error)
|
||||
SearchOrgUsers(context.Context, *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error)
|
||||
@ -512,8 +511,29 @@ func validateOneAdminLeftInOrg(orgID int64, sess *db.Session) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (ss *sqlStore) GetOrgUsers(ctx context.Context, query *org.GetOrgUsersQuery) ([]*org.OrgUserDTO, error) {
|
||||
result := make([]*org.OrgUserDTO, 0)
|
||||
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")))
|
||||
@ -556,7 +576,8 @@ func (ss *sqlStore) GetOrgUsers(ctx context.Context, query *org.GetOrgUsersQuery
|
||||
}
|
||||
|
||||
if query.Limit > 0 {
|
||||
sess.Limit(query.Limit, 0)
|
||||
offset := query.Limit * (query.Page - 1)
|
||||
sess.Limit(query.Limit, offset)
|
||||
}
|
||||
|
||||
sess.Cols(
|
||||
@ -573,92 +594,6 @@ func (ss *sqlStore) GetOrgUsers(ctx context.Context, query *org.GetOrgUsersQuery
|
||||
)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
@ -327,35 +327,35 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) {
|
||||
err = orgUserStore.UpdateOrgUser(context.Background(), &updateCmd)
|
||||
require.NoError(t, err)
|
||||
|
||||
orgUsersQuery := org.GetOrgUsersQuery{
|
||||
orgUsersQuery := org.SearchOrgUsersQuery{
|
||||
OrgID: ac1.OrgID,
|
||||
User: &user.SignedInUser{
|
||||
OrgID: ac1.OrgID,
|
||||
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.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) {
|
||||
query := org.GetOrgUsersQuery{
|
||||
query := org.SearchOrgUsersQuery{
|
||||
OrgID: ac1.OrgID,
|
||||
User: &user.SignedInUser{
|
||||
OrgID: ac1.OrgID,
|
||||
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.Equal(t, len(result), 2)
|
||||
require.Equal(t, result[0].Role, "Admin")
|
||||
require.Equal(t, len(result.OrgUsers), 2)
|
||||
require.Equal(t, result.OrgUsers[0].Role, "Admin")
|
||||
})
|
||||
|
||||
t.Run("Can get organization users with query", func(t *testing.T) {
|
||||
query := org.GetOrgUsersQuery{
|
||||
query := org.SearchOrgUsersQuery{
|
||||
OrgID: ac1.OrgID,
|
||||
Query: "ac1",
|
||||
User: &user.SignedInUser{
|
||||
@ -363,14 +363,14 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) {
|
||||
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.Equal(t, len(result), 1)
|
||||
require.Equal(t, result[0].Email, ac1.Email)
|
||||
require.Equal(t, len(result.OrgUsers), 1)
|
||||
require.Equal(t, result.OrgUsers[0].Email, ac1.Email)
|
||||
})
|
||||
t.Run("Can get organization users with query and limit", func(t *testing.T) {
|
||||
query := org.GetOrgUsersQuery{
|
||||
query := org.SearchOrgUsersQuery{
|
||||
OrgID: ac1.OrgID,
|
||||
Query: "ac",
|
||||
Limit: 1,
|
||||
@ -379,11 +379,11 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) {
|
||||
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.Equal(t, len(result), 1)
|
||||
require.Equal(t, result[0].Email, ac1.Email)
|
||||
require.Equal(t, len(result.OrgUsers), 1)
|
||||
require.Equal(t, result.OrgUsers[0].Email, ac1.Email)
|
||||
})
|
||||
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}
|
||||
@ -532,12 +532,12 @@ func TestIntegration_SQLStore_GetOrgUsers(t *testing.T) {
|
||||
}
|
||||
tests := []struct {
|
||||
desc string
|
||||
query *org.GetOrgUsersQuery
|
||||
query *org.SearchOrgUsersQuery
|
||||
expectedNumUsers int
|
||||
}{
|
||||
{
|
||||
desc: "should return all users",
|
||||
query: &org.GetOrgUsersQuery{
|
||||
query: &org.SearchOrgUsersQuery{
|
||||
OrgID: 1,
|
||||
User: &user.SignedInUser{
|
||||
OrgID: 1,
|
||||
@ -548,7 +548,7 @@ func TestIntegration_SQLStore_GetOrgUsers(t *testing.T) {
|
||||
},
|
||||
{
|
||||
desc: "should return no users",
|
||||
query: &org.GetOrgUsersQuery{
|
||||
query: &org.SearchOrgUsersQuery{
|
||||
OrgID: 1,
|
||||
User: &user.SignedInUser{
|
||||
OrgID: 1,
|
||||
@ -559,7 +559,7 @@ func TestIntegration_SQLStore_GetOrgUsers(t *testing.T) {
|
||||
},
|
||||
{
|
||||
desc: "should return some users",
|
||||
query: &org.GetOrgUsersQuery{
|
||||
query: &org.SearchOrgUsersQuery{
|
||||
OrgID: 1,
|
||||
User: &user.SignedInUser{
|
||||
OrgID: 1,
|
||||
@ -589,12 +589,12 @@ func TestIntegration_SQLStore_GetOrgUsers(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
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.Len(t, result, tt.expectedNumUsers)
|
||||
require.Len(t, result.OrgUsers, tt.expectedNumUsers)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@ -675,7 +675,7 @@ func TestIntegration_SQLStore_GetOrgUsers_PopulatesCorrectly(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
query := &org.GetOrgUsersQuery{
|
||||
query := &org.SearchOrgUsersQuery{
|
||||
OrgID: 1,
|
||||
UserID: newUser.ID,
|
||||
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}}},
|
||||
},
|
||||
}
|
||||
result, err := orgUserStore.GetOrgUsers(context.Background(), query)
|
||||
result, err := orgUserStore.SearchOrgUsers(context.Background(), query)
|
||||
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.UserID)
|
||||
assert.Equal(t, "viewer@localhost", actual.Email)
|
||||
|
@ -6,8 +6,8 @@ import (
|
||||
context "context"
|
||||
|
||||
models "github.com/grafana/grafana/pkg/models"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// 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
|
||||
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)
|
||||
|
||||
var r0 error
|
||||
|
@ -1,69 +1,90 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
import { NavModelItem, UrlQueryValue } from '@grafana/data';
|
||||
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 { contextSrv } from 'app/core/core';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
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 {
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
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)) {
|
||||
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) => {
|
||||
await getBackendSrv().patch('/api/orgs/' + orgId + '/users/' + orgUser.userId, orgUser);
|
||||
const updateOrgUserRole = (orgUser: OrgUser, orgId: UrlQueryValue) => {
|
||||
return getBackendSrv().patch(`/api/orgs/${orgId}/users/${orgUser.userId}`, orgUser);
|
||||
};
|
||||
|
||||
const removeOrgUser = async (orgUser: OrgUser, orgId: UrlQueryValue) => {
|
||||
return await getBackendSrv().delete('/api/orgs/' + orgId + '/users/' + orgUser.userId);
|
||||
const removeOrgUser = (orgUser: OrgUser, orgId: UrlQueryValue) => {
|
||||
return getBackendSrv().delete(`/api/orgs/${orgId}/users/${orgUser.userId}`);
|
||||
};
|
||||
|
||||
interface Props extends GrafanaRouteComponentProps<{ id: string }> {}
|
||||
|
||||
export default function AdminEditOrgPage({ match }: Props) {
|
||||
const AdminEditOrgPage = ({ match }: Props) => {
|
||||
const orgId = parseInt(match.params.id, 10);
|
||||
const canWriteOrg = contextSrv.hasPermission(AccessControlAction.OrgsWrite);
|
||||
const canReadUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead);
|
||||
|
||||
const [users, setUsers] = useState<OrgUser[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
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(() => {
|
||||
fetchOrg();
|
||||
fetchOrgUsers().then((res) => setUsers(res));
|
||||
}, [fetchOrg, fetchOrgUsers]);
|
||||
fetchOrgUsers(page);
|
||||
}, [fetchOrg, fetchOrgUsers, page]);
|
||||
|
||||
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 = () => {
|
||||
return (
|
||||
<Alert severity="info" title="Access denied">
|
||||
You do not have permission to see users in this organization. To update this organization, contact your server
|
||||
administrator.
|
||||
</Alert>
|
||||
);
|
||||
const renderMissingPermissionMessage = () => (
|
||||
<Alert severity="info" title="Access denied">
|
||||
You do not have permission to see users in this organization. To update this organization, contact your server
|
||||
administrator.
|
||||
</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 = {
|
||||
@ -81,7 +102,7 @@ export default function AdminEditOrgPage({ match }: Props) {
|
||||
{orgState.value && (
|
||||
<Form
|
||||
defaultValues={{ orgName: orgState.value.name }}
|
||||
onSubmit={async (values: OrgNameDTO) => await updateOrgName(values.orgName)}
|
||||
onSubmit={(values: OrgNameDTO) => updateOrgName(values.orgName)}
|
||||
>
|
||||
{({ register, errors }) => (
|
||||
<>
|
||||
@ -96,39 +117,27 @@ export default function AdminEditOrgPage({ match }: Props) {
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={css`
|
||||
margin-top: 20px;
|
||||
`}
|
||||
>
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Legend>Organization users</Legend>
|
||||
{!canReadUsers && renderMissingUserListRightsMessage()}
|
||||
{!canReadUsers && renderMissingPermissionMessage()}
|
||||
{canReadUsers && !!users.length && (
|
||||
<UsersTable
|
||||
users={users}
|
||||
orgId={orgId}
|
||||
onRoleChange={(role, orgUser) => {
|
||||
updateOrgUserRole({ ...orgUser, role }, orgId);
|
||||
setUsers(
|
||||
users.map((user) => {
|
||||
if (orgUser.userId === user.userId) {
|
||||
return { ...orgUser, role };
|
||||
}
|
||||
return user;
|
||||
})
|
||||
);
|
||||
fetchOrgUsers();
|
||||
}}
|
||||
onRemoveUser={(orgUser) => {
|
||||
removeOrgUser(orgUser, orgId);
|
||||
setUsers(users.filter((user) => orgUser.userId !== user.userId));
|
||||
fetchOrgUsers();
|
||||
}}
|
||||
/>
|
||||
<VerticalGroup spacing="md">
|
||||
<UsersTable users={users} orgId={orgId} onRoleChange={onRoleChange} onRemoveUser={onRemoveUser} />
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Pagination
|
||||
onNavigate={onPageChange}
|
||||
currentPage={page}
|
||||
numberOfPages={totalPages}
|
||||
hideWhenSinglePage={true}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default AdminEditOrgPage;
|
||||
|
@ -2,8 +2,8 @@ import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { mockToolkitActionCreator } from 'test/core/redux/mocks';
|
||||
|
||||
import { Props, UsersActionBar } from './UsersActionBar';
|
||||
import { setUsersSearchQuery } from './state/reducers';
|
||||
import { Props, UsersActionBarUnconnected } from './UsersActionBar';
|
||||
import { searchQueryChanged } from './state/reducers';
|
||||
|
||||
jest.mock('app/core/core', () => ({
|
||||
contextSrv: {
|
||||
@ -15,7 +15,7 @@ jest.mock('app/core/core', () => ({
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
searchQuery: '',
|
||||
setUsersSearchQuery: mockToolkitActionCreator(setUsersSearchQuery),
|
||||
changeSearchQuery: mockToolkitActionCreator(searchQueryChanged),
|
||||
onShowInvites: jest.fn(),
|
||||
pendingInvitesCount: 0,
|
||||
canInvite: false,
|
||||
@ -26,7 +26,7 @@ const setup = (propOverrides?: object) => {
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const { rerender } = render(<UsersActionBar {...props} />);
|
||||
const { rerender } = render(<UsersActionBarUnconnected {...props} />);
|
||||
|
||||
return { rerender, props };
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import React from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { RadioButtonGroup, LinkButton, FilterInput } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
@ -7,61 +7,12 @@ import { AccessControlAction, StoreState } from 'app/types';
|
||||
|
||||
import { selectTotal } from '../invites/state/selectors';
|
||||
|
||||
import { setUsersSearchQuery } from './state/reducers';
|
||||
import { changeSearchQuery } from './state/actions';
|
||||
import { getUsersSearchQuery } from './state/selectors';
|
||||
|
||||
export interface Props {
|
||||
searchQuery: string;
|
||||
setUsersSearchQuery: typeof setUsersSearchQuery;
|
||||
onShowInvites: () => void;
|
||||
pendingInvitesCount: number;
|
||||
canInvite: boolean;
|
||||
export interface OwnProps {
|
||||
showInvites: boolean;
|
||||
externalUserMngLinkUrl: string;
|
||||
externalUserMngLinkName: string;
|
||||
}
|
||||
|
||||
export class UsersActionBar extends PureComponent<Props> {
|
||||
render() {
|
||||
const {
|
||||
canInvite,
|
||||
externalUserMngLinkName,
|
||||
externalUserMngLinkUrl,
|
||||
searchQuery,
|
||||
pendingInvitesCount,
|
||||
setUsersSearchQuery,
|
||||
onShowInvites,
|
||||
showInvites,
|
||||
} = this.props;
|
||||
const options = [
|
||||
{ label: 'Users', value: 'users' },
|
||||
{ label: `Pending Invites (${pendingInvitesCount})`, value: 'invites' },
|
||||
];
|
||||
const canAddToOrg: boolean = contextSrv.hasAccess(AccessControlAction.OrgUsersAdd, canInvite);
|
||||
|
||||
return (
|
||||
<div className="page-action-bar" data-testid="users-action-bar">
|
||||
<div className="gf-form gf-form--grow">
|
||||
<FilterInput
|
||||
value={searchQuery}
|
||||
onChange={setUsersSearchQuery}
|
||||
placeholder="Search user by login, email or name"
|
||||
/>
|
||||
</div>
|
||||
{pendingInvitesCount > 0 && (
|
||||
<div style={{ marginLeft: '1rem' }}>
|
||||
<RadioButtonGroup value={showInvites ? 'invites' : 'users'} options={options} onChange={onShowInvites} />
|
||||
</div>
|
||||
)}
|
||||
{canAddToOrg && <LinkButton href="org/users/invite">Invite</LinkButton>}
|
||||
{externalUserMngLinkUrl && (
|
||||
<LinkButton href={externalUserMngLinkUrl} target="_blank" rel="noopener">
|
||||
{externalUserMngLinkName}
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
onShowInvites: () => void;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: StoreState) {
|
||||
@ -75,7 +26,51 @@ function mapStateToProps(state: StoreState) {
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setUsersSearchQuery,
|
||||
changeSearchQuery,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(UsersActionBar);
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export type Props = ConnectedProps<typeof connector> & OwnProps;
|
||||
|
||||
export const UsersActionBarUnconnected = ({
|
||||
canInvite,
|
||||
externalUserMngLinkName,
|
||||
externalUserMngLinkUrl,
|
||||
searchQuery,
|
||||
pendingInvitesCount,
|
||||
changeSearchQuery,
|
||||
onShowInvites,
|
||||
showInvites,
|
||||
}: Props): JSX.Element => {
|
||||
const options = [
|
||||
{ label: 'Users', value: 'users' },
|
||||
{ label: `Pending Invites (${pendingInvitesCount})`, value: 'invites' },
|
||||
];
|
||||
const canAddToOrg: boolean = contextSrv.hasAccess(AccessControlAction.OrgUsersAdd, canInvite);
|
||||
|
||||
return (
|
||||
<div className="page-action-bar" data-testid="users-action-bar">
|
||||
<div className="gf-form gf-form--grow">
|
||||
<FilterInput
|
||||
value={searchQuery}
|
||||
onChange={changeSearchQuery}
|
||||
placeholder="Search user by login, email or name"
|
||||
/>
|
||||
</div>
|
||||
{pendingInvitesCount > 0 && (
|
||||
<div style={{ marginLeft: '1rem' }}>
|
||||
<RadioButtonGroup value={showInvites ? 'invites' : 'users'} options={options} onChange={onShowInvites} />
|
||||
</div>
|
||||
)}
|
||||
{canAddToOrg && <LinkButton href="org/users/invite">Invite</LinkButton>}
|
||||
{externalUserMngLinkUrl && (
|
||||
<LinkButton href={externalUserMngLinkUrl} target="_blank" rel="noopener">
|
||||
{externalUserMngLinkName}
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const UsersActionBar = connector(UsersActionBarUnconnected);
|
||||
|
@ -7,7 +7,7 @@ import { configureStore } from 'app/store/configureStore';
|
||||
import { Invitee, OrgUser } from 'app/types';
|
||||
|
||||
import { Props, UsersListPageUnconnected } from './UsersListPage';
|
||||
import { setUsersSearchPage, setUsersSearchQuery } from './state/reducers';
|
||||
import { pageChanged } from './state/reducers';
|
||||
|
||||
jest.mock('../../core/app_events', () => ({
|
||||
emit: jest.fn(),
|
||||
@ -27,15 +27,16 @@ const setup = (propOverrides?: object) => {
|
||||
users: [] as OrgUser[],
|
||||
invitees: [] as Invitee[],
|
||||
searchQuery: '',
|
||||
searchPage: 1,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
perPage: 30,
|
||||
externalUserMngInfo: '',
|
||||
fetchInvitees: jest.fn(),
|
||||
loadUsers: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
removeUser: jest.fn(),
|
||||
setUsersSearchQuery: mockToolkitActionCreator(setUsersSearchQuery),
|
||||
setUsersSearchPage: mockToolkitActionCreator(setUsersSearchPage),
|
||||
hasFetched: false,
|
||||
changePage: mockToolkitActionCreator(pageChanged),
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
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 { renderMarkdown } from '@grafana/data';
|
||||
@ -11,29 +11,29 @@ import InviteesTable from '../invites/InviteesTable';
|
||||
import { fetchInvitees } from '../invites/state/actions';
|
||||
import { selectInvitesMatchingQuery } from '../invites/state/selectors';
|
||||
|
||||
import UsersActionBar from './UsersActionBar';
|
||||
import UsersTable from './UsersTable';
|
||||
import { loadUsers, removeUser, updateUser } from './state/actions';
|
||||
import { setUsersSearchQuery, setUsersSearchPage } from './state/reducers';
|
||||
import { getUsers, getUsersSearchQuery, getUsersSearchPage } from './state/selectors';
|
||||
import { UsersActionBar } from './UsersActionBar';
|
||||
import { UsersTable } from './UsersTable';
|
||||
import { loadUsers, removeUser, updateUser, changePage } from './state/actions';
|
||||
import { getUsers, getUsersSearchQuery } from './state/selectors';
|
||||
|
||||
function mapStateToProps(state: StoreState) {
|
||||
const searchQuery = getUsersSearchQuery(state.users);
|
||||
return {
|
||||
users: getUsers(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),
|
||||
externalUserMngInfo: state.users.externalUserMngInfo,
|
||||
hasFetched: state.users.hasFetched,
|
||||
isLoading: state.users.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadUsers,
|
||||
fetchInvitees,
|
||||
setUsersSearchQuery,
|
||||
setUsersSearchPage,
|
||||
changePage,
|
||||
updateUser,
|
||||
removeUser,
|
||||
};
|
||||
@ -46,73 +46,51 @@ export interface State {
|
||||
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> {
|
||||
declare externalUserMngInfoHtml: string;
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
fetchInvitees();
|
||||
}, [fetchInvitees, loadUsers]);
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
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);
|
||||
const onRoleChange = (role: OrgRole, user: OrgUser) => {
|
||||
updateUser({ ...user, role: role });
|
||||
};
|
||||
|
||||
onShowInvites = () => {
|
||||
this.setState((prevState) => ({
|
||||
showInvites: !prevState.showInvites,
|
||||
}));
|
||||
const onShowInvites = () => {
|
||||
setShowInvites(!showInvites);
|
||||
};
|
||||
|
||||
getPaginatedUsers = (users: OrgUser[]) => {
|
||||
const offset = (this.props.searchPage - 1) * pageLimit;
|
||||
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) {
|
||||
const renderTable = () => {
|
||||
if (showInvites) {
|
||||
return <InviteesTable invitees={invitees} />;
|
||||
} else {
|
||||
return (
|
||||
<VerticalGroup spacing="md">
|
||||
<UsersTable
|
||||
users={paginatedUsers}
|
||||
users={users}
|
||||
orgId={contextSrv.user.orgId}
|
||||
onRoleChange={(role, user) => this.onRoleChange(role, user)}
|
||||
onRemoveUser={(user) => this.props.removeUser(user.userId)}
|
||||
onRoleChange={(role, user) => onRoleChange(role, user)}
|
||||
onRemoveUser={(user) => removeUser(user.userId)}
|
||||
/>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Pagination
|
||||
onNavigate={setUsersSearchPage}
|
||||
currentPage={this.props.searchPage}
|
||||
onNavigate={changePage}
|
||||
currentPage={page}
|
||||
numberOfPages={totalPages}
|
||||
hideWhenSinglePage={true}
|
||||
/>
|
||||
@ -120,23 +98,18 @@ export class UsersListPageUnconnected extends PureComponent<Props, State> {
|
||||
</VerticalGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { hasFetched } = this.props;
|
||||
const externalUserMngInfoHtml = this.externalUserMngInfoHtml;
|
||||
|
||||
return (
|
||||
<Page.Contents isLoading={!hasFetched}>
|
||||
<UsersActionBar onShowInvites={this.onShowInvites} showInvites={this.state.showInvites} />
|
||||
{externalUserMngInfoHtml && (
|
||||
<div className="grafana-info-box" dangerouslySetInnerHTML={{ __html: externalUserMngInfoHtml }} />
|
||||
)}
|
||||
{hasFetched && this.renderTable()}
|
||||
</Page.Contents>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Page.Contents isLoading={!isLoading}>
|
||||
<UsersActionBar onShowInvites={onShowInvites} showInvites={showInvites} />
|
||||
{externalUserMngInfoHtml && (
|
||||
<div className="grafana-info-box" dangerouslySetInnerHTML={{ __html: externalUserMngInfoHtml }} />
|
||||
)}
|
||||
{isLoading && renderTable()}
|
||||
</Page.Contents>
|
||||
);
|
||||
};
|
||||
|
||||
export const UsersListPageContent = connector(UsersListPageUnconnected);
|
||||
|
||||
|
@ -4,7 +4,7 @@ import React from 'react';
|
||||
|
||||
import { OrgUser } from 'app/types';
|
||||
|
||||
import UsersTable, { Props } from './UsersTable';
|
||||
import { UsersTable, Props } from './UsersTable';
|
||||
import { getMockUsers } from './__mocks__/userMocks';
|
||||
|
||||
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 { Button, ConfirmModal } from '@grafana/ui';
|
||||
@ -17,8 +17,7 @@ export interface Props {
|
||||
onRemoveUser: (user: OrgUser) => void;
|
||||
}
|
||||
|
||||
const UsersTable: FC<Props> = (props) => {
|
||||
const { users, orgId, onRoleChange, onRemoveUser } = props;
|
||||
export const UsersTable = ({ users, orgId, onRoleChange, onRemoveUser }: Props) => {
|
||||
const [userToRemove, setUserToRemove] = useState<OrgUser | null>(null);
|
||||
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';
|
||||
|
||||
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 = [];
|
||||
|
||||
for (let i = 0; i <= amount; i++) {
|
||||
|
@ -1,18 +1,30 @@
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { accessControlQueryParam } from 'app/core/utils/accessControl';
|
||||
import { OrgUser } from 'app/types';
|
||||
|
||||
import { ThunkResult } from '../../../types';
|
||||
|
||||
import { usersLoaded } from './reducers';
|
||||
import { usersLoaded, pageChanged, usersFetchBegin, usersFetchEnd, searchQueryChanged } from './reducers';
|
||||
|
||||
export function loadUsers(): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
const users = await getBackendSrv().get('/api/org/users', accessControlQueryParam());
|
||||
dispatch(usersLoaded(users));
|
||||
return async (dispatch, getState) => {
|
||||
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));
|
||||
} catch (error) {
|
||||
usersFetchEnd();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const fetchUsersWithDebounce = debounce((dispatch) => dispatch(loadUsers()), 300);
|
||||
|
||||
export function updateUser(user: OrgUser): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
await getBackendSrv().patch(`/api/org/users/${user.userId}`, { role: user.role });
|
||||
@ -26,3 +38,19 @@ export function removeUser(userId: number): ThunkResult<void> {
|
||||
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 { 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('when usersLoaded is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<UsersState>()
|
||||
.givenReducer(usersReducer, { ...initialState })
|
||||
.whenActionIsDispatched(usersLoaded(getMockUsers(1)))
|
||||
.whenActionIsDispatched(usersLoaded(getFetchUsersMock(1)))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
users: getMockUsers(1),
|
||||
hasFetched: true,
|
||||
isLoading: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setUsersSearchQuery is dispatched', () => {
|
||||
describe('when searchQueryChanged is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<UsersState>()
|
||||
.givenReducer(usersReducer, { ...initialState })
|
||||
.whenActionIsDispatched(setUsersSearchQuery('a query'))
|
||||
.whenActionIsDispatched(searchQueryChanged('a query'))
|
||||
.thenStateShouldEqual({
|
||||
...initialState,
|
||||
searchQuery: 'a query',
|
||||
|
@ -6,32 +6,62 @@ import { OrgUser, UsersState } from 'app/types';
|
||||
export const initialState: UsersState = {
|
||||
users: [] as OrgUser[],
|
||||
searchQuery: '',
|
||||
searchPage: 1,
|
||||
page: 0,
|
||||
perPage: 30,
|
||||
totalPages: 1,
|
||||
canInvite: !config.externalUserMngLinkName,
|
||||
externalUserMngInfo: config.externalUserMngInfo,
|
||||
externalUserMngLinkName: config.externalUserMngLinkName,
|
||||
externalUserMngLinkUrl: config.externalUserMngLinkUrl,
|
||||
hasFetched: false,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
export interface UsersFetchResult {
|
||||
orgUsers: OrgUser[];
|
||||
perPage: number;
|
||||
page: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const usersSlice = createSlice({
|
||||
name: 'users',
|
||||
initialState,
|
||||
reducers: {
|
||||
usersLoaded: (state, action: PayloadAction<OrgUser[]>): UsersState => {
|
||||
return { ...state, hasFetched: true, users: action.payload };
|
||||
usersLoaded: (state, action: PayloadAction<UsersFetchResult>): UsersState => {
|
||||
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
|
||||
return { ...state, searchQuery: action.payload, searchPage: initialState.searchPage };
|
||||
return { ...state, searchQuery: action.payload, page: initialState.page };
|
||||
},
|
||||
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;
|
||||
|
||||
|
@ -9,4 +9,3 @@ export const getUsers = (state: UsersState) => {
|
||||
};
|
||||
|
||||
export const getUsersSearchQuery = (state: UsersState) => state.searchQuery;
|
||||
export const getUsersSearchPage = (state: UsersState) => state.searchPage;
|
||||
|
@ -68,12 +68,14 @@ export interface Invitee {
|
||||
export interface UsersState {
|
||||
users: OrgUser[];
|
||||
searchQuery: string;
|
||||
searchPage: number;
|
||||
canInvite: boolean;
|
||||
externalUserMngLinkUrl: string;
|
||||
externalUserMngLinkName: string;
|
||||
externalUserMngInfo: string;
|
||||
hasFetched: boolean;
|
||||
isLoading: boolean;
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
|
Loading…
Reference in New Issue
Block a user