Merge pull request #15977 from grafana/admin-on-create-poc

Editors becomes admin when creating dashboards, folders & teams
This commit is contained in:
Leonard Gram 2019-03-19 15:02:52 +01:00 committed by GitHub
commit f2b06a89f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1895 additions and 615 deletions

View File

@ -259,7 +259,7 @@ external_manage_info =
viewers_can_edit = false
# Editors can administrate dashboard, folders and teams they create
editors_can_own = false
editors_can_admin = false
[auth]
# Login cookie name

View File

@ -239,7 +239,7 @@ log_queries =
;viewers_can_edit = false
# Editors can administrate dashboard, folders and teams they create
;editors_can_own = false
;editors_can_admin = false
[auth]
# Login cookie name

View File

@ -354,6 +354,11 @@ options are `Admin` and `Editor`. e.g. :
Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
Defaults to `false`.
### editors_can_admin
Editors can administrate dashboards, folders and teams they create.
Defaults to `false`.
### login_hint
Text used as placeholder text on login page for login/username input.

View File

@ -28,6 +28,9 @@ Can do everything scoped to the organization. For example:
- Can create and modify dashboards & alert rules. This can be disabled on specific folders and dashboards.
- **Cannot** create or edit data sources nor invite new users.
This role can be tweaked via Grafana server setting [editors_can_admin]({{< relref "installation/configuration.md#editors_can_admin" >}}). If you set this to true users
with **Editor** can also administrate dashboards, folders and teams they create. Useful for enabling self organizing teams.
## Viewer Role
- View any dashboard. This can be disabled on specific folders and dashboards.

View File

@ -2,6 +2,7 @@ import React, { PureComponent, SyntheticEvent } from 'react';
interface Props {
onConfirm(): void;
disabled?: boolean;
}
interface State {
@ -33,25 +34,22 @@ export class DeleteButton extends PureComponent<Props, State> {
};
render() {
const { onConfirm } = this.props;
let showConfirm;
let showDeleteButton;
if (this.state.showConfirm) {
showConfirm = 'show';
showDeleteButton = 'hide';
} else {
showConfirm = 'hide';
showDeleteButton = 'show';
}
const { onConfirm, disabled } = this.props;
const showConfirmClass = this.state.showConfirm ? 'show' : 'hide';
const showDeleteButtonClass = this.state.showConfirm ? 'hide' : 'show';
const disabledClass = disabled ? 'disabled btn-inverse' : '';
const onClick = disabled ? () => {} : this.onClickDelete;
return (
<span className="delete-button-container">
<a className={'delete-button ' + showDeleteButton + ' btn btn-danger btn-small'} onClick={this.onClickDelete}>
<a
className={`delete-button ${showDeleteButtonClass} btn btn-danger btn-small ${disabledClass}`}
onClick={onClick}
>
<i className="fa fa-remove" />
</a>
<span className="confirm-delete-container">
<span className={'confirm-delete ' + showConfirm}>
<span className={`confirm-delete ${showConfirmClass}`}>
<a className="btn btn-small" onClick={this.onClickCancel}>
Cancel
</a>

View File

@ -14,6 +14,7 @@ func (hs *HTTPServer) registerRoutes() {
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
reqEditorRole := middleware.ReqEditorRole
reqOrgAdmin := middleware.ReqOrgAdmin
reqCanAccessTeams := middleware.AdminOrFeatureEnabled(hs.Cfg.EditorsCanAdmin)
redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL()
redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL()
quota := middleware.Quota(hs.QuotaService)
@ -41,8 +42,8 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/org/users", reqOrgAdmin, hs.Index)
r.Get("/org/users/new", reqOrgAdmin, hs.Index)
r.Get("/org/users/invite", reqOrgAdmin, hs.Index)
r.Get("/org/teams", reqOrgAdmin, hs.Index)
r.Get("/org/teams/*", reqOrgAdmin, hs.Index)
r.Get("/org/teams", reqCanAccessTeams, hs.Index)
r.Get("/org/teams/*", reqCanAccessTeams, hs.Index)
r.Get("/org/apikeys/", reqOrgAdmin, hs.Index)
r.Get("/dashboard/import/", reqSignedIn, hs.Index)
r.Get("/configuration", reqGrafanaAdmin, hs.Index)
@ -153,20 +154,21 @@ func (hs *HTTPServer) registerRoutes() {
// team (admin permission required)
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(CreateTeam))
teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(UpdateTeam))
teamsRoute.Delete("/:teamId", Wrap(DeleteTeamByID))
teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam))
teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(hs.UpdateTeam))
teamsRoute.Delete("/:teamId", Wrap(hs.DeleteTeamByID))
teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers))
teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember))
teamsRoute.Delete("/:teamId/members/:userId", Wrap(RemoveTeamMember))
teamsRoute.Get("/:teamId/preferences", Wrap(GetTeamPreferences))
teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateTeamPreferences))
}, reqOrgAdmin)
teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember))
teamsRoute.Put("/:teamId/members/:userId", bind(m.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember))
teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember))
teamsRoute.Get("/:teamId/preferences", Wrap(hs.GetTeamPreferences))
teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(hs.UpdateTeamPreferences))
}, reqCanAccessTeams)
// team without requirement of user to be org admin
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
teamsRoute.Get("/:teamId", Wrap(GetTeamByID))
teamsRoute.Get("/search", Wrap(SearchTeams))
teamsRoute.Get("/search", Wrap(hs.SearchTeams))
})
// org information available to all users.
@ -265,7 +267,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {
folderRoute.Get("/", Wrap(GetFolders))
folderRoute.Get("/id/:id", Wrap(GetFolderByID))
folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(CreateFolder))
folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(hs.CreateFolder))
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Get("/", Wrap(GetFolderByUID))

View File

@ -213,7 +213,8 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand)
dash := cmd.GetDashboardModel()
if dash.Id == 0 && dash.Uid == "" {
newDashboard := dash.Id == 0 && dash.Uid == ""
if newDashboard {
limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard")
if err != nil {
return Error(500, "failed to get quota", err)
@ -276,6 +277,15 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand)
return Error(500, "Failed to save dashboard", err)
}
if hs.Cfg.EditorsCanAdmin && newDashboard {
inFolder := cmd.FolderId > 0
err := dashboards.MakeUserAdmin(hs.Bus, cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder)
if err != nil {
hs.log.Error("Could not make user admin", "dashboard", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err)
return Error(500, "Failed to make user admin of dashboard", err)
}
}
c.TimeRequest(metrics.M_Api_Dashboard_Save)
return JSON(200, util.DynMap{
"status": "success",

View File

@ -974,6 +974,7 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d
hs := HTTPServer{
Bus: bus.GetBus(),
Cfg: setting.NewCfg(),
}
sc := setupScenarioContext(url)
@ -1024,6 +1025,7 @@ func restoreDashboardVersionScenario(desc string, url string, routePattern strin
defer bus.ClearBusHandlers()
hs := HTTPServer{
Cfg: setting.NewCfg(),
Bus: bus.GetBus(),
}

View File

@ -54,13 +54,20 @@ func GetFolderByID(c *m.ReqContext) Response {
return JSON(200, toFolderDto(g, folder))
}
func CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response {
func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response {
s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
err := s.CreateFolder(&cmd)
if err != nil {
return toFolderError(err)
}
if hs.Cfg.EditorsCanAdmin {
if err := dashboards.MakeUserAdmin(hs.Bus, c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil {
hs.log.Error("Could not make user admin", "folder", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err)
return Error(500, "Failed to make user admin of folder", err)
}
}
g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser)
return JSON(200, toFolderDto(g, cmd.Result))
}

View File

@ -9,6 +9,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey"
)
@ -141,12 +142,17 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
hs := HTTPServer{
Bus: bus.GetBus(),
Cfg: setting.NewCfg(),
}
sc := setupScenarioContext(url)
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
sc.context = c
sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
return CreateFolder(c, cmd)
return hs.CreateFolder(c, cmd)
})
origNewFolderService := dashboards.NewFolderService

View File

@ -167,7 +167,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
"externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl,
"externalUserMngLinkName": setting.ExternalUserMngLinkName,
"viewersCanEdit": setting.ViewersCanEdit,
"editorsCanOwn": hs.Cfg.EditorsCanOwn,
"editorsCanAdmin": hs.Cfg.EditorsCanAdmin,
"disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml,
"buildInfo": map[string]interface{}{
"version": setting.BuildVersion,

View File

@ -327,6 +327,27 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
})
}
if (c.OrgRole == m.ROLE_EDITOR || c.OrgRole == m.ROLE_VIEWER) && hs.Cfg.EditorsCanAdmin {
cfgNode := &dtos.NavLink{
Id: "cfg",
Text: "Configuration",
SubTitle: "Organization: " + c.OrgName,
Icon: "gicon gicon-cog",
Url: setting.AppSubUrl + "/org/teams",
Children: []*dtos.NavLink{
{
Text: "Teams",
Id: "teams",
Description: "Manage org groups",
Icon: "gicon gicon-team",
Url: setting.AppSubUrl + "/org/teams",
},
},
}
data.NavTree = append(data.NavTree, cfgNode)
}
data.NavTree = append(data.NavTree, &dtos.NavLink{
Text: "Help",
SubTitle: fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit),

View File

@ -4,19 +4,38 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/teamguardian"
"github.com/grafana/grafana/pkg/util"
)
// POST /api/teams
func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
cmd.OrgId = c.OrgId
if err := bus.Dispatch(&cmd); err != nil {
if c.OrgRole == m.ROLE_VIEWER {
return Error(403, "Not allowed to create team.", nil)
}
if err := hs.Bus.Dispatch(&cmd); err != nil {
if err == m.ErrTeamNameTaken {
return Error(409, "Team name taken", err)
}
return Error(500, "Failed to create Team", err)
}
if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanAdmin {
addMemberCmd := m.AddTeamMemberCommand{
UserId: c.SignedInUser.UserId,
OrgId: cmd.OrgId,
TeamId: cmd.Result.Id,
Permission: m.PERMISSION_ADMIN,
}
if err := hs.Bus.Dispatch(&addMemberCmd); err != nil {
c.Logger.Error("Could not add creator to team.", "error", err)
}
}
return JSON(200, &util.DynMap{
"teamId": cmd.Result.Id,
"message": "Team created",
@ -24,10 +43,15 @@ func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
}
// PUT /api/teams/:teamId
func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
func (hs *HTTPServer) UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
cmd.OrgId = c.OrgId
cmd.Id = c.ParamsInt64(":teamId")
if err := bus.Dispatch(&cmd); err != nil {
if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.Id, c.SignedInUser); err != nil {
return Error(403, "Not allowed to update team", err)
}
if err := hs.Bus.Dispatch(&cmd); err != nil {
if err == m.ErrTeamNameTaken {
return Error(400, "Team name taken", err)
}
@ -38,18 +62,26 @@ func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
}
// DELETE /api/teams/:teamId
func DeleteTeamByID(c *m.ReqContext) Response {
if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")}); err != nil {
func (hs *HTTPServer) DeleteTeamByID(c *m.ReqContext) Response {
orgId := c.OrgId
teamId := c.ParamsInt64(":teamId")
user := c.SignedInUser
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, user); err != nil {
return Error(403, "Not allowed to delete team", err)
}
if err := hs.Bus.Dispatch(&m.DeleteTeamCommand{OrgId: orgId, Id: teamId}); err != nil {
if err == m.ErrTeamNotFound {
return Error(404, "Failed to delete Team. ID not found", nil)
}
return Error(500, "Failed to update Team", err)
return Error(500, "Failed to delete Team", err)
}
return Success("Team deleted")
}
// GET /api/teams/search
func SearchTeams(c *m.ReqContext) Response {
func (hs *HTTPServer) SearchTeams(c *m.ReqContext) Response {
perPage := c.QueryInt("perpage")
if perPage <= 0 {
perPage = 1000
@ -59,12 +91,18 @@ func SearchTeams(c *m.ReqContext) Response {
page = 1
}
var userIdFilter int64
if hs.Cfg.EditorsCanAdmin && c.OrgRole != m.ROLE_ADMIN {
userIdFilter = c.SignedInUser.UserId
}
query := m.SearchTeamsQuery{
OrgId: c.OrgId,
Query: c.Query("query"),
Name: c.Query("name"),
Page: page,
Limit: perPage,
OrgId: c.OrgId,
Query: c.Query("query"),
Name: c.Query("name"),
UserIdFilter: userIdFilter,
Page: page,
Limit: perPage,
}
if err := bus.Dispatch(&query); err != nil {
@ -98,11 +136,25 @@ func GetTeamByID(c *m.ReqContext) Response {
}
// GET /api/teams/:teamId/preferences
func GetTeamPreferences(c *m.ReqContext) Response {
return getPreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"))
func (hs *HTTPServer) GetTeamPreferences(c *m.ReqContext) Response {
teamId := c.ParamsInt64(":teamId")
orgId := c.OrgId
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
return Error(403, "Not allowed to view team preferences.", err)
}
return getPreferencesFor(orgId, 0, teamId)
}
// PUT /api/teams/:teamId/preferences
func UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
return updatePreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"), &dtoCmd)
func (hs *HTTPServer) UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
teamId := c.ParamsInt64(":teamId")
orgId := c.OrgId
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
return Error(403, "Not allowed to update team preferences.", err)
}
return updatePreferencesFor(orgId, 0, teamId, &dtoCmd)
}

View File

@ -4,6 +4,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/teamguardian"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@ -29,11 +30,15 @@ func GetTeamMembers(c *m.ReqContext) Response {
}
// POST /api/teams/:teamId/members
func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
cmd.TeamId = c.ParamsInt64(":teamId")
func (hs *HTTPServer) AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
cmd.OrgId = c.OrgId
cmd.TeamId = c.ParamsInt64(":teamId")
if err := bus.Dispatch(&cmd); err != nil {
if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.TeamId, c.SignedInUser); err != nil {
return Error(403, "Not allowed to add team member", err)
}
if err := hs.Bus.Dispatch(&cmd); err != nil {
if err == m.ErrTeamNotFound {
return Error(404, "Team not found", nil)
}
@ -50,9 +55,48 @@ func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
})
}
// PUT /:teamId/members/:userId
func (hs *HTTPServer) UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response {
teamId := c.ParamsInt64(":teamId")
orgId := c.OrgId
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
return Error(403, "Not allowed to update team member", err)
}
if c.OrgRole != m.ROLE_ADMIN {
cmd.ProtectLastAdmin = true
}
cmd.TeamId = teamId
cmd.UserId = c.ParamsInt64(":userId")
cmd.OrgId = orgId
if err := hs.Bus.Dispatch(&cmd); err != nil {
if err == m.ErrTeamMemberNotFound {
return Error(404, "Team member not found.", nil)
}
return Error(500, "Failed to update team member.", err)
}
return Success("Team member updated")
}
// DELETE /api/teams/:teamId/members/:userId
func RemoveTeamMember(c *m.ReqContext) Response {
if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil {
func (hs *HTTPServer) RemoveTeamMember(c *m.ReqContext) Response {
orgId := c.OrgId
teamId := c.ParamsInt64(":teamId")
userId := c.ParamsInt64(":userId")
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
return Error(403, "Not allowed to remove team member", err)
}
protectLastAdmin := false
if c.OrgRole != m.ROLE_ADMIN {
protectLastAdmin = true
}
if err := hs.Bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: orgId, TeamId: teamId, UserId: userId, ProtectLastAdmin: protectLastAdmin}); err != nil {
if err == m.ErrTeamNotFound {
return Error(404, "Team not found", nil)
}

View File

@ -3,6 +3,8 @@ package api
import (
"testing"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
@ -20,6 +22,10 @@ func TestTeamApiEndpoint(t *testing.T) {
TotalCount: 2,
}
hs := &HTTPServer{
Cfg: setting.NewCfg(),
}
Convey("When searching with no parameters", func() {
loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) {
var sentLimit int
@ -33,7 +39,7 @@ func TestTeamApiEndpoint(t *testing.T) {
return nil
})
sc.handlerFunc = SearchTeams
sc.handlerFunc = hs.SearchTeams
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
So(sentLimit, ShouldEqual, 1000)
@ -60,7 +66,7 @@ func TestTeamApiEndpoint(t *testing.T) {
return nil
})
sc.handlerFunc = SearchTeams
sc.handlerFunc = hs.SearchTeams
sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
So(sentLimit, ShouldEqual, 10)

View File

@ -86,3 +86,20 @@ func Auth(options *AuthOptions) macaron.Handler {
}
}
}
// AdminOrFeatureEnabled creates a middleware that allows access
// if the signed in user is either an Org Admin or if the
// feature flag is enabled.
// Intended for when feature flags open up access to APIs that
// are otherwise only available to admins.
func AdminOrFeatureEnabled(enabled bool) macaron.Handler {
return func(c *m.ReqContext) {
if c.OrgRole == m.ROLE_ADMIN {
return
}
if !enabled {
accessForbidden(c)
}
}
}

View File

@ -7,9 +7,12 @@ import (
// Typed errors
var (
ErrTeamNotFound = errors.New("Team not found")
ErrTeamNameTaken = errors.New("Team name is taken")
ErrTeamMemberNotFound = errors.New("Team member not found")
ErrTeamNotFound = errors.New("Team not found")
ErrTeamNameTaken = errors.New("Team name is taken")
ErrTeamMemberNotFound = errors.New("Team member not found")
ErrLastTeamAdmin = errors.New("Not allowed to remove last admin")
ErrNotAllowedToUpdateTeam = errors.New("User not allowed to update team")
ErrNotAllowedToUpdateTeamInDifferentOrg = errors.New("User not allowed to update team in another org")
)
// Team model
@ -59,22 +62,24 @@ type GetTeamsByUserQuery struct {
}
type SearchTeamsQuery struct {
Query string
Name string
Limit int
Page int
OrgId int64
Query string
Name string
Limit int
Page int
OrgId int64
UserIdFilter int64
Result SearchTeamQueryResult
}
type TeamDTO struct {
Id int64 `json:"id"`
OrgId int64 `json:"orgId"`
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl string `json:"avatarUrl"`
MemberCount int64 `json:"memberCount"`
Id int64 `json:"id"`
OrgId int64 `json:"orgId"`
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl string `json:"avatarUrl"`
MemberCount int64 `json:"memberCount"`
Permission PermissionType `json:"permission"`
}
type SearchTeamQueryResult struct {

View File

@ -12,11 +12,12 @@ var (
// TeamMember model
type TeamMember struct {
Id int64
OrgId int64
TeamId int64
UserId int64
External bool
Id int64
OrgId int64
TeamId int64
UserId int64
External bool // Signals that the membership has been created by an external systems, such as LDAP
Permission PermissionType
Created time.Time
Updated time.Time
@ -26,16 +27,26 @@ type TeamMember struct {
// COMMANDS
type AddTeamMemberCommand struct {
UserId int64 `json:"userId" binding:"Required"`
OrgId int64 `json:"-"`
TeamId int64 `json:"-"`
External bool `json:"-"`
UserId int64 `json:"userId" binding:"Required"`
OrgId int64 `json:"-"`
TeamId int64 `json:"-"`
External bool `json:"-"`
Permission PermissionType `json:"-"`
}
type UpdateTeamMemberCommand struct {
UserId int64 `json:"-"`
OrgId int64 `json:"-"`
TeamId int64 `json:"-"`
Permission PermissionType `json:"permission"`
ProtectLastAdmin bool `json:"-"`
}
type RemoveTeamMemberCommand struct {
OrgId int64 `json:"-"`
UserId int64
TeamId int64
OrgId int64 `json:"-"`
UserId int64
TeamId int64
ProtectLastAdmin bool `json:"-"`
}
// ----------------------
@ -53,12 +64,13 @@ type GetTeamMembersQuery struct {
// Projections and DTOs
type TeamMemberDTO struct {
OrgId int64 `json:"orgId"`
TeamId int64 `json:"teamId"`
UserId int64 `json:"userId"`
External bool `json:"-"`
Email string `json:"email"`
Login string `json:"login"`
AvatarUrl string `json:"avatarUrl"`
Labels []string `json:"labels"`
OrgId int64 `json:"orgId"`
TeamId int64 `json:"teamId"`
UserId int64 `json:"userId"`
External bool `json:"-"`
Email string `json:"email"`
Login string `json:"login"`
AvatarUrl string `json:"avatarUrl"`
Labels []string `json:"labels"`
Permission PermissionType `json:"permission"`
}

View File

@ -0,0 +1,55 @@
package dashboards
import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"time"
)
func MakeUserAdmin(bus bus.Bus, orgId int64, userId int64, dashboardId int64, setViewAndEditPermissions bool) error {
rtEditor := models.ROLE_EDITOR
rtViewer := models.ROLE_VIEWER
items := []*models.DashboardAcl{
{
OrgId: orgId,
DashboardId: dashboardId,
UserId: userId,
Permission: models.PERMISSION_ADMIN,
Created: time.Now(),
Updated: time.Now(),
},
}
if setViewAndEditPermissions {
items = append(items,
&models.DashboardAcl{
OrgId: orgId,
DashboardId: dashboardId,
Role: &rtEditor,
Permission: models.PERMISSION_EDIT,
Created: time.Now(),
Updated: time.Now(),
},
&models.DashboardAcl{
OrgId: orgId,
DashboardId: dashboardId,
Role: &rtViewer,
Permission: models.PERMISSION_VIEW,
Created: time.Now(),
Updated: time.Now(),
},
)
}
aclCmd := &models.UpdateDashboardAclCommand{
DashboardId: dashboardId,
Items: items,
}
if err := bus.Dispatch(aclCmd); err != nil {
return err
}
return nil
}

View File

@ -54,4 +54,8 @@ func addTeamMigrations(mg *Migrator) {
mg.AddMigration("Add column external to team_member table", NewAddColumnMigration(teamMemberV1, &Column{
Name: "external", Type: DB_Bool, Nullable: true,
}))
mg.AddMigration("Add column permission to team_member table", NewAddColumnMigration(teamMemberV1, &Column{
Name: "permission", Type: DB_SmallInt, Nullable: true,
}))
}

View File

@ -18,10 +18,23 @@ func init() {
bus.AddHandler("sql", GetTeamsByUser)
bus.AddHandler("sql", AddTeamMember)
bus.AddHandler("sql", UpdateTeamMember)
bus.AddHandler("sql", RemoveTeamMember)
bus.AddHandler("sql", GetTeamMembers)
}
func getTeamSearchSqlBase() string {
return `SELECT
team.id as id,
team.org_id,
team.name as name,
team.email as email,
(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count,
team_member.permission
FROM team as team
INNER JOIN team_member on team.id = team_member.team_id AND team_member.user_id = ? `
}
func getTeamSelectSqlBase() string {
return `SELECT
team.id as id,
@ -91,10 +104,8 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error {
// DeleteTeam will delete a team, its member and any permissions connected to the team
func DeleteTeam(cmd *m.DeleteTeamCommand) error {
return inTransaction(func(sess *DBSession) error {
if teamExists, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil {
if _, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil {
return err
} else if !teamExists {
return m.ErrTeamNotFound
}
deletes := []string{
@ -117,7 +128,7 @@ func teamExists(orgId int64, teamId int64, sess *DBSession) (bool, error) {
if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", orgId, teamId); err != nil {
return false, err
} else if len(res) != 1 {
return false, nil
return false, m.ErrTeamNotFound
}
return true, nil
@ -147,7 +158,12 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
var sql bytes.Buffer
params := make([]interface{}, 0)
sql.WriteString(getTeamSelectSqlBase())
if query.UserIdFilter > 0 {
sql.WriteString(getTeamSearchSqlBase())
params = append(params, query.UserIdFilter)
} else {
sql.WriteString(getTeamSelectSqlBase())
}
sql.WriteString(` WHERE team.org_id = ?`)
params = append(params, query.OrgId)
@ -233,19 +249,18 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
return m.ErrTeamMemberAlreadyAdded
}
if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
return err
} else if !teamExists {
return m.ErrTeamNotFound
}
entity := m.TeamMember{
OrgId: cmd.OrgId,
TeamId: cmd.TeamId,
UserId: cmd.UserId,
External: cmd.External,
Created: time.Now(),
Updated: time.Now(),
OrgId: cmd.OrgId,
TeamId: cmd.TeamId,
UserId: cmd.UserId,
External: cmd.External,
Created: time.Now(),
Updated: time.Now(),
Permission: cmd.Permission,
}
_, err := sess.Insert(&entity)
@ -253,13 +268,59 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
})
}
func getTeamMember(sess *DBSession, orgId int64, teamId int64, userId int64) (m.TeamMember, error) {
rawSql := `SELECT * FROM team_member WHERE org_id=? and team_id=? and user_id=?`
var member m.TeamMember
exists, err := sess.SQL(rawSql, orgId, teamId, userId).Get(&member)
if err != nil {
return member, err
}
if !exists {
return member, m.ErrTeamMemberNotFound
}
return member, nil
}
// UpdateTeamMember updates a team member
func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error {
return inTransaction(func(sess *DBSession) error {
member, err := getTeamMember(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
if err != nil {
return err
}
if cmd.ProtectLastAdmin {
_, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
if err != nil {
return err
}
}
if cmd.Permission != m.PERMISSION_ADMIN { // make sure we don't get invalid permission levels in store
cmd.Permission = 0
}
member.Permission = cmd.Permission
_, err = sess.Cols("permission").Where("org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId).Update(member)
return err
})
}
// RemoveTeamMember removes a member from a team
func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
return inTransaction(func(sess *DBSession) error {
if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
return err
} else if !teamExists {
return m.ErrTeamNotFound
}
if cmd.ProtectLastAdmin {
_, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
if err != nil {
return err
}
}
var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?"
@ -276,6 +337,29 @@ func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
})
}
func isLastAdmin(sess *DBSession, orgId int64, teamId int64, userId int64) (bool, error) {
rawSql := "SELECT user_id FROM team_member WHERE org_id=? and team_id=? and permission=?"
userIds := []*int64{}
err := sess.SQL(rawSql, orgId, teamId, m.PERMISSION_ADMIN).Find(&userIds)
if err != nil {
return false, err
}
isAdmin := false
for _, adminId := range userIds {
if userId == *adminId {
isAdmin = true
break
}
}
if isAdmin && len(userIds) == 1 {
return true, m.ErrLastTeamAdmin
}
return false, err
}
// GetTeamMembers return a list of members for the specified team
func GetTeamMembers(query *m.GetTeamMembersQuery) error {
query.Result = make([]*m.TeamMemberDTO, 0)
@ -293,7 +377,7 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error {
if query.External {
sess.Where("team_member.external=?", dialect.BooleanStr(true))
}
sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external")
sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external", "team_member.permission")
sess.Asc("user.login", "user.email")
err := sess.Find(&query.Result)

View File

@ -75,6 +75,72 @@ func TestTeamCommandsAndQueries(t *testing.T) {
So(q2.Result[0].External, ShouldEqual, true)
})
Convey("Should be able to update users in a team", func() {
userId := userIds[0]
team := group1.Result
addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userId}
err = AddTeamMember(&addMemberCmd)
So(err, ShouldBeNil)
qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
err = GetTeamMembers(qBeforeUpdate)
So(err, ShouldBeNil)
So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0)
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
UserId: userId,
OrgId: testOrgId,
TeamId: team.Id,
Permission: m.PERMISSION_ADMIN,
})
So(err, ShouldBeNil)
qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
err = GetTeamMembers(qAfterUpdate)
So(err, ShouldBeNil)
So(qAfterUpdate.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
})
Convey("Should default to member permission level when updating a user with invalid permission level", func() {
userID := userIds[0]
team := group1.Result
addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userID}
err = AddTeamMember(&addMemberCmd)
So(err, ShouldBeNil)
qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
err = GetTeamMembers(qBeforeUpdate)
So(err, ShouldBeNil)
So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0)
invalidPermissionLevel := m.PERMISSION_EDIT
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
UserId: userID,
OrgId: testOrgId,
TeamId: team.Id,
Permission: invalidPermissionLevel,
})
So(err, ShouldBeNil)
qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
err = GetTeamMembers(qAfterUpdate)
So(err, ShouldBeNil)
So(qAfterUpdate.Result[0].Permission, ShouldEqual, 0)
})
Convey("Shouldn't be able to update a user not in the team.", func() {
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
UserId: 1,
OrgId: testOrgId,
TeamId: group1.Result.Id,
Permission: m.PERMISSION_ADMIN,
})
So(err, ShouldEqual, m.ErrTeamMemberNotFound)
})
Convey("Should be able to search for teams", func() {
query := &m.SearchTeamsQuery{OrgId: testOrgId, Query: "group", Page: 1}
err = SearchTeams(query)
@ -114,6 +180,33 @@ func TestTeamCommandsAndQueries(t *testing.T) {
So(len(q2.Result), ShouldEqual, 0)
})
Convey("When ProtectLastAdmin is set to true", func() {
err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: m.PERMISSION_ADMIN})
So(err, ShouldBeNil)
Convey("A user should not be able to remove the last admin", func() {
err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true})
So(err, ShouldEqual, m.ErrLastTeamAdmin)
})
Convey("A user should be able to remove an admin if there are other admins", func() {
AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN})
err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true})
So(err, ShouldEqual, nil)
})
Convey("A user should not be able to remove the admin permission for the last admin", func() {
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true})
So(err, ShouldEqual, m.ErrLastTeamAdmin)
})
Convey("A user should be able to remove the admin permission if there are other admins", func() {
AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN})
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true})
So(err, ShouldEqual, nil)
})
})
Convey("Should be able to remove a group with users and permissions", func() {
groupId := group2.Result.Id
err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[1]})

View File

@ -0,0 +1,34 @@
package teamguardian
import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
)
func CanAdmin(bus bus.Bus, orgId int64, teamId int64, user *m.SignedInUser) error {
if user.OrgRole == m.ROLE_ADMIN {
return nil
}
if user.OrgId != orgId {
return m.ErrNotAllowedToUpdateTeamInDifferentOrg
}
cmd := m.GetTeamMembersQuery{
OrgId: orgId,
TeamId: teamId,
UserId: user.UserId,
}
if err := bus.Dispatch(&cmd); err != nil {
return err
}
for _, member := range cmd.Result {
if member.UserId == user.UserId && member.Permission == m.PERMISSION_ADMIN {
return nil
}
}
return m.ErrNotAllowedToUpdateTeam
}

View File

@ -0,0 +1,87 @@
package teamguardian
import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
"testing"
)
func TestUpdateTeam(t *testing.T) {
Convey("Updating a team", t, func() {
bus.ClearBusHandlers()
admin := m.SignedInUser{
UserId: 1,
OrgId: 1,
OrgRole: m.ROLE_ADMIN,
}
editor := m.SignedInUser{
UserId: 2,
OrgId: 1,
OrgRole: m.ROLE_EDITOR,
}
testTeam := m.Team{
Id: 1,
OrgId: 1,
}
Convey("Given an editor and a team he isn't a member of", func() {
Convey("Should not be able to update the team", func() {
bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
cmd.Result = []*m.TeamMemberDTO{}
return nil
})
err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor)
So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam)
})
})
Convey("Given an editor and a team he is an admin in", func() {
Convey("Should be able to update the team", func() {
bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
cmd.Result = []*m.TeamMemberDTO{{
OrgId: testTeam.OrgId,
TeamId: testTeam.Id,
UserId: editor.UserId,
Permission: m.PERMISSION_ADMIN,
}}
return nil
})
err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor)
So(err, ShouldBeNil)
})
})
Convey("Given an editor and a team in another org", func() {
testTeamOtherOrg := m.Team{
Id: 1,
OrgId: 2,
}
Convey("Shouldn't be able to update the team", func() {
bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
cmd.Result = []*m.TeamMemberDTO{{
OrgId: testTeamOtherOrg.OrgId,
TeamId: testTeamOtherOrg.Id,
UserId: editor.UserId,
Permission: m.PERMISSION_ADMIN,
}}
return nil
})
err := CanAdmin(bus.GetBus(), testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor)
So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg)
})
})
Convey("Given an org admin and a team", func() {
Convey("Should be able to update the team", func() {
err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &admin)
So(err, ShouldBeNil)
})
})
})
}

View File

@ -239,14 +239,13 @@ type Cfg struct {
LoginMaxLifetimeDays int
TokenRotationIntervalMinutes int
// User
EditorsCanOwn bool
// Dataproxy
SendUserHeader bool
// DistributedCache
RemoteCacheOptions *RemoteCacheOptions
EditorsCanAdmin bool
}
type CommandLineArgs struct {
@ -670,7 +669,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
ExternalUserMngLinkName = users.Key("external_manage_link_name").String()
ExternalUserMngInfo = users.Key("external_manage_info").String()
ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false)
cfg.EditorsCanOwn = users.Key("editors_can_own").MustBool(false)
cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false)
// auth
auth := iniFile.Section("auth")

View File

@ -0,0 +1,13 @@
import React, { FunctionComponent } from 'react';
export interface Props {
featureToggle: boolean;
}
export const WithFeatureToggle: FunctionComponent<Props> = ({ featureToggle, children }) => {
if (featureToggle === true) {
return <>{children}</>;
}
return null;
};

View File

@ -12,6 +12,7 @@ const setup = (propOverrides?: object) => {
{
link: {},
user: {
id: 1,
isGrafanaAdmin: false,
isSignedIn: false,
orgCount: 2,

View File

@ -37,7 +37,7 @@ export class Settings {
passwordHint: any;
loginError: any;
viewersCanEdit: boolean;
editorsCanOwn: boolean;
editorsCanAdmin: boolean;
disableSanitizeHtml: boolean;
theme: GrafanaTheme;
@ -59,7 +59,7 @@ export class Settings {
isEnterprise: false,
},
viewersCanEdit: false,
editorsCanOwn: false,
editorsCanAdmin: false,
disableSanitizeHtml: false,
};

View File

@ -3,6 +3,7 @@ import _ from 'lodash';
import coreModule from 'app/core/core_module';
export class User {
id: number;
isGrafanaAdmin: any;
isSignedIn: any;
orgRole: any;

View File

@ -1,8 +1,9 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Props, TeamList } from './TeamList';
import { NavModel, Team } from '../../types';
import { NavModel, Team, OrgRole } from '../../types';
import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks';
import { User } from 'app/core/services/context_srv';
const setup = (propOverrides?: object) => {
const props: Props = {
@ -21,6 +22,11 @@ const setup = (propOverrides?: object) => {
searchQuery: '',
teamsCount: 0,
hasFetched: false,
editorsCanAdmin: false,
signedInUser: {
id: 1,
orgRole: OrgRole.Viewer,
} as User,
};
Object.assign(props, propOverrides);
@ -49,6 +55,42 @@ describe('Render', () => {
expect(wrapper).toMatchSnapshot();
});
describe('when feature toggle editorsCanAdmin is turned on', () => {
describe('and signedin user is not viewer', () => {
it('should enable the new team button', () => {
const { wrapper } = setup({
teams: getMultipleMockTeams(1),
teamsCount: 1,
hasFetched: true,
editorsCanAdmin: true,
signedInUser: {
id: 1,
orgRole: OrgRole.Editor,
} as User,
});
expect(wrapper).toMatchSnapshot();
});
});
describe('and signedin user is a viewer', () => {
it('should disable the new team button', () => {
const { wrapper } = setup({
teams: getMultipleMockTeams(1),
teamsCount: 1,
hasFetched: true,
editorsCanAdmin: true,
signedInUser: {
id: 1,
orgRole: OrgRole.Viewer,
} as User,
});
expect(wrapper).toMatchSnapshot();
});
});
});
});
describe('Life cycle', () => {

View File

@ -4,11 +4,13 @@ import { hot } from 'react-hot-loader';
import Page from 'app/core/components/Page/Page';
import { DeleteButton } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { NavModel, Team } from 'app/types';
import { NavModel, Team, OrgRole } from 'app/types';
import { loadTeams, deleteTeam, setSearchQuery } from './state/actions';
import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors';
import { getSearchQuery, getTeams, getTeamsCount, isPermissionTeamAdmin } from './state/selectors';
import { getNavModel } from 'app/core/selectors/navModel';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { config } from 'app/core/config';
import { contextSrv, User } from 'app/core/services/context_srv';
export interface Props {
navModel: NavModel;
@ -19,6 +21,8 @@ export interface Props {
loadTeams: typeof loadTeams;
deleteTeam: typeof deleteTeam;
setSearchQuery: typeof setSearchQuery;
editorsCanAdmin?: boolean;
signedInUser?: User;
}
export class TeamList extends PureComponent<Props, any> {
@ -39,7 +43,10 @@ export class TeamList extends PureComponent<Props, any> {
};
renderTeam(team: Team) {
const { editorsCanAdmin, signedInUser } = this.props;
const permission = team.permission;
const teamUrl = `org/teams/edit/${team.id}`;
const canDelete = isPermissionTeamAdmin({ permission, editorsCanAdmin, signedInUser });
return (
<tr key={team.id}>
@ -58,7 +65,7 @@ export class TeamList extends PureComponent<Props, any> {
<a href={teamUrl}>{team.memberCount}</a>
</td>
<td className="text-right">
<DeleteButton onConfirm={() => this.deleteTeam(team)} />
<DeleteButton onConfirm={() => this.deleteTeam(team)} disabled={!canDelete} />
</td>
</tr>
);
@ -84,7 +91,10 @@ export class TeamList extends PureComponent<Props, any> {
}
renderTeamList() {
const { teams, searchQuery } = this.props;
const { teams, searchQuery, editorsCanAdmin, signedInUser } = this.props;
const isCanAdminAndViewer = editorsCanAdmin && signedInUser.orgRole === OrgRole.Viewer;
const disabledClass = isCanAdminAndViewer ? ' disabled' : '';
const newTeamHref = isCanAdminAndViewer ? '#' : 'org/teams/new';
return (
<>
@ -101,7 +111,7 @@ export class TeamList extends PureComponent<Props, any> {
<div className="page-action-bar__spacer" />
<a className="btn btn-primary" href="org/teams/new">
<a className={`btn btn-primary${disabledClass}`} href={newTeamHref}>
New team
</a>
</div>
@ -152,6 +162,8 @@ function mapStateToProps(state) {
searchQuery: getSearchQuery(state.teams),
teamsCount: getTeamsCount(state.teams),
hasFetched: state.teams.hasFetched,
editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
};
}

View File

@ -0,0 +1,90 @@
import React from 'react';
import { shallow } from 'enzyme';
import { TeamMember, TeamPermissionLevel } from '../../types';
import { getMockTeamMember } from './__mocks__/teamMocks';
import { TeamMemberRow, Props } from './TeamMemberRow';
import { SelectOptionItem } from '@grafana/ui';
const setup = (propOverrides?: object) => {
const props: Props = {
member: getMockTeamMember(),
syncEnabled: false,
editorsCanAdmin: false,
signedInUserIsTeamAdmin: false,
updateTeamMember: jest.fn(),
removeTeamMember: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = shallow(<TeamMemberRow {...props} />);
const instance = wrapper.instance() as TeamMemberRow;
return {
wrapper,
instance,
};
};
describe('Render', () => {
it('should render team members when sync enabled', () => {
const member = getMockTeamMember();
member.labels = ['LDAP'];
const { wrapper } = setup({ member, syncEnabled: true });
expect(wrapper).toMatchSnapshot();
});
describe('when feature toggle editorsCanAdmin is turned on', () => {
it('should render permissions select if user is team admin', () => {
const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: true });
expect(wrapper).toMatchSnapshot();
});
it('should render span and disable buttons if user is team member', () => {
const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: false });
expect(wrapper).toMatchSnapshot();
});
});
describe('when feature toggle editorsCanAdmin is turned off', () => {
it('should not render permissions', () => {
const { wrapper } = setup({ editorsCanAdmin: false, signedInUserIsTeamAdmin: true });
expect(wrapper).toMatchSnapshot();
});
});
});
describe('Functions', () => {
describe('on remove member', () => {
const member = getMockTeamMember();
const { instance } = setup({ member });
instance.onRemoveMember(member);
expect(instance.props.removeTeamMember).toHaveBeenCalledWith(1);
});
describe('on update permision for user in team', () => {
const member: TeamMember = {
userId: 3,
teamId: 2,
avatarUrl: '',
email: 'user@user.org',
labels: [],
login: 'member',
permission: TeamPermissionLevel.Member,
};
const { instance } = setup({ member });
const permission = TeamPermissionLevel.Admin;
const item: SelectOptionItem = { value: permission };
const expectedTeamMemeber = { ...member, permission };
instance.onPermissionChange(item, member);
expect(instance.props.updateTeamMember).toHaveBeenCalledWith(expectedTeamMemeber);
});
});

View File

@ -0,0 +1,106 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { DeleteButton, Select, SelectOptionItem } from '@grafana/ui';
import { TeamMember, teamsPermissionLevels } from 'app/types';
import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle';
import { updateTeamMember, removeTeamMember } from './state/actions';
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
export interface Props {
member: TeamMember;
syncEnabled: boolean;
editorsCanAdmin: boolean;
signedInUserIsTeamAdmin: boolean;
removeTeamMember?: typeof removeTeamMember;
updateTeamMember?: typeof updateTeamMember;
}
export class TeamMemberRow extends PureComponent<Props> {
constructor(props: Props) {
super(props);
this.renderLabels = this.renderLabels.bind(this);
this.renderPermissions = this.renderPermissions.bind(this);
}
onRemoveMember(member: TeamMember) {
this.props.removeTeamMember(member.userId);
}
onPermissionChange = (item: SelectOptionItem, member: TeamMember) => {
const permission = item.value;
const updatedTeamMember = { ...member, permission };
this.props.updateTeamMember(updatedTeamMember);
};
renderPermissions(member: TeamMember) {
const { editorsCanAdmin, signedInUserIsTeamAdmin } = this.props;
const value = teamsPermissionLevels.find(dp => dp.value === member.permission);
return (
<WithFeatureToggle featureToggle={editorsCanAdmin}>
<td className="width-5 team-permissions">
<div className="gf-form">
{signedInUserIsTeamAdmin && (
<Select
isSearchable={false}
options={teamsPermissionLevels}
onChange={item => this.onPermissionChange(item, member)}
className="gf-form-select-box__control--menu-right"
value={value}
/>
)}
{!signedInUserIsTeamAdmin && <span>{value.label}</span>}
</div>
</td>
</WithFeatureToggle>
);
}
renderLabels(labels: string[]) {
if (!labels) {
return <td />;
}
return (
<td>
{labels.map(label => (
<TagBadge key={label} label={label} removeIcon={false} count={0} onClick={() => {}} />
))}
</td>
);
}
render() {
const { member, syncEnabled, signedInUserIsTeamAdmin } = this.props;
return (
<tr key={member.userId}>
<td className="width-4 text-center">
<img className="filter-table__avatar" src={member.avatarUrl} />
</td>
<td>{member.login}</td>
<td>{member.email}</td>
{this.renderPermissions(member)}
{syncEnabled && this.renderLabels(member.labels)}
<td className="text-right">
<DeleteButton onConfirm={() => this.onRemoveMember(member)} disabled={!signedInUserIsTeamAdmin} />
</td>
</tr>
);
}
}
function mapStateToProps(state) {
return {};
}
const mapDispatchToProps = {
removeTeamMember,
updateTeamMember,
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(TeamMemberRow);

View File

@ -1,18 +1,25 @@
import React from 'react';
import { shallow } from 'enzyme';
import { TeamMembers, Props, State } from './TeamMembers';
import { TeamMember } from '../../types';
import { getMockTeamMember, getMockTeamMembers } from './__mocks__/teamMocks';
import { TeamMember, OrgRole } from '../../types';
import { getMockTeamMembers } from './__mocks__/teamMocks';
import { User } from 'app/core/services/context_srv';
const signedInUserId = 1;
const setup = (propOverrides?: object) => {
const props: Props = {
members: [] as TeamMember[],
searchMemberQuery: '',
setSearchMemberQuery: jest.fn(),
loadTeamMembers: jest.fn(),
addTeamMember: jest.fn(),
removeTeamMember: jest.fn(),
syncEnabled: false,
editorsCanAdmin: false,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
};
Object.assign(props, propOverrides);
@ -28,24 +35,13 @@ const setup = (propOverrides?: object) => {
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
const { wrapper } = setup({});
expect(wrapper).toMatchSnapshot();
});
it('should render team members', () => {
const { wrapper } = setup({
members: getMockTeamMembers(5),
});
expect(wrapper).toMatchSnapshot();
});
it('should render team members when sync enabled', () => {
const { wrapper } = setup({
members: getMockTeamMembers(5),
syncEnabled: true,
});
const { wrapper } = setup({ members: getMockTeamMembers(5, 5) });
expect(wrapper).toMatchSnapshot();
});
@ -54,7 +50,7 @@ describe('Render', () => {
describe('Functions', () => {
describe('on search member query change', () => {
it('it should call setSearchMemberQuery', () => {
const { instance } = setup();
const { instance } = setup({});
instance.onSearchQueryChange('member');
@ -62,17 +58,8 @@ describe('Functions', () => {
});
});
describe('on remove member', () => {
const { instance } = setup();
const mockTeamMember = getMockTeamMember();
instance.onRemoveMember(mockTeamMember);
expect(instance.props.removeTeamMember).toHaveBeenCalledWith(1);
});
describe('on add user to team', () => {
const { wrapper, instance } = setup();
const { wrapper, instance } = setup({});
const state = wrapper.state() as State;
state.newTeamMember = {

View File

@ -2,21 +2,24 @@ import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import SlideDown from 'app/core/components/Animations/SlideDown';
import { UserPicker } from 'app/core/components/Select/UserPicker';
import { DeleteButton } from '@grafana/ui';
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
import { TeamMember, User } from 'app/types';
import { loadTeamMembers, addTeamMember, removeTeamMember, setSearchMemberQuery } from './state/actions';
import { getSearchMemberQuery, getTeamMembers } from './state/selectors';
import { addTeamMember, setSearchMemberQuery } from './state/actions';
import { getSearchMemberQuery, isSignedInUserTeamAdmin } from './state/selectors';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle';
import { config } from 'app/core/config';
import { contextSrv, User as SignedInUser } from 'app/core/services/context_srv';
import TeamMemberRow from './TeamMemberRow';
export interface Props {
members: TeamMember[];
searchMemberQuery: string;
loadTeamMembers: typeof loadTeamMembers;
addTeamMember: typeof addTeamMember;
removeTeamMember: typeof removeTeamMember;
setSearchMemberQuery: typeof setSearchMemberQuery;
syncEnabled: boolean;
editorsCanAdmin?: boolean;
signedInUser?: SignedInUser;
}
export interface State {
@ -30,18 +33,10 @@ export class TeamMembers extends PureComponent<Props, State> {
this.state = { isAdding: false, newTeamMember: null };
}
componentDidMount() {
this.props.loadTeamMembers();
}
onSearchQueryChange = (value: string) => {
this.props.setSearchMemberQuery(value);
};
onRemoveMember(member: TeamMember) {
this.props.removeTeamMember(member.userId);
}
onToggleAdding = () => {
this.setState({ isAdding: !this.state.isAdding });
};
@ -69,25 +64,11 @@ export class TeamMembers extends PureComponent<Props, State> {
);
}
renderMember(member: TeamMember, syncEnabled: boolean) {
return (
<tr key={member.userId}>
<td className="width-4 text-center">
<img className="filter-table__avatar" src={member.avatarUrl} />
</td>
<td>{member.login}</td>
<td>{member.email}</td>
{syncEnabled && this.renderLabels(member.labels)}
<td className="text-right">
<DeleteButton onConfirm={() => this.onRemoveMember(member)} />
</td>
</tr>
);
}
render() {
const { isAdding } = this.state;
const { searchMemberQuery, members, syncEnabled } = this.props;
const { searchMemberQuery, members, syncEnabled, editorsCanAdmin, signedInUser } = this.props;
const isTeamAdmin = isSignedInUserTeamAdmin({ members, editorsCanAdmin, signedInUser });
return (
<div>
<div className="page-action-bar">
@ -103,7 +84,11 @@ export class TeamMembers extends PureComponent<Props, State> {
<div className="page-action-bar__spacer" />
<button className="btn btn-primary pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
<button
className="btn btn-primary pull-right"
onClick={this.onToggleAdding}
disabled={isAdding || !isTeamAdmin}
>
Add member
</button>
</div>
@ -132,11 +117,25 @@ export class TeamMembers extends PureComponent<Props, State> {
<th />
<th>Name</th>
<th>Email</th>
<WithFeatureToggle featureToggle={editorsCanAdmin}>
<th>Permission</th>
</WithFeatureToggle>
{syncEnabled && <th />}
<th style={{ width: '1%' }} />
</tr>
</thead>
<tbody>{members && members.map(member => this.renderMember(member, syncEnabled))}</tbody>
<tbody>
{members &&
members.map(member => (
<TeamMemberRow
key={member.userId}
member={member}
syncEnabled={syncEnabled}
editorsCanAdmin={editorsCanAdmin}
signedInUserIsTeamAdmin={isTeamAdmin}
/>
))}
</tbody>
</table>
</div>
</div>
@ -146,15 +145,14 @@ export class TeamMembers extends PureComponent<Props, State> {
function mapStateToProps(state) {
return {
members: getTeamMembers(state.team),
searchMemberQuery: getSearchMemberQuery(state.team),
editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
};
}
const mapDispatchToProps = {
loadTeamMembers,
addTeamMember,
removeTeamMember,
setSearchMemberQuery,
};

View File

@ -1,8 +1,9 @@
import React from 'react';
import { shallow } from 'enzyme';
import { TeamPages, Props } from './TeamPages';
import { NavModel, Team } from '../../types';
import { NavModel, Team, TeamMember, OrgRole } from '../../types';
import { getMockTeam } from './__mocks__/teamMocks';
import { User } from 'app/core/services/context_srv';
jest.mock('app/core/config', () => ({
buildInfo: { isEnterprise: true },
@ -13,8 +14,16 @@ const setup = (propOverrides?: object) => {
navModel: {} as NavModel,
teamId: 1,
loadTeam: jest.fn(),
loadTeamMembers: jest.fn(),
pageName: 'members',
team: {} as Team,
members: [] as TeamMember[],
editorsCanAdmin: false,
signedInUser: {
id: 1,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
};
Object.assign(props, propOverrides);
@ -65,4 +74,46 @@ describe('Render', () => {
expect(wrapper).toMatchSnapshot();
});
describe('when feature toggle editorsCanAdmin is turned on', () => {
it('should render settings page if user is team admin', () => {
const { wrapper } = setup({
team: getMockTeam(),
pageName: 'settings',
preferences: {
homeDashboardId: 1,
theme: 'Default',
timezone: 'Default',
},
editorsCanAdmin: true,
signedInUser: {
id: 1,
isGrafanaAdmin: false,
orgRole: OrgRole.Admin,
} as User,
});
expect(wrapper).toMatchSnapshot();
});
it('should not render settings page if user is team member', () => {
const { wrapper } = setup({
team: getMockTeam(),
pageName: 'settings',
preferences: {
homeDashboardId: 1,
theme: 'Default',
timezone: 'Default',
},
editorsCanAdmin: true,
signedInUser: {
id: 1,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
});
expect(wrapper).toMatchSnapshot();
});
});
});

View File

@ -7,19 +7,24 @@ import Page from 'app/core/components/Page/Page';
import TeamMembers from './TeamMembers';
import TeamSettings from './TeamSettings';
import TeamGroupSync from './TeamGroupSync';
import { NavModel, Team } from 'app/types';
import { loadTeam } from './state/actions';
import { getTeam } from './state/selectors';
import { NavModel, Team, TeamMember } from 'app/types';
import { loadTeam, loadTeamMembers } from './state/actions';
import { getTeam, getTeamMembers, isSignedInUserTeamAdmin } from './state/selectors';
import { getTeamLoadingNav } from './state/navModel';
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParamsId, getRouteParamsPage } from '../../core/selectors/location';
import { contextSrv, User } from 'app/core/services/context_srv';
export interface Props {
team: Team;
loadTeam: typeof loadTeam;
loadTeamMembers: typeof loadTeamMembers;
teamId: number;
pageName: string;
navModel: NavModel;
members?: TeamMember[];
editorsCanAdmin?: boolean;
signedInUser?: User;
}
interface State {
@ -51,6 +56,7 @@ export class TeamPages extends PureComponent<Props, State> {
const { loadTeam, teamId } = this.props;
this.setState({ isLoading: true });
const team = await loadTeam(teamId);
await this.props.loadTeamMembers();
this.setState({ isLoading: false });
return team;
}
@ -61,30 +67,56 @@ export class TeamPages extends PureComponent<Props, State> {
return _.includes(pages, currentPage) ? currentPage : pages[0];
}
renderPage() {
textsAreEqual = (text1: string, text2: string) => {
if (!text1 && !text2) {
return true;
}
if (!text1 || !text2) {
return false;
}
return text1.toLocaleLowerCase() === text2.toLocaleLowerCase();
};
hideTabsFromNonTeamAdmin = (navModel: NavModel, isSignedInUserTeamAdmin: boolean) => {
if (!isSignedInUserTeamAdmin && navModel.main && navModel.main.children) {
navModel.main.children
.filter(navItem => !this.textsAreEqual(navItem.text, PageTypes.Members))
.map(navItem => {
navItem.hideFromTabs = true;
});
}
return navModel;
};
renderPage(isSignedInUserTeamAdmin: boolean) {
const { isSyncEnabled } = this.state;
const { members } = this.props;
const currentPage = this.getCurrentPage();
switch (currentPage) {
case PageTypes.Members:
return <TeamMembers syncEnabled={isSyncEnabled} />;
return <TeamMembers syncEnabled={isSyncEnabled} members={members} />;
case PageTypes.Settings:
return <TeamSettings />;
return isSignedInUserTeamAdmin && <TeamSettings />;
case PageTypes.GroupSync:
return isSyncEnabled && <TeamGroupSync />;
return isSignedInUserTeamAdmin && isSyncEnabled && <TeamGroupSync />;
}
return null;
}
render() {
const { team, navModel } = this.props;
const { team, navModel, members, editorsCanAdmin, signedInUser } = this.props;
const isTeamAdmin = isSignedInUserTeamAdmin({ members, editorsCanAdmin, signedInUser });
return (
<Page navModel={navModel}>
<Page navModel={this.hideTabsFromNonTeamAdmin(navModel, isTeamAdmin)}>
<Page.Contents isLoading={this.state.isLoading}>
{team && Object.keys(team).length !== 0 && this.renderPage()}
{team && Object.keys(team).length !== 0 && this.renderPage(isTeamAdmin)}
</Page.Contents>
</Page>
);
@ -95,17 +127,24 @@ function mapStateToProps(state) {
const teamId = getRouteParamsId(state.location);
const pageName = getRouteParamsPage(state.location) || 'members';
const teamLoadingNav = getTeamLoadingNav(pageName);
const navModel = getNavModel(state.navIndex, `team-${pageName}-${teamId}`, teamLoadingNav);
const team = getTeam(state.team, teamId);
const members = getTeamMembers(state.team);
return {
navModel: getNavModel(state.navIndex, `team-${pageName}-${teamId}`, teamLoadingNav),
navModel,
teamId: teamId,
pageName: pageName,
team: getTeam(state.team, teamId),
team,
members,
editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
};
}
const mapDispatchToProps = {
loadTeam,
loadTeamMembers,
};
export default hot(module)(

View File

@ -1,4 +1,4 @@
import { Team, TeamGroup, TeamMember } from 'app/types';
import { Team, TeamGroup, TeamMember, TeamPermissionLevel } from 'app/types';
export const getMultipleMockTeams = (numberOfTeams: number): Team[] => {
const teams: Team[] = [];
@ -9,6 +9,7 @@ export const getMultipleMockTeams = (numberOfTeams: number): Team[] => {
avatarUrl: 'some/url/',
email: `test-${i}@test.com`,
memberCount: i,
permission: TeamPermissionLevel.Member,
});
}
@ -22,10 +23,11 @@ export const getMockTeam = (): Team => {
avatarUrl: 'some/url/',
email: 'test@test.com',
memberCount: 1,
permission: TeamPermissionLevel.Member,
};
};
export const getMockTeamMembers = (amount: number): TeamMember[] => {
export const getMockTeamMembers = (amount: number, teamAdminId: number): TeamMember[] => {
const teamMembers: TeamMember[] = [];
for (let i = 1; i <= amount; i++) {
@ -36,6 +38,7 @@ export const getMockTeamMembers = (amount: number): TeamMember[] => {
email: 'test@test.com',
login: `testUser-${i}`,
labels: ['label 1', 'label 2'],
permission: i === teamAdminId ? TeamPermissionLevel.Admin : TeamPermissionLevel.Member,
});
}
@ -50,6 +53,7 @@ export const getMockTeamMember = (): TeamMember => {
email: 'test@test.com',
login: 'testUser',
labels: [],
permission: TeamPermissionLevel.Member,
};
};

View File

@ -133,6 +133,7 @@ exports[`Render should render teams table 1`] = `
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
@ -183,6 +184,7 @@ exports[`Render should render teams table 1`] = `
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
@ -233,6 +235,7 @@ exports[`Render should render teams table 1`] = `
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
@ -283,6 +286,7 @@ exports[`Render should render teams table 1`] = `
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
@ -333,6 +337,259 @@ exports[`Render should render teams table 1`] = `
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
</tr>
</tbody>
</table>
</div>
</PageContents>
</Page>
`;
exports[`Render when feature toggle editorsCanAdmin is turned on and signedin user is a viewer should disable the new team button 1`] = `
<Page
navModel={
Object {
"main": Object {
"text": "Configuration",
},
"node": Object {
"text": "Team List",
},
}
}
>
<PageContents
isLoading={false}
>
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<ForwardRef
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search teams"
value=""
/>
</div>
<div
className="page-action-bar__spacer"
/>
<a
className="btn btn-primary disabled"
href="#"
>
New team
</a>
</div>
<div
className="admin-list-table"
>
<table
className="filter-table filter-table--hover form-inline"
>
<thead>
<tr>
<th />
<th>
Name
</th>
<th>
Email
</th>
<th>
Members
</th>
<th
style={
Object {
"width": "1%",
}
}
/>
</tr>
</thead>
<tbody>
<tr
key="1"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/1"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
1
</a>
</td>
<td
className="text-right"
>
<DeleteButton
disabled={true}
onConfirm={[Function]}
/>
</td>
</tr>
</tbody>
</table>
</div>
</PageContents>
</Page>
`;
exports[`Render when feature toggle editorsCanAdmin is turned on and signedin user is not viewer should enable the new team button 1`] = `
<Page
navModel={
Object {
"main": Object {
"text": "Configuration",
},
"node": Object {
"text": "Team List",
},
}
}
>
<PageContents
isLoading={false}
>
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<ForwardRef
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search teams"
value=""
/>
</div>
<div
className="page-action-bar__spacer"
/>
<a
className="btn btn-primary"
href="org/teams/new"
>
New team
</a>
</div>
<div
className="admin-list-table"
>
<table
className="filter-table filter-table--hover form-inline"
>
<thead>
<tr>
<th />
<th>
Name
</th>
<th>
Email
</th>
<th>
Members
</th>
<th
style={
Object {
"width": "1%",
}
}
/>
</tr>
</thead>
<tbody>
<tr
key="1"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/1"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
1
</a>
</td>
<td
className="text-right"
>
<DeleteButton
disabled={true}
onConfirm={[Function]}
/>
</td>

View File

@ -0,0 +1,250 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render team members when sync enabled 1`] = `
<tr
key="1"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser
</td>
<td>
test@test.com
</td>
<Component
featureToggle={false}
>
<td
className="width-5 team-permissions"
>
<div
className="gf-form"
>
<span>
Member
</span>
</div>
</td>
</Component>
<td>
<TagBadge
count={0}
key="LDAP"
label="LDAP"
onClick={[Function]}
removeIcon={false}
/>
</td>
<td
className="text-right"
>
<DeleteButton
disabled={true}
onConfirm={[Function]}
/>
</td>
</tr>
`;
exports[`Render when feature toggle editorsCanAdmin is turned off should not render permissions 1`] = `
<tr
key="1"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser
</td>
<td>
test@test.com
</td>
<Component
featureToggle={false}
>
<td
className="width-5 team-permissions"
>
<div
className="gf-form"
>
<Select
autoFocus={false}
backspaceRemovesValue={true}
className="gf-form-select-box__control--menu-right"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={false}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={
Array [
Object {
"description": "Is team member",
"label": "Member",
"value": 0,
},
Object {
"description": "Can add/remove permissions, members and delete team.",
"label": "Admin",
"value": 4,
},
]
}
value={
Object {
"description": "Is team member",
"label": "Member",
"value": 0,
}
}
width={null}
/>
</div>
</td>
</Component>
<td
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
</tr>
`;
exports[`Render when feature toggle editorsCanAdmin is turned on should render permissions select if user is team admin 1`] = `
<tr
key="1"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser
</td>
<td>
test@test.com
</td>
<Component
featureToggle={true}
>
<td
className="width-5 team-permissions"
>
<div
className="gf-form"
>
<Select
autoFocus={false}
backspaceRemovesValue={true}
className="gf-form-select-box__control--menu-right"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={false}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={
Array [
Object {
"description": "Is team member",
"label": "Member",
"value": 0,
},
Object {
"description": "Can add/remove permissions, members and delete team.",
"label": "Admin",
"value": 4,
},
]
}
value={
Object {
"description": "Is team member",
"label": "Member",
"value": 0,
}
}
width={null}
/>
</div>
</td>
</Component>
<td
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
</tr>
`;
exports[`Render when feature toggle editorsCanAdmin is turned on should render span and disable buttons if user is team member 1`] = `
<tr
key="1"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser
</td>
<td>
test@test.com
</td>
<Component
featureToggle={true}
>
<td
className="width-5 team-permissions"
>
<div
className="gf-form"
>
<span>
Member
</span>
</div>
</td>
</Component>
<td
className="text-right"
>
<DeleteButton
disabled={true}
onConfirm={[Function]}
/>
</td>
</tr>
`;

View File

@ -69,6 +69,13 @@ exports[`Render should render component 1`] = `
<th>
Email
</th>
<Component
featureToggle={false}
>
<th>
Permission
</th>
</Component>
<th
style={
Object {
@ -153,6 +160,13 @@ exports[`Render should render team members 1`] = `
<th>
Email
</th>
<Component
featureToggle={false}
>
<th>
Permission
</th>
</Component>
<th
style={
Object {
@ -163,422 +177,106 @@ exports[`Render should render team members 1`] = `
</tr>
</thead>
<tbody>
<tr
<Connect(TeamMemberRow)
editorsCanAdmin={false}
key="1"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-1
</td>
<td>
test@test.com
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
<tr
key="2"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-2
</td>
<td>
test@test.com
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
<tr
key="3"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-3
</td>
<td>
test@test.com
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
<tr
key="4"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-4
</td>
<td>
test@test.com
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
<tr
key="5"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-5
</td>
<td>
test@test.com
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
`;
exports[`Render should render team members when sync enabled 1`] = `
<div>
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<ForwardRef
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search members"
value=""
/>
</div>
<div
className="page-action-bar__spacer"
/>
<button
className="btn btn-primary pull-right"
disabled={false}
onClick={[Function]}
>
Add member
</button>
</div>
<Component
in={false}
>
<div
className="cta-form"
>
<button
className="cta-form__close btn btn-transparent"
onClick={[Function]}
>
<i
className="fa fa-close"
/>
</button>
<h5>
Add team member
</h5>
<div
className="gf-form-inline"
>
<UserPicker
className="min-width-30"
onSelected={[Function]}
/>
</div>
</div>
</Component>
<div
className="admin-list-table"
>
<table
className="filter-table filter-table--hover form-inline"
>
<thead>
<tr>
<th />
<th>
Name
</th>
<th>
Email
</th>
<th />
<th
style={
Object {
"width": "1%",
}
member={
Object {
"avatarUrl": "some/url/",
"email": "test@test.com",
"labels": Array [
"label 1",
"label 2",
],
"login": "testUser-1",
"permission": 0,
"teamId": 1,
"userId": 1,
}
/>
</tr>
</thead>
<tbody>
<tr
key="1"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-1
</td>
<td>
test@test.com
</td>
<td>
<TagBadge
count={0}
key="label 1"
label="label 1"
onClick={[Function]}
removeIcon={false}
/>
<TagBadge
count={0}
key="label 2"
label="label 2"
onClick={[Function]}
removeIcon={false}
/>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
<tr
}
signedInUserIsTeamAdmin={true}
syncEnabled={false}
/>
<Connect(TeamMemberRow)
editorsCanAdmin={false}
key="2"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-2
</td>
<td>
test@test.com
</td>
<td>
<TagBadge
count={0}
key="label 1"
label="label 1"
onClick={[Function]}
removeIcon={false}
/>
<TagBadge
count={0}
key="label 2"
label="label 2"
onClick={[Function]}
removeIcon={false}
/>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
<tr
member={
Object {
"avatarUrl": "some/url/",
"email": "test@test.com",
"labels": Array [
"label 1",
"label 2",
],
"login": "testUser-2",
"permission": 0,
"teamId": 1,
"userId": 2,
}
}
signedInUserIsTeamAdmin={true}
syncEnabled={false}
/>
<Connect(TeamMemberRow)
editorsCanAdmin={false}
key="3"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-3
</td>
<td>
test@test.com
</td>
<td>
<TagBadge
count={0}
key="label 1"
label="label 1"
onClick={[Function]}
removeIcon={false}
/>
<TagBadge
count={0}
key="label 2"
label="label 2"
onClick={[Function]}
removeIcon={false}
/>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
<tr
member={
Object {
"avatarUrl": "some/url/",
"email": "test@test.com",
"labels": Array [
"label 1",
"label 2",
],
"login": "testUser-3",
"permission": 0,
"teamId": 1,
"userId": 3,
}
}
signedInUserIsTeamAdmin={true}
syncEnabled={false}
/>
<Connect(TeamMemberRow)
editorsCanAdmin={false}
key="4"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-4
</td>
<td>
test@test.com
</td>
<td>
<TagBadge
count={0}
key="label 1"
label="label 1"
onClick={[Function]}
removeIcon={false}
/>
<TagBadge
count={0}
key="label 2"
label="label 2"
onClick={[Function]}
removeIcon={false}
/>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
<tr
member={
Object {
"avatarUrl": "some/url/",
"email": "test@test.com",
"labels": Array [
"label 1",
"label 2",
],
"login": "testUser-4",
"permission": 0,
"teamId": 1,
"userId": 4,
}
}
signedInUserIsTeamAdmin={true}
syncEnabled={false}
/>
<Connect(TeamMemberRow)
editorsCanAdmin={false}
key="5"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-5
</td>
<td>
test@test.com
</td>
<td>
<TagBadge
count={0}
key="label 1"
label="label 1"
onClick={[Function]}
removeIcon={false}
/>
<TagBadge
count={0}
key="label 2"
label="label 2"
onClick={[Function]}
removeIcon={false}
/>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
member={
Object {
"avatarUrl": "some/url/",
"email": "test@test.com",
"labels": Array [
"label 1",
"label 2",
],
"login": "testUser-5",
"permission": 4,
"teamId": 1,
"userId": 5,
}
}
signedInUserIsTeamAdmin={true}
syncEnabled={false}
/>
</tbody>
</table>
</div>

View File

@ -30,6 +30,7 @@ exports[`Render should render member page if team not empty 1`] = `
isLoading={true}
>
<Connect(TeamMembers)
members={Array []}
syncEnabled={true}
/>
</PageContents>
@ -47,3 +48,25 @@ exports[`Render should render settings and preferences page 1`] = `
</PageContents>
</Page>
`;
exports[`Render when feature toggle editorsCanAdmin is turned on should not render settings page if user is team member 1`] = `
<Page
navModel={Object {}}
>
<PageContents
isLoading={true}
/>
</Page>
`;
exports[`Render when feature toggle editorsCanAdmin is turned on should render settings page if user is team admin 1`] = `
<Page
navModel={Object {}}
>
<PageContents
isLoading={true}
>
<Connect(TeamSettings) />
</PageContents>
</Page>
`;

View File

@ -160,3 +160,12 @@ export function deleteTeam(id: number): ThunkResult<void> {
dispatch(loadTeams());
};
}
export function updateTeamMember(member: TeamMember): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().put(`/api/teams/${member.teamId}/members/${member.userId}`, {
permission: member.permission,
});
dispatch(loadTeamMembers());
};
}

View File

@ -1,4 +1,4 @@
import { Team, NavModelItem, NavModel } from 'app/types';
import { Team, NavModelItem, NavModel, TeamPermissionLevel } from 'app/types';
import config from 'app/core/config';
export function buildNavModel(team: Team): NavModelItem {
@ -47,6 +47,7 @@ export function getTeamLoadingNav(pageName: string): NavModel {
name: 'Loading',
email: 'loading',
memberCount: 0,
permission: TeamPermissionLevel.Member,
});
let node: NavModelItem;

View File

@ -1,6 +1,7 @@
import { getTeam, getTeamMembers, getTeams } from './selectors';
import { getTeam, getTeamMembers, getTeams, isSignedInUserTeamAdmin, Config } from './selectors';
import { getMockTeam, getMockTeamMembers, getMultipleMockTeams } from '../__mocks__/teamMocks';
import { Team, TeamGroup, TeamsState, TeamState } from '../../../types';
import { Team, TeamGroup, TeamsState, TeamState, OrgRole } from '../../../types';
import { User } from 'app/core/services/context_srv';
describe('Teams selectors', () => {
describe('Get teams', () => {
@ -40,7 +41,7 @@ describe('Team selectors', () => {
});
describe('Get members', () => {
const mockTeamMembers = getMockTeamMembers(5);
const mockTeamMembers = getMockTeamMembers(5, 5);
it('should return team members', () => {
const mockState: TeamState = {
@ -55,3 +56,94 @@ describe('Team selectors', () => {
});
});
});
const signedInUserId = 1;
const setup = (configOverrides?: Partial<Config>) => {
const defaultConfig: Config = {
editorsCanAdmin: false,
members: getMockTeamMembers(5, 5),
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
};
return { ...defaultConfig, ...configOverrides };
};
describe('isSignedInUserTeamAdmin', () => {
describe('when feature toggle editorsCanAdmin is turned off', () => {
it('should return true', () => {
const config = setup();
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(true);
});
});
describe('when feature toggle editorsCanAdmin is turned on', () => {
it('should return true if signed in user is grafanaAdmin', () => {
const config = setup({
editorsCanAdmin: true,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: true,
orgRole: OrgRole.Viewer,
} as User,
});
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(true);
});
it('should return true if signed in user is org admin', () => {
const config = setup({
editorsCanAdmin: true,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Admin,
} as User,
});
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(true);
});
it('should return true if signed in user is team admin', () => {
const config = setup({
members: getMockTeamMembers(5, signedInUserId),
editorsCanAdmin: true,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
});
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(true);
});
it('should return false if signed in user is not grafanaAdmin, org admin or team admin', () => {
const config = setup({
editorsCanAdmin: true,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
});
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(false);
});
});
});

View File

@ -1,4 +1,5 @@
import { Team, TeamsState, TeamState } from 'app/types';
import { Team, TeamsState, TeamState, TeamMember, OrgRole, TeamPermissionLevel } from 'app/types';
import { User } from 'app/core/services/context_srv';
export const getSearchQuery = (state: TeamsState) => state.searchQuery;
export const getSearchMemberQuery = (state: TeamState) => state.searchMemberQuery;
@ -28,3 +29,32 @@ export const getTeamMembers = (state: TeamState) => {
return regex.test(member.login) || regex.test(member.email);
});
};
export interface Config {
members: TeamMember[];
editorsCanAdmin: boolean;
signedInUser: User;
}
export const isSignedInUserTeamAdmin = (config: Config): boolean => {
const { members, signedInUser, editorsCanAdmin } = config;
const userInMembers = members.find(m => m.userId === signedInUser.id);
const permission = userInMembers ? userInMembers.permission : TeamPermissionLevel.Member;
return isPermissionTeamAdmin({ permission, signedInUser, editorsCanAdmin });
};
export interface PermissionConfig {
permission: TeamPermissionLevel;
editorsCanAdmin: boolean;
signedInUser: User;
}
export const isPermissionTeamAdmin = (config: PermissionConfig): boolean => {
const { permission, signedInUser, editorsCanAdmin } = config;
const isAdmin = signedInUser.isGrafanaAdmin || signedInUser.orgRole === OrgRole.Admin;
const userIsTeamAdmin = permission === TeamPermissionLevel.Admin;
const isSignedInUserTeamAdmin = isAdmin || userIsTeamAdmin;
return isSignedInUserTeamAdmin || !editorsCanAdmin;
};

View File

@ -195,7 +195,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
.when('/org/teams', {
template: '<react-container />',
resolve: {
roles: () => ['Editor', 'Admin'],
roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']),
component: () => TeamList,
},
})
@ -207,7 +207,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
.when('/org/teams/edit/:id/:page?', {
template: '<react-container />',
resolve: {
roles: () => ['Admin'],
roles: () => (config.editorsCanAdmin ? [] : ['Admin']),
component: () => TeamPages,
},
})

View File

@ -98,3 +98,23 @@ export const dashboardPermissionLevels: DashboardPermissionInfo[] = [
description: 'Can add/remove permissions and can add, edit and delete dashboards.',
},
];
export enum TeamPermissionLevel {
Member = 0,
Admin = 4,
}
export interface TeamPermissionInfo {
value: TeamPermissionLevel;
label: string;
description: string;
}
export const teamsPermissionLevels: TeamPermissionInfo[] = [
{ value: TeamPermissionLevel.Member, label: 'Member', description: 'Is team member' },
{
value: TeamPermissionLevel.Admin,
label: 'Admin',
description: 'Can add/remove permissions, members and delete team.',
},
];

View File

@ -1,9 +1,12 @@
import { TeamPermissionLevel } from './acl';
export interface Team {
id: number;
name: string;
avatarUrl: string;
email: string;
memberCount: number;
permission: TeamPermissionLevel;
}
export interface TeamMember {
@ -13,6 +16,7 @@ export interface TeamMember {
email: string;
login: string;
labels: string[];
permission: number;
}
export interface TeamGroup {

View File

@ -19,3 +19,9 @@ td.admin-settings-key {
margin-bottom: 5px;
}
}
.admin-list-table {
.team-permissions {
padding-right: 120px;
}
}