diff --git a/pkg/api/api.go b/pkg/api/api.go index c3a3728338d..db7bf1df3fb 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -132,6 +132,13 @@ func (hs *HttpServer) registerRoutes() { r.Post("/:id/using/:orgId", wrap(UpdateUserActiveOrg)) }, reqGrafanaAdmin) + // user group (admin permission required) + r.Group("/user-groups", func() { + r.Get("/search", wrap(SearchUserGroups)) + r.Post("/", quota("user-groups"), bind(m.CreateUserGroupCommand{}), wrap(CreateUserGroup)) + r.Delete("/:userGroupId", wrap(DeleteUserGroupById)) + }, reqGrafanaAdmin) + // org information available to all users. r.Group("/org", func() { r.Get("/", wrap(GetOrgCurrent)) diff --git a/pkg/api/user.go b/pkg/api/user.go index 39e7fce1462..a0a2592cca1 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -218,7 +218,7 @@ func SearchUsers(c *middleware.Context) Response { return Json(200, query.Result.Users) } -// GET /api/search +// GET /api/users/search func SearchUsersWithPaging(c *middleware.Context) Response { query, err := searchUser(c) if err != nil { diff --git a/pkg/api/user_group.go b/pkg/api/user_group.go new file mode 100644 index 00000000000..392fb52ebf1 --- /dev/null +++ b/pkg/api/user_group.go @@ -0,0 +1,66 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/metrics" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +// POST /api/user-groups +func CreateUserGroup(c *middleware.Context, cmd m.CreateUserGroupCommand) Response { + cmd.OrgId = c.OrgId + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrUserGroupNameTaken { + return ApiError(409, "User Group name taken", err) + } + return ApiError(500, "Failed to create User Group", err) + } + + metrics.M_Api_UserGroup_Create.Inc(1) + + return Json(200, &util.DynMap{ + "userGroupId": cmd.Result.Id, + "message": "User Group created", + }) +} + +// DELETE /api/user-groups/:userGroupId +func DeleteUserGroupById(c *middleware.Context) Response { + if err := bus.Dispatch(&m.DeleteUserGroupCommand{Id: c.ParamsInt64(":userGroupId")}); err != nil { + if err == m.ErrUserGroupNotFound { + return ApiError(404, "Failed to delete User Group. ID not found", nil) + } + return ApiError(500, "Failed to update User Group", err) + } + return ApiSuccess("User Group deleted") +} + +// GET /api/user-groups/search +func SearchUserGroups(c *middleware.Context) Response { + perPage := c.QueryInt("perpage") + if perPage <= 0 { + perPage = 1000 + } + page := c.QueryInt("page") + if page < 1 { + page = 1 + } + + query := m.SearchUserGroupsQuery{ + Query: c.Query("query"), + Name: c.Query("name"), + Page: page, + Limit: perPage, + } + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to search User Groups", err) + } + + query.Result.Page = page + query.Result.PerPage = perPage + + return Json(200, query.Result) +} diff --git a/pkg/api/user_group_test.go b/pkg/api/user_group_test.go new file mode 100644 index 00000000000..730461ac8e8 --- /dev/null +++ b/pkg/api/user_group_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestUserGroupApiEndpoint(t *testing.T) { + Convey("Given two user groups", t, func() { + mockResult := models.SearchUserGroupQueryResult{ + UserGroups: []*models.UserGroup{ + {Name: "userGroup1"}, + {Name: "userGroup2"}, + }, + TotalCount: 2, + } + + Convey("When searching with no parameters", func() { + loggedInUserScenario("When calling GET on", "/api/user-groups/search", func(sc *scenarioContext) { + var sentLimit int + var sendPage int + bus.AddHandler("test", func(query *models.SearchUserGroupsQuery) error { + query.Result = mockResult + + sentLimit = query.Limit + sendPage = query.Page + + return nil + }) + + sc.handlerFunc = SearchUserGroups + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + So(sentLimit, ShouldEqual, 1000) + So(sendPage, ShouldEqual, 1) + + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + + So(respJSON.Get("totalCount").MustInt(), ShouldEqual, 2) + So(len(respJSON.Get("userGroups").MustArray()), ShouldEqual, 2) + }) + }) + + Convey("When searching with page and perpage parameters", func() { + loggedInUserScenario("When calling GET on", "/api/user-groups/search", func(sc *scenarioContext) { + var sentLimit int + var sendPage int + bus.AddHandler("test", func(query *models.SearchUserGroupsQuery) error { + query.Result = mockResult + + sentLimit = query.Limit + sendPage = query.Page + + return nil + }) + + sc.handlerFunc = SearchUserGroups + sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec() + + So(sentLimit, ShouldEqual, 10) + So(sendPage, ShouldEqual, 2) + }) + }) + }) +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index c23a53009a9..002cc840d97 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -35,6 +35,7 @@ var ( M_Api_Dashboard_Snapshot_Create Counter M_Api_Dashboard_Snapshot_External Counter M_Api_Dashboard_Snapshot_Get Counter + M_Api_UserGroup_Create Counter M_Models_Dashboard_Insert Counter M_Alerting_Result_State_Alerting Counter M_Alerting_Result_State_Ok Counter @@ -92,6 +93,8 @@ func initMetricVars(settings *MetricSettings) { M_Api_User_SignUpCompleted = RegCounter("api.user.signup_completed") M_Api_User_SignUpInvite = RegCounter("api.user.signup_invite") + M_Api_UserGroup_Create = RegCounter("api.usergroup.create") + M_Api_Dashboard_Save = RegTimer("api.dashboard.save") M_Api_Dashboard_Get = RegTimer("api.dashboard.get") M_Api_Dashboard_Search = RegTimer("api.dashboard.search") diff --git a/pkg/models/user_group.go b/pkg/models/user_group.go index abfdad64d4b..c9e0a54025e 100644 --- a/pkg/models/user_group.go +++ b/pkg/models/user_group.go @@ -13,12 +13,12 @@ var ( // UserGroup model type UserGroup struct { - Id int64 - OrgId int64 - Name string + Id int64 `json:"id"` + OrgId int64 `json:"orgId"` + Name string `json:"name"` - Created time.Time - Updated time.Time + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` } // --------------------- @@ -26,7 +26,7 @@ type UserGroup struct { type CreateUserGroupCommand struct { Name string `json:"name" binding:"Required"` - OrgId int64 `json:"orgId" binding:"Required"` + OrgId int64 `json:"-"` Result UserGroup `json:"-"` } @@ -46,5 +46,12 @@ type SearchUserGroupsQuery struct { Limit int Page int - Result []*UserGroup + Result SearchUserGroupQueryResult +} + +type SearchUserGroupQueryResult struct { + TotalCount int64 `json:"totalCount"` + UserGroups []*UserGroup `json:"userGroups"` + Page int `json:"page"` + PerPage int `json:"perPage"` } diff --git a/pkg/services/sqlstore/user_group.go b/pkg/services/sqlstore/user_group.go index f4d1396e82b..996e318d694 100644 --- a/pkg/services/sqlstore/user_group.go +++ b/pkg/services/sqlstore/user_group.go @@ -83,17 +83,37 @@ func isUserGroupNameTaken(name string, existingId int64, sess *session) (bool, e } func SearchUserGroups(query *m.SearchUserGroupsQuery) error { - query.Result = make([]*m.UserGroup, 0) + query.Result = m.SearchUserGroupQueryResult{ + UserGroups: make([]*m.UserGroup, 0), + } + queryWithWildcards := "%" + query.Query + "%" + sess := x.Table("user_group") if query.Query != "" { - sess.Where("name LIKE ?", query.Query+"%") + sess.Where("name LIKE ?", queryWithWildcards) } if query.Name != "" { sess.Where("name=?", query.Name) } - sess.Limit(query.Limit, query.Limit*query.Page) + offset := query.Limit * (query.Page - 1) + sess.Limit(query.Limit, offset) sess.Cols("id", "name") - err := sess.Find(&query.Result) + if err := sess.Find(&query.Result.UserGroups); err != nil { + return err + } + + userGroup := m.UserGroup{} + + countSess := x.Table("user_group") + if query.Query != "" { + countSess.Where("name LIKE ?", queryWithWildcards) + } + if query.Name != "" { + countSess.Where("name=?", query.Name) + } + count, err := countSess.Count(&userGroup) + query.Result.TotalCount = count + return err } diff --git a/pkg/services/sqlstore/user_group_test.go b/pkg/services/sqlstore/user_group_test.go index acf11a74e54..63fddc89012 100644 --- a/pkg/services/sqlstore/user_group_test.go +++ b/pkg/services/sqlstore/user_group_test.go @@ -36,13 +36,13 @@ func TestUserGroupCommandsAndQueries(t *testing.T) { So(err, ShouldBeNil) Convey("Should be able to create user groups and add users", func() { - query := &m.SearchUserGroupsQuery{Name: "group1 name"} + query := &m.SearchUserGroupsQuery{Name: "group1 name", Page: 1, Limit: 10} err = SearchUserGroups(query) So(err, ShouldBeNil) - So(query.Page, ShouldEqual, 0) + So(query.Page, ShouldEqual, 1) - userGroup1 := query.Result[0] - So(query.Result[0].Name, ShouldEqual, "group1 name") + userGroup1 := query.Result.UserGroups[0] + So(userGroup1.Name, ShouldEqual, "group1 name") err = AddUserGroupMember(&m.AddUserGroupMemberCommand{OrgId: 1, UserGroupId: userGroup1.Id, UserId: userIds[0]}) So(err, ShouldBeNil) @@ -55,10 +55,16 @@ func TestUserGroupCommandsAndQueries(t *testing.T) { }) Convey("Should be able to search for user groups", func() { - query := &m.SearchUserGroupsQuery{Query: "group"} + query := &m.SearchUserGroupsQuery{Query: "group", Page: 1} err = SearchUserGroups(query) So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 2) + So(len(query.Result.UserGroups), ShouldEqual, 2) + So(query.Result.TotalCount, ShouldEqual, 2) + + query2 := &m.SearchUserGroupsQuery{Query: ""} + err = SearchUserGroups(query2) + So(err, ShouldBeNil) + So(len(query2.Result.UserGroups), ShouldEqual, 2) }) Convey("Should be able to remove users from a group", func() { diff --git a/public/app/core/components/sidemenu/sidemenu.ts b/public/app/core/components/sidemenu/sidemenu.ts index 62258a25e7d..ccecc41b2fc 100644 --- a/public/app/core/components/sidemenu/sidemenu.ts +++ b/public/app/core/components/sidemenu/sidemenu.ts @@ -64,6 +64,10 @@ export class SideMenuCtrl { text: "Users", url: this.getUrl("/org/users") }); + this.orgMenu.push({ + text: "User Groups", + url: this.getUrl("/org/user-groups") + }); this.orgMenu.push({ text: "API Keys", url: this.getUrl("/org/apikeys") diff --git a/public/app/core/routes/routes.ts b/public/app/core/routes/routes.ts index a7565b8e167..9faa7211da4 100644 --- a/public/app/core/routes/routes.ts +++ b/public/app/core/routes/routes.ts @@ -83,6 +83,12 @@ function setupAngularRoutes($routeProvider, $locationProvider) { controller : 'OrgApiKeysCtrl', resolve: loadOrgBundle, }) + .when('/org/user-groups', { + templateUrl: 'public/app/features/org/partials/user_groups.html', + controller : 'UserGroupsCtrl', + controllerAs: 'ctrl', + resolve: loadOrgBundle, + }) .when('/profile', { templateUrl: 'public/app/features/org/partials/profile.html', controller : 'ProfileCtrl', diff --git a/public/app/features/org/all.js b/public/app/features/org/all.js index e206583a8c7..07328700a7a 100644 --- a/public/app/features/org/all.js +++ b/public/app/features/org/all.js @@ -1,7 +1,6 @@ define([ './org_users_ctrl', './profile_ctrl', - './org_users_ctrl', './select_org_ctrl', './change_password_ctrl', './newOrgCtrl', @@ -9,4 +8,5 @@ define([ './orgApiKeysCtrl', './orgDetailsCtrl', './prefs_control', + './user_groups_ctrl', ], function () {}); diff --git a/public/app/features/org/partials/user_groups.html b/public/app/features/org/partials/user_groups.html new file mode 100644 index 00000000000..fbaaccd42e2 --- /dev/null +++ b/public/app/features/org/partials/user_groups.html @@ -0,0 +1,60 @@ + + + +
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + +
IdName
{{userGroup.id}}{{userGroup.name}} + + + Edit + +    + + + +
+
+ +
+
    +
  1. + +
  2. +
+
+
diff --git a/public/app/features/org/user_groups_ctrl.ts b/public/app/features/org/user_groups_ctrl.ts new file mode 100644 index 00000000000..c1232328fb6 --- /dev/null +++ b/public/app/features/org/user_groups_ctrl.ts @@ -0,0 +1,68 @@ +/// + +import coreModule from 'app/core/core_module'; + +export default class UserGroupsCtrl { + userGroups: any; + pages = []; + perPage = 50; + page = 1; + totalPages: number; + showPaging = false; + query: any = ''; + + /** @ngInject */ + constructor(private $scope, private $http, private backendSrv) { + this.get(); + } + + get() { + this.backendSrv.get(`/api/user-groups/search?perpage=${this.perPage}&page=${this.page}&query=${this.query}`) + .then((result) => { + this.userGroups = result.userGroups; + this.page = result.page; + this.perPage = result.perPage; + this.totalPages = Math.ceil(result.totalCount / result.perPage); + this.showPaging = this.totalPages > 1; + this.pages = []; + + for (var i = 1; i < this.totalPages+1; i++) { + this.pages.push({ page: i, current: i === this.page}); + } + }); + } + + navigateToPage(page) { + this.page = page.page; + this.get(); + } + + deleteUserGroup(userGroup) { + this.$scope.appEvent('confirm-modal', { + title: 'Delete', + text: 'Are you sure you want to delete User Group ' + userGroup.name + '?', + yesText: "Delete", + icon: "fa-warning", + onConfirm: () => { + this.deleteUserGroupConfirmed(userGroup); + } + }); + } + + deleteUserGroupConfirmed(userGroup) { + this.backendSrv.delete('/api/user-groups/' + userGroup.id) + .then(this.get.bind(this)); + } + + openUserGroupModal() { + var modalScope = this.$scope.$new(); + + this.$scope.appEvent('show-modal', { + src: 'public/app/features/org/partials/add_user.html', + modalClass: 'user-group-modal', + scope: modalScope + }); + } +} + +coreModule.controller('UserGroupsCtrl', UserGroupsCtrl);