diff --git a/pkg/api/api.go b/pkg/api/api.go index d9fee8a0187..026bae2d894 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -123,6 +123,7 @@ func (hs *HttpServer) registerRoutes() { // users (admin permission required) r.Group("/users", func() { r.Get("/", wrap(SearchUsers)) + r.Get("/search", wrap(SearchUsersWithPaging)) r.Get("/:id", wrap(GetUserById)) r.Get("/:id/orgs", wrap(GetUserOrgList)) // query parameters /users/lookup?loginOrEmail=admin@example.com @@ -195,7 +196,7 @@ func (hs *HttpServer) registerRoutes() { // Data sources r.Group("/datasources", func() { - r.Get("/", GetDataSources) + r.Get("/", wrap(GetDataSources)) r.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), AddDataSource) r.Put("/:id", bind(m.UpdateDataSourceCommand{}), wrap(UpdateDataSource)) r.Delete("/:id", DeleteDataSourceById) diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 455195aacb0..ddf8681c3c8 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -11,12 +11,11 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func GetDataSources(c *middleware.Context) { +func GetDataSources(c *middleware.Context) Response { query := m.GetDataSourcesQuery{OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to query datasources", err) - return + return ApiError(500, "Failed to query datasources", err) } result := make(dtos.DataSourceList, 0) @@ -46,7 +45,8 @@ func GetDataSources(c *middleware.Context) { } sort.Sort(result) - c.JSON(200, result) + + return Json(200, &result) } func GetDataSourceById(c *middleware.Context) Response { diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index aa3f6106afd..5ae752bea91 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -59,7 +59,9 @@ func loggedInUserScenario(desc string, url string, fn scenarioFunc) { Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() - sc := &scenarioContext{} + sc := &scenarioContext{ + url: url, + } viewsPath, _ := filepath.Abs("../../public/views") sc.m = macaron.New() @@ -71,16 +73,18 @@ func loggedInUserScenario(desc string, url string, fn scenarioFunc) { sc.m.Use(middleware.GetContextHandler()) sc.m.Use(middleware.Sessioner(&session.Options{})) - sc.defaultHandler = func(c *middleware.Context) { + sc.defaultHandler = wrap(func(c *middleware.Context) Response { sc.context = c sc.context.UserId = TestUserID sc.context.OrgId = TestOrgID sc.context.OrgRole = models.ROLE_EDITOR if sc.handlerFunc != nil { - sc.handlerFunc(sc.context) + return sc.handlerFunc(sc.context) } - } - sc.m.SetAutoHead(true) + + return nil + }) + sc.m.Get(url, sc.defaultHandler) fn(sc) @@ -96,6 +100,20 @@ func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { return sc } +func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, err := http.NewRequest(method, url, nil) + q := req.URL.Query() + for k, v := range queryParams { + q.Add(k, v) + } + req.URL.RawQuery = q.Encode() + So(err, ShouldBeNil) + sc.req = req + + return sc +} + type scenarioContext struct { m *macaron.Macaron context *middleware.Context @@ -103,6 +121,7 @@ type scenarioContext struct { handlerFunc handlerFunc defaultHandler macaron.Handler req *http.Request + url string } func (sc *scenarioContext) exec() { @@ -110,4 +129,4 @@ func (sc *scenarioContext) exec() { } type scenarioFunc func(c *scenarioContext) -type handlerFunc func(c *middleware.Context) +type handlerFunc func(c *middleware.Context) Response diff --git a/pkg/api/user.go b/pkg/api/user.go index 7bce599d692..9a978503761 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -210,14 +210,46 @@ func ChangeUserPassword(c *middleware.Context, cmd m.ChangeUserPasswordCommand) // GET /api/users func SearchUsers(c *middleware.Context) Response { - query := m.SearchUsersQuery{Query: "", Page: 0, Limit: 1000} - if err := bus.Dispatch(&query); err != nil { + query, err := searchUser(c) + if err != nil { + return ApiError(500, "Failed to fetch users", err) + } + + return Json(200, query.Result.Users) +} + +// GET /api/paged-users +func SearchUsersWithPaging(c *middleware.Context) Response { + query, err := searchUser(c) + if err != nil { return ApiError(500, "Failed to fetch users", err) } return Json(200, query.Result) } +func searchUser(c *middleware.Context) (*m.SearchUsersQuery, error) { + perPage := c.QueryInt("perpage") + if perPage <= 0 { + perPage = 1000 + } + page := c.QueryInt("page") + + if page < 1 { + page = 1 + } + + query := &m.SearchUsersQuery{Query: "", Page: page, Limit: perPage} + if err := bus.Dispatch(query); err != nil { + return nil, err + } + + query.Result.Page = page + query.Result.PerPage = perPage + + return query, nil +} + func SetHelpFlag(c *middleware.Context) Response { flag := c.ParamsInt64(":id") diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go new file mode 100644 index 00000000000..6aa9dd9adbf --- /dev/null +++ b/pkg/api/user_test.go @@ -0,0 +1,109 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/models" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + . "github.com/smartystreets/goconvey/convey" +) + +func TestUserApiEndpoint(t *testing.T) { + Convey("Given a user is logged in", t, func() { + mockResult := models.SearchUserQueryResult{ + Users: []*models.UserSearchHitDTO{ + {Name: "user1"}, + {Name: "user2"}, + }, + TotalCount: 2, + } + + loggedInUserScenario("When calling GET on", "/api/users", func(sc *scenarioContext) { + var sentLimit int + var sendPage int + bus.AddHandler("test", func(query *models.SearchUsersQuery) error { + query.Result = mockResult + + sentLimit = query.Limit + sendPage = query.Page + + return nil + }) + + sc.handlerFunc = SearchUsers + 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(len(respJSON.MustArray()), ShouldEqual, 2) + }) + + loggedInUserScenario("When calling GET with page and limit querystring parameters on", "/api/users", func(sc *scenarioContext) { + var sentLimit int + var sendPage int + bus.AddHandler("test", func(query *models.SearchUsersQuery) error { + query.Result = mockResult + + sentLimit = query.Limit + sendPage = query.Page + + return nil + }) + + sc.handlerFunc = SearchUsers + sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec() + + So(sentLimit, ShouldEqual, 10) + So(sendPage, ShouldEqual, 2) + }) + + loggedInUserScenario("When calling GET on", "/api/users/search", func(sc *scenarioContext) { + var sentLimit int + var sendPage int + bus.AddHandler("test", func(query *models.SearchUsersQuery) error { + query.Result = mockResult + + sentLimit = query.Limit + sendPage = query.Page + + return nil + }) + + sc.handlerFunc = SearchUsersWithPaging + 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("users").MustArray()), ShouldEqual, 2) + }) + + loggedInUserScenario("When calling GET with page and perpage querystring parameters on", "/api/users/search", func(sc *scenarioContext) { + var sentLimit int + var sendPage int + bus.AddHandler("test", func(query *models.SearchUsersQuery) error { + query.Result = mockResult + + sentLimit = query.Limit + sendPage = query.Page + + return nil + }) + + sc.handlerFunc = SearchUsersWithPaging + 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/models/user.go b/pkg/models/user.go index e14f4486ba3..e0a36be8c0a 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -130,7 +130,14 @@ type SearchUsersQuery struct { Page int Limit int - Result []*UserSearchHitDTO + Result SearchUserQueryResult +} + +type SearchUserQueryResult struct { + TotalCount int64 `json:"totalCount"` + Users []*UserSearchHitDTO `json:"users"` + Page int `json:"page"` + PerPage int `json:"perPage"` } type GetUserOrgListQuery struct { diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index f52175c2e5c..e7c718fc9a8 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -63,8 +63,8 @@ func TestAccountDataAccess(t *testing.T) { err := SearchUsers(&query) So(err, ShouldBeNil) - So(query.Result[0].Email, ShouldEqual, "ac1@test.com") - So(query.Result[1].Email, ShouldEqual, "ac2@test.com") + So(query.Result.Users[0].Email, ShouldEqual, "ac1@test.com") + So(query.Result.Users[1].Email, ShouldEqual, "ac2@test.com") }) Convey("Given an added org user", func() { diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index b26a9153f55..791a38f2d01 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -344,12 +344,21 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error { } func SearchUsers(query *m.SearchUsersQuery) error { - query.Result = make([]*m.UserSearchHitDTO, 0) + query.Result = m.SearchUserQueryResult{ + Users: make([]*m.UserSearchHitDTO, 0), + } sess := x.Table("user") sess.Where("email LIKE ?", query.Query+"%") - sess.Limit(query.Limit, query.Limit*query.Page) + offset := query.Limit * (query.Page - 1) + sess.Limit(query.Limit, offset) sess.Cols("id", "email", "name", "login", "is_admin") - err := sess.Find(&query.Result) + if err := sess.Find(&query.Result.Users); err != nil { + return err + } + + user := m.User{} + count, err := x.Count(&user) + query.Result.TotalCount = count return err } diff --git a/pkg/services/sqlstore/user_test.go b/pkg/services/sqlstore/user_test.go new file mode 100644 index 00000000000..53a50f9631c --- /dev/null +++ b/pkg/services/sqlstore/user_test.go @@ -0,0 +1,45 @@ +package sqlstore + +import ( + "fmt" + "testing" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/grafana/grafana/pkg/models" +) + +func TestUserDataAccess(t *testing.T) { + + Convey("Testing DB", t, func() { + InitTestDB(t) + + var err error + for i := 0; i < 5; i++ { + err = CreateUser(&models.CreateUserCommand{ + Email: fmt.Sprint("user", i, "@test.com"), + Name: fmt.Sprint("user", i), + Login: fmt.Sprint("user", i), + }) + So(err, ShouldBeNil) + } + + Convey("Can return the first page of users and a total count", func() { + query := models.SearchUsersQuery{Query: "", Page: 1, Limit: 3} + err = SearchUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 3) + So(query.Result.TotalCount, ShouldEqual, 5) + }) + + Convey("Can return the second page of users and a total count", func() { + query := models.SearchUsersQuery{Query: "", Page: 2, Limit: 3} + err = SearchUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 2) + So(query.Result.TotalCount, ShouldEqual, 5) + }) + }) +} diff --git a/public/app/core/routes/routes.ts b/public/app/core/routes/routes.ts index 28e1dcf1cd1..3ccf89bae01 100644 --- a/public/app/core/routes/routes.ts +++ b/public/app/core/routes/routes.ts @@ -113,6 +113,7 @@ function setupAngularRoutes($routeProvider, $locationProvider) { .when('/admin/users', { templateUrl: 'public/app/features/admin/partials/users.html', controller : 'AdminListUsersCtrl', + controllerAs: 'ctrl', resolve: loadAdminBundle, }) .when('/admin/users/create', { diff --git a/public/app/features/admin/admin.ts b/public/app/features/admin/admin.ts index b93fd07a059..6809e4c54ab 100644 --- a/public/app/features/admin/admin.ts +++ b/public/app/features/admin/admin.ts @@ -1,4 +1,4 @@ -import './adminListUsersCtrl'; +import AdminListUsersCtrl from './admin_list_users_ctrl'; import './adminListOrgsCtrl'; import './adminEditOrgCtrl'; import './adminEditUserCtrl'; @@ -37,3 +37,4 @@ export class AdminStatsCtrl { coreModule.controller('AdminSettingsCtrl', AdminSettingsCtrl); coreModule.controller('AdminHomeCtrl', AdminHomeCtrl); coreModule.controller('AdminStatsCtrl', AdminStatsCtrl); +coreModule.controller('AdminListUsersCtrl', AdminListUsersCtrl); diff --git a/public/app/features/admin/adminListUsersCtrl.js b/public/app/features/admin/adminListUsersCtrl.js deleted file mode 100644 index 721adcfb98c..00000000000 --- a/public/app/features/admin/adminListUsersCtrl.js +++ /dev/null @@ -1,38 +0,0 @@ -define([ - 'angular', -], -function (angular) { - 'use strict'; - - var module = angular.module('grafana.controllers'); - - module.controller('AdminListUsersCtrl', function($scope, backendSrv) { - - $scope.init = function() { - $scope.getUsers(); - }; - - $scope.getUsers = function() { - backendSrv.get('/api/users').then(function(users) { - $scope.users = users; - }); - }; - - $scope.deleteUser = function(user) { - $scope.appEvent('confirm-modal', { - title: 'Delete', - text: 'Do you want to delete ' + user.login + '?', - icon: 'fa-trash', - yesText: 'Delete', - onConfirm: function() { - backendSrv.delete('/api/admin/users/' + user.id).then(function() { - $scope.getUsers(); - }); - } - }); - }; - - $scope.init(); - - }); -}); diff --git a/public/app/features/admin/admin_list_users_ctrl.ts b/public/app/features/admin/admin_list_users_ctrl.ts new file mode 100644 index 00000000000..1347457bc06 --- /dev/null +++ b/public/app/features/admin/admin_list_users_ctrl.ts @@ -0,0 +1,49 @@ +/// + +export default class AdminListUsersCtrl { + users: any; + pages = []; + perPage = 1000; + page = 1; + totalPages: number; + showPaging = false; + + /** @ngInject */ + constructor(private $scope, private backendSrv) { + this.getUsers(); + } + + getUsers() { + this.backendSrv.get(`/api/users/search?perpage=${this.perPage}&page=${this.page}`).then((result) => { + this.users = result.users; + 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.getUsers(); + } + + deleteUser(user) { + this.$scope.appEvent('confirm-modal', { + title: 'Delete', + text: 'Do you want to delete ' + user.login + '?', + icon: 'fa-trash', + yesText: 'Delete', + onConfirm: () => { + this.backendSrv.delete('/api/admin/users/' + user.id).then(() => { + this.getUsers(); + }); + } + }); + } +} diff --git a/public/app/features/admin/partials/users.html b/public/app/features/admin/partials/users.html index a1c86391088..57714380c67 100644 --- a/public/app/features/admin/partials/users.html +++ b/public/app/features/admin/partials/users.html @@ -1,49 +1,62 @@ - - - Users - + + + Users +
- +
+ + + + + + + + + + + + + + + + + + + + + -
IdNameLoginEmailGrafana Admin
{{user.id}}{{user.name}}{{user.login}}{{user.email}}{{user.isAdmin}} + + + Edit + +    + + + +
- - - - - - - - - - - - - - - - - - - - -
IdNameLoginEmailGrafana Admin
{{user.id}}{{user.name}}{{user.login}}{{user.email}}{{user.isAdmin}} - - - Edit - -    - - - -
+ +
+ +
+
    +
  1. + +
  2. +
+
diff --git a/public/sass/pages/_admin.scss b/public/sass/pages/_admin.scss index 30bc3ca8b34..b2be062849d 100644 --- a/public/sass/pages/_admin.scss +++ b/public/sass/pages/_admin.scss @@ -8,3 +8,15 @@ td.admin-settings-key { padding-left: 20px; } +.admin-list-table { + margin-bottom: 20px; +} + +.admin-list-paging { + float: right; + li { + display: inline-block; + padding-left: 10px; + margin-bottom: 5px; + } +}