Browse Source

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

Editors becomes admin when creating dashboards, folders & teams
Leonard Gram 6 years ago
parent
commit
f2b06a89f2
50 changed files with 1895 additions and 615 deletions
  1. 1 1
      conf/defaults.ini
  2. 1 1
      conf/sample.ini
  3. 5 0
      docs/sources/installation/configuration.md
  4. 3 0
      docs/sources/permissions/organization_roles.md
  5. 11 13
      packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx
  6. 14 12
      pkg/api/api.go
  7. 11 1
      pkg/api/dashboard.go
  8. 2 0
      pkg/api/dashboard_test.go
  9. 8 1
      pkg/api/folder.go
  10. 7 1
      pkg/api/folder_test.go
  11. 1 1
      pkg/api/frontendsettings.go
  12. 21 0
      pkg/api/index.go
  13. 69 17
      pkg/api/team.go
  14. 49 5
      pkg/api/team_members.go
  15. 8 2
      pkg/api/team_test.go
  16. 17 0
      pkg/middleware/auth.go
  17. 19 14
      pkg/models/team.go
  18. 32 20
      pkg/models/team_member.go
  19. 55 0
      pkg/services/dashboards/acl_service.go
  20. 4 0
      pkg/services/sqlstore/migrations/team_mig.go
  21. 103 19
      pkg/services/sqlstore/team.go
  22. 93 0
      pkg/services/sqlstore/team_test.go
  23. 34 0
      pkg/services/teamguardian/team.go
  24. 87 0
      pkg/services/teamguardian/teams_test.go
  25. 3 4
      pkg/setting/setting.go
  26. 13 0
      public/app/core/components/WithFeatureToggle.tsx
  27. 1 0
      public/app/core/components/sidemenu/BottomNavLinks.test.tsx
  28. 2 2
      public/app/core/config.ts
  29. 1 0
      public/app/core/services/context_srv.ts
  30. 43 1
      public/app/features/teams/TeamList.test.tsx
  31. 17 5
      public/app/features/teams/TeamList.tsx
  32. 90 0
      public/app/features/teams/TeamMemberRow.test.tsx
  33. 106 0
      public/app/features/teams/TeamMemberRow.tsx
  34. 15 28
      public/app/features/teams/TeamMembers.test.tsx
  35. 33 35
      public/app/features/teams/TeamMembers.tsx
  36. 52 1
      public/app/features/teams/TeamPages.test.tsx
  37. 51 12
      public/app/features/teams/TeamPages.tsx
  38. 6 2
      public/app/features/teams/__mocks__/teamMocks.ts
  39. 257 0
      public/app/features/teams/__snapshots__/TeamList.test.tsx.snap
  40. 250 0
      public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap
  41. 108 410
      public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap
  42. 23 0
      public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap
  43. 9 0
      public/app/features/teams/state/actions.ts
  44. 2 1
      public/app/features/teams/state/navModel.ts
  45. 95 3
      public/app/features/teams/state/selectors.test.ts
  46. 31 1
      public/app/features/teams/state/selectors.ts
  47. 2 2
      public/app/routes/routes.ts
  48. 20 0
      public/app/types/acl.ts
  49. 4 0
      public/app/types/teams.ts
  50. 6 0
      public/sass/pages/_admin.scss

+ 1 - 1
conf/defaults.ini

@@ -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

+ 1 - 1
conf/sample.ini

@@ -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

+ 5 - 0
docs/sources/installation/configuration.md

@@ -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.

+ 3 - 0
docs/sources/permissions/organization_roles.md

@@ -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.

+ 11 - 13
packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx

@@ -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>

+ 14 - 12
pkg/api/api.go

@@ -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))

+ 11 - 1
pkg/api/dashboard.go

@@ -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",

+ 2 - 0
pkg/api/dashboard_test.go

@@ -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(),
 		}
 

+ 8 - 1
pkg/api/folder.go

@@ -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))
 }

+ 7 - 1
pkg/api/folder_test.go

@@ -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

+ 1 - 1
pkg/api/frontendsettings.go

@@ -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,

+ 21 - 0
pkg/api/index.go

@@ -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),

+ 69 - 17
pkg/api/team.go

@@ -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)
 }

+ 49 - 5
pkg/api/team_members.go

@@ -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)
 		}

+ 8 - 2
pkg/api/team_test.go

@@ -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)

+ 17 - 0
pkg/middleware/auth.go

@@ -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)
+		}
+	}
+}

+ 19 - 14
pkg/models/team.go

@@ -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 {

+ 32 - 20
pkg/models/team_member.go

@@ -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"`
 }

+ 55 - 0
pkg/services/dashboards/acl_service.go

@@ -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
+}

+ 4 - 0
pkg/services/sqlstore/migrations/team_mig.go

@@ -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,
+	}))
 }

+ 103 - 19
pkg/services/sqlstore/team.go

@@ -18,17 +18,30 @@ 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,
 		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
+		(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count 
 		FROM team as team `
 }
 
@@ -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)

+ 93 - 0
pkg/services/sqlstore/team_test.go

@@ -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]})

+ 34 - 0
pkg/services/teamguardian/team.go

@@ -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
+}

+ 87 - 0
pkg/services/teamguardian/teams_test.go

@@ -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)
+			})
+		})
+	})
+}

+ 3 - 4
pkg/setting/setting.go

@@ -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")

+ 13 - 0
public/app/core/components/WithFeatureToggle.tsx

@@ -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;
+};

+ 1 - 0
public/app/core/components/sidemenu/BottomNavLinks.test.tsx

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

+ 2 - 2
public/app/core/config.ts

@@ -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,
     };
 

+ 1 - 0
public/app/core/services/context_srv.ts

@@ -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;

+ 43 - 1
public/app/features/teams/TeamList.test.tsx

@@ -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', () => {

+ 17 - 5
public/app/features/teams/TeamList.tsx

@@ -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,
   };
 }
 

+ 90 - 0
public/app/features/teams/TeamMemberRow.test.tsx

@@ -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);
+  });
+});

+ 106 - 0
public/app/features/teams/TeamMemberRow.tsx

@@ -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);

+ 15 - 28
public/app/features/teams/TeamMembers.test.tsx

@@ -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 = {

+ 33 - 35
public/app/features/teams/TeamMembers.tsx

@@ -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,
 };
 

+ 52 - 1
public/app/features/teams/TeamPages.test.tsx

@@ -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();
+    });
+  });
 });

+ 51 - 12
public/app/features/teams/TeamPages.tsx

@@ -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)(

+ 6 - 2
public/app/features/teams/__mocks__/teamMocks.ts

@@ -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,
   };
 };
 

+ 257 - 0
public/app/features/teams/__snapshots__/TeamList.test.tsx.snap

@@ -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>

+ 250 - 0
public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap

@@ -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>
+`;

+ 108 - 410
public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap

@@ -69,6 +69,13 @@ exports[`Render should render component 1`] = `
           <th>
             Email
           </th>
+          <Component
+            featureToggle={false}
+          >
+            <th>
+              Permission
+            </th>
+          </Component>
           <th
             style={
               Object {
@@ -153,217 +160,13 @@ exports[`Render should render team members 1`] = `
           <th>
             Email
           </th>
-          <th
-            style={
-              Object {
-                "width": "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
-            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"
+          <Component
+            featureToggle={false}
           >
-            <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>
+              Permission
+            </th>
+          </Component>
           <th
             style={
               Object {
@@ -374,211 +177,106 @@ exports[`Render should render team members when sync enabled 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>
-            <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-1",
+              "permission": 0,
+              "teamId": 1,
+              "userId": 1,
+            }
+          }
+          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>

+ 23 - 0
public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap

@@ -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>
+`;

+ 9 - 0
public/app/features/teams/state/actions.ts

@@ -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());
+  };
+}

+ 2 - 1
public/app/features/teams/state/navModel.ts

@@ -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;

+ 95 - 3
public/app/features/teams/state/selectors.test.ts

@@ -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);
+    });
+  });
+});

+ 31 - 1
public/app/features/teams/state/selectors.ts

@@ -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;
+};

+ 2 - 2
public/app/routes/routes.ts

@@ -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,
       },
     })

+ 20 - 0
public/app/types/acl.ts

@@ -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.',
+  },
+];

+ 4 - 0
public/app/types/teams.ts

@@ -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 {

+ 6 - 0
public/sass/pages/_admin.scss

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