mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
admin: adds paging to global user list
Currently there is a limit of 1000 users in the global user list. This change introduces paging so that an admin can see all users and not just the first 1000. Adds a new route to the api - /api/users/search that returns a list of users and a total count. It takes two parameters perpage and page that enable paging. Fixes #7469
This commit is contained in:
parent
e80f673264
commit
193d468ed3
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
||||
|
109
pkg/api/user_test.go
Normal file
109
pkg/api/user_test.go
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
@ -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 {
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
45
pkg/services/sqlstore/user_test.go
Normal file
45
pkg/services/sqlstore/user_test.go
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
@ -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', {
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
||||
});
|
||||
});
|
49
public/app/features/admin/admin_list_users_ctrl.ts
Normal file
49
public/app/features/admin/admin_list_users_ctrl.ts
Normal file
@ -0,0 +1,49 @@
|
||||
///<reference path="../../headers/common.d.ts" />
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -1,49 +1,62 @@
|
||||
<navbar icon="fa fa-fw fa-cogs" title="Admin" title-url="admin">
|
||||
<a href="admin/users" class="navbar-page-btn">
|
||||
<i class="icon-gf icon-gf-users"></i>
|
||||
Users
|
||||
</a>
|
||||
<a href="admin/users" class="navbar-page-btn">
|
||||
<i class="icon-gf icon-gf-users"></i>
|
||||
Users
|
||||
</a>
|
||||
</navbar>
|
||||
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1>Users</h1>
|
||||
<div class="page-header">
|
||||
<h1>Users</h1>
|
||||
|
||||
<a class="btn btn-success" href="admin/users/create">
|
||||
<i class="fa fa-plus"></i>
|
||||
Add new user
|
||||
</a>
|
||||
</div>
|
||||
<a class="btn btn-success" href="admin/users/create">
|
||||
<i class="fa fa-plus"></i>
|
||||
Add new user
|
||||
</a>
|
||||
</div>
|
||||
<div class="admin-list-table">
|
||||
<table class="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>Name</th>
|
||||
<th>Login</th>
|
||||
<th>Email</th>
|
||||
<th style="white-space: nowrap">Grafana Admin</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="user in ctrl.users">
|
||||
<td>{{user.id}}</td>
|
||||
<td>{{user.name}}</td>
|
||||
<td>{{user.login}}</td>
|
||||
<td>{{user.email}}</td>
|
||||
<td>{{user.isAdmin}}</td>
|
||||
<td class="text-right">
|
||||
<a href="admin/users/edit/{{user.id}}" class="btn btn-inverse btn-small">
|
||||
<i class="fa fa-edit"></i>
|
||||
Edit
|
||||
</a>
|
||||
|
||||
<a ng-click="ctrl.deleteUser(user)" class="btn btn-danger btn-small">
|
||||
<i class="fa fa-remove"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<table class="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>Name</th>
|
||||
<th>Login</th>
|
||||
<th>Email</th>
|
||||
<th style="white-space: nowrap">Grafana Admin</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="user in users">
|
||||
<td>{{user.id}}</td>
|
||||
<td>{{user.name}}</td>
|
||||
<td>{{user.login}}</td>
|
||||
<td>{{user.email}}</td>
|
||||
<td>{{user.isAdmin}}</td>
|
||||
<td class="text-right">
|
||||
<a href="admin/users/edit/{{user.id}}" class="btn btn-inverse btn-small">
|
||||
<i class="fa fa-edit"></i>
|
||||
Edit
|
||||
</a>
|
||||
|
||||
<a ng-click="deleteUser(user)" class="btn btn-danger btn-small">
|
||||
<i class="fa fa-remove"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="admin-list-paging" ng-if="ctrl.showPaging">
|
||||
<ol>
|
||||
<li ng-repeat="page in ctrl.pages">
|
||||
<button
|
||||
class="btn btn-small"
|
||||
ng-class="{'btn-secondary': page.current, 'btn-inverse': !page.current}"
|
||||
ng-click="ctrl.navigateToPage(page)">{{page.page}}</button>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user