mirror of
https://github.com/grafana/grafana.git
synced 2024-11-28 03:34:15 -06:00
Merge pull request #15977 from grafana/admin-on-create-poc
Editors becomes admin when creating dashboards, folders & teams
This commit is contained in:
commit
f2b06a89f2
@ -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
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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,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))
|
||||
|
@ -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",
|
||||
|
@ -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(),
|
||||
}
|
||||
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
pkg/services/dashboards/acl_service.go
Normal file
55
pkg/services/dashboards/acl_service.go
Normal file
@ -0,0 +1,55 @@
|
||||
package dashboards
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
func MakeUserAdmin(bus bus.Bus, orgId int64, userId int64, dashboardId int64, setViewAndEditPermissions bool) error {
|
||||
rtEditor := models.ROLE_EDITOR
|
||||
rtViewer := models.ROLE_VIEWER
|
||||
|
||||
items := []*models.DashboardAcl{
|
||||
{
|
||||
OrgId: orgId,
|
||||
DashboardId: dashboardId,
|
||||
UserId: userId,
|
||||
Permission: models.PERMISSION_ADMIN,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
if setViewAndEditPermissions {
|
||||
items = append(items,
|
||||
&models.DashboardAcl{
|
||||
OrgId: orgId,
|
||||
DashboardId: dashboardId,
|
||||
Role: &rtEditor,
|
||||
Permission: models.PERMISSION_EDIT,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
},
|
||||
&models.DashboardAcl{
|
||||
OrgId: orgId,
|
||||
DashboardId: dashboardId,
|
||||
Role: &rtViewer,
|
||||
Permission: models.PERMISSION_VIEW,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
aclCmd := &models.UpdateDashboardAclCommand{
|
||||
DashboardId: dashboardId,
|
||||
Items: items,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(aclCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
@ -18,10 +18,23 @@ func init() {
|
||||
bus.AddHandler("sql", GetTeamsByUser)
|
||||
|
||||
bus.AddHandler("sql", AddTeamMember)
|
||||
bus.AddHandler("sql", UpdateTeamMember)
|
||||
bus.AddHandler("sql", RemoveTeamMember)
|
||||
bus.AddHandler("sql", GetTeamMembers)
|
||||
}
|
||||
|
||||
func getTeamSearchSqlBase() string {
|
||||
return `SELECT
|
||||
team.id as id,
|
||||
team.org_id,
|
||||
team.name as name,
|
||||
team.email as email,
|
||||
(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count,
|
||||
team_member.permission
|
||||
FROM team as team
|
||||
INNER JOIN team_member on team.id = team_member.team_id AND team_member.user_id = ? `
|
||||
}
|
||||
|
||||
func getTeamSelectSqlBase() string {
|
||||
return `SELECT
|
||||
team.id as id,
|
||||
@ -91,10 +104,8 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error {
|
||||
// DeleteTeam will delete a team, its member and any permissions connected to the team
|
||||
func DeleteTeam(cmd *m.DeleteTeamCommand) error {
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
if teamExists, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil {
|
||||
if _, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil {
|
||||
return err
|
||||
} else if !teamExists {
|
||||
return m.ErrTeamNotFound
|
||||
}
|
||||
|
||||
deletes := []string{
|
||||
@ -117,7 +128,7 @@ func teamExists(orgId int64, teamId int64, sess *DBSession) (bool, error) {
|
||||
if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", orgId, teamId); err != nil {
|
||||
return false, err
|
||||
} else if len(res) != 1 {
|
||||
return false, nil
|
||||
return false, m.ErrTeamNotFound
|
||||
}
|
||||
|
||||
return true, nil
|
||||
@ -147,7 +158,12 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
|
||||
var sql bytes.Buffer
|
||||
params := make([]interface{}, 0)
|
||||
|
||||
sql.WriteString(getTeamSelectSqlBase())
|
||||
if query.UserIdFilter > 0 {
|
||||
sql.WriteString(getTeamSearchSqlBase())
|
||||
params = append(params, query.UserIdFilter)
|
||||
} else {
|
||||
sql.WriteString(getTeamSelectSqlBase())
|
||||
}
|
||||
sql.WriteString(` WHERE team.org_id = ?`)
|
||||
|
||||
params = append(params, query.OrgId)
|
||||
@ -233,19 +249,18 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
|
||||
return m.ErrTeamMemberAlreadyAdded
|
||||
}
|
||||
|
||||
if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
|
||||
if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
|
||||
return err
|
||||
} else if !teamExists {
|
||||
return m.ErrTeamNotFound
|
||||
}
|
||||
|
||||
entity := m.TeamMember{
|
||||
OrgId: cmd.OrgId,
|
||||
TeamId: cmd.TeamId,
|
||||
UserId: cmd.UserId,
|
||||
External: cmd.External,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
OrgId: cmd.OrgId,
|
||||
TeamId: cmd.TeamId,
|
||||
UserId: cmd.UserId,
|
||||
External: cmd.External,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
Permission: cmd.Permission,
|
||||
}
|
||||
|
||||
_, err := sess.Insert(&entity)
|
||||
@ -253,13 +268,59 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
|
||||
})
|
||||
}
|
||||
|
||||
func getTeamMember(sess *DBSession, orgId int64, teamId int64, userId int64) (m.TeamMember, error) {
|
||||
rawSql := `SELECT * FROM team_member WHERE org_id=? and team_id=? and user_id=?`
|
||||
var member m.TeamMember
|
||||
exists, err := sess.SQL(rawSql, orgId, teamId, userId).Get(&member)
|
||||
|
||||
if err != nil {
|
||||
return member, err
|
||||
}
|
||||
if !exists {
|
||||
return member, m.ErrTeamMemberNotFound
|
||||
}
|
||||
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// UpdateTeamMember updates a team member
|
||||
func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error {
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
member, err := getTeamMember(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cmd.ProtectLastAdmin {
|
||||
_, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if cmd.Permission != m.PERMISSION_ADMIN { // make sure we don't get invalid permission levels in store
|
||||
cmd.Permission = 0
|
||||
}
|
||||
|
||||
member.Permission = cmd.Permission
|
||||
_, err = sess.Cols("permission").Where("org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId).Update(member)
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveTeamMember removes a member from a team
|
||||
func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
|
||||
if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
|
||||
return err
|
||||
} else if !teamExists {
|
||||
return m.ErrTeamNotFound
|
||||
}
|
||||
|
||||
if cmd.ProtectLastAdmin {
|
||||
_, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?"
|
||||
@ -276,6 +337,29 @@ func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
|
||||
})
|
||||
}
|
||||
|
||||
func isLastAdmin(sess *DBSession, orgId int64, teamId int64, userId int64) (bool, error) {
|
||||
rawSql := "SELECT user_id FROM team_member WHERE org_id=? and team_id=? and permission=?"
|
||||
userIds := []*int64{}
|
||||
err := sess.SQL(rawSql, orgId, teamId, m.PERMISSION_ADMIN).Find(&userIds)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
for _, adminId := range userIds {
|
||||
if userId == *adminId {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isAdmin && len(userIds) == 1 {
|
||||
return true, m.ErrLastTeamAdmin
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
// GetTeamMembers return a list of members for the specified team
|
||||
func GetTeamMembers(query *m.GetTeamMembersQuery) error {
|
||||
query.Result = make([]*m.TeamMemberDTO, 0)
|
||||
@ -293,7 +377,7 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error {
|
||||
if query.External {
|
||||
sess.Where("team_member.external=?", dialect.BooleanStr(true))
|
||||
}
|
||||
sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external")
|
||||
sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external", "team_member.permission")
|
||||
sess.Asc("user.login", "user.email")
|
||||
|
||||
err := sess.Find(&query.Result)
|
||||
|
@ -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
pkg/services/teamguardian/team.go
Normal file
34
pkg/services/teamguardian/team.go
Normal file
@ -0,0 +1,34 @@
|
||||
package teamguardian
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
|
||||
func CanAdmin(bus bus.Bus, orgId int64, teamId int64, user *m.SignedInUser) error {
|
||||
if user.OrgRole == m.ROLE_ADMIN {
|
||||
return nil
|
||||
}
|
||||
|
||||
if user.OrgId != orgId {
|
||||
return m.ErrNotAllowedToUpdateTeamInDifferentOrg
|
||||
}
|
||||
|
||||
cmd := m.GetTeamMembersQuery{
|
||||
OrgId: orgId,
|
||||
TeamId: teamId,
|
||||
UserId: user.UserId,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, member := range cmd.Result {
|
||||
if member.UserId == user.UserId && member.Permission == m.PERMISSION_ADMIN {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return m.ErrNotAllowedToUpdateTeam
|
||||
}
|
87
pkg/services/teamguardian/teams_test.go
Normal file
87
pkg/services/teamguardian/teams_test.go
Normal file
@ -0,0 +1,87 @@
|
||||
package teamguardian
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateTeam(t *testing.T) {
|
||||
Convey("Updating a team", t, func() {
|
||||
bus.ClearBusHandlers()
|
||||
|
||||
admin := m.SignedInUser{
|
||||
UserId: 1,
|
||||
OrgId: 1,
|
||||
OrgRole: m.ROLE_ADMIN,
|
||||
}
|
||||
editor := m.SignedInUser{
|
||||
UserId: 2,
|
||||
OrgId: 1,
|
||||
OrgRole: m.ROLE_EDITOR,
|
||||
}
|
||||
testTeam := m.Team{
|
||||
Id: 1,
|
||||
OrgId: 1,
|
||||
}
|
||||
|
||||
Convey("Given an editor and a team he isn't a member of", func() {
|
||||
Convey("Should not be able to update the team", func() {
|
||||
bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
|
||||
cmd.Result = []*m.TeamMemberDTO{}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor)
|
||||
So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given an editor and a team he is an admin in", func() {
|
||||
Convey("Should be able to update the team", func() {
|
||||
bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
|
||||
cmd.Result = []*m.TeamMemberDTO{{
|
||||
OrgId: testTeam.OrgId,
|
||||
TeamId: testTeam.Id,
|
||||
UserId: editor.UserId,
|
||||
Permission: m.PERMISSION_ADMIN,
|
||||
}}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor)
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given an editor and a team in another org", func() {
|
||||
testTeamOtherOrg := m.Team{
|
||||
Id: 1,
|
||||
OrgId: 2,
|
||||
}
|
||||
|
||||
Convey("Shouldn't be able to update the team", func() {
|
||||
bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
|
||||
cmd.Result = []*m.TeamMemberDTO{{
|
||||
OrgId: testTeamOtherOrg.OrgId,
|
||||
TeamId: testTeamOtherOrg.Id,
|
||||
UserId: editor.UserId,
|
||||
Permission: m.PERMISSION_ADMIN,
|
||||
}}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := CanAdmin(bus.GetBus(), testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor)
|
||||
So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given an org admin and a team", func() {
|
||||
Convey("Should be able to update the team", func() {
|
||||
err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &admin)
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
@ -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
public/app/core/components/WithFeatureToggle.tsx
Normal file
13
public/app/core/components/WithFeatureToggle.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React, { FunctionComponent } from 'react';
|
||||
|
||||
export interface Props {
|
||||
featureToggle: boolean;
|
||||
}
|
||||
|
||||
export const WithFeatureToggle: FunctionComponent<Props> = ({ featureToggle, children }) => {
|
||||
if (featureToggle === true) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
@ -12,6 +12,7 @@ const setup = (propOverrides?: object) => {
|
||||
{
|
||||
link: {},
|
||||
user: {
|
||||
id: 1,
|
||||
isGrafanaAdmin: false,
|
||||
isSignedIn: false,
|
||||
orgCount: 2,
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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', () => {
|
||||
|
@ -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
public/app/features/teams/TeamMemberRow.test.tsx
Normal file
90
public/app/features/teams/TeamMemberRow.test.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { TeamMember, TeamPermissionLevel } from '../../types';
|
||||
import { getMockTeamMember } from './__mocks__/teamMocks';
|
||||
import { TeamMemberRow, Props } from './TeamMemberRow';
|
||||
import { SelectOptionItem } from '@grafana/ui';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
member: getMockTeamMember(),
|
||||
syncEnabled: false,
|
||||
editorsCanAdmin: false,
|
||||
signedInUserIsTeamAdmin: false,
|
||||
updateTeamMember: jest.fn(),
|
||||
removeTeamMember: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<TeamMemberRow {...props} />);
|
||||
const instance = wrapper.instance() as TeamMemberRow;
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
instance,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render team members when sync enabled', () => {
|
||||
const member = getMockTeamMember();
|
||||
member.labels = ['LDAP'];
|
||||
const { wrapper } = setup({ member, syncEnabled: true });
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('when feature toggle editorsCanAdmin is turned on', () => {
|
||||
it('should render permissions select if user is team admin', () => {
|
||||
const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: true });
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render span and disable buttons if user is team member', () => {
|
||||
const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: false });
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when feature toggle editorsCanAdmin is turned off', () => {
|
||||
it('should not render permissions', () => {
|
||||
const { wrapper } = setup({ editorsCanAdmin: false, signedInUserIsTeamAdmin: true });
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Functions', () => {
|
||||
describe('on remove member', () => {
|
||||
const member = getMockTeamMember();
|
||||
const { instance } = setup({ member });
|
||||
|
||||
instance.onRemoveMember(member);
|
||||
|
||||
expect(instance.props.removeTeamMember).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
describe('on update permision for user in team', () => {
|
||||
const member: TeamMember = {
|
||||
userId: 3,
|
||||
teamId: 2,
|
||||
avatarUrl: '',
|
||||
email: 'user@user.org',
|
||||
labels: [],
|
||||
login: 'member',
|
||||
permission: TeamPermissionLevel.Member,
|
||||
};
|
||||
const { instance } = setup({ member });
|
||||
const permission = TeamPermissionLevel.Admin;
|
||||
const item: SelectOptionItem = { value: permission };
|
||||
const expectedTeamMemeber = { ...member, permission };
|
||||
|
||||
instance.onPermissionChange(item, member);
|
||||
|
||||
expect(instance.props.updateTeamMember).toHaveBeenCalledWith(expectedTeamMemeber);
|
||||
});
|
||||
});
|
106
public/app/features/teams/TeamMemberRow.tsx
Normal file
106
public/app/features/teams/TeamMemberRow.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { DeleteButton, Select, SelectOptionItem } from '@grafana/ui';
|
||||
|
||||
import { TeamMember, teamsPermissionLevels } from 'app/types';
|
||||
import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle';
|
||||
import { updateTeamMember, removeTeamMember } from './state/actions';
|
||||
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
|
||||
|
||||
export interface Props {
|
||||
member: TeamMember;
|
||||
syncEnabled: boolean;
|
||||
editorsCanAdmin: boolean;
|
||||
signedInUserIsTeamAdmin: boolean;
|
||||
removeTeamMember?: typeof removeTeamMember;
|
||||
updateTeamMember?: typeof updateTeamMember;
|
||||
}
|
||||
|
||||
export class TeamMemberRow extends PureComponent<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.renderLabels = this.renderLabels.bind(this);
|
||||
this.renderPermissions = this.renderPermissions.bind(this);
|
||||
}
|
||||
|
||||
onRemoveMember(member: TeamMember) {
|
||||
this.props.removeTeamMember(member.userId);
|
||||
}
|
||||
|
||||
onPermissionChange = (item: SelectOptionItem, member: TeamMember) => {
|
||||
const permission = item.value;
|
||||
const updatedTeamMember = { ...member, permission };
|
||||
|
||||
this.props.updateTeamMember(updatedTeamMember);
|
||||
};
|
||||
|
||||
renderPermissions(member: TeamMember) {
|
||||
const { editorsCanAdmin, signedInUserIsTeamAdmin } = this.props;
|
||||
const value = teamsPermissionLevels.find(dp => dp.value === member.permission);
|
||||
|
||||
return (
|
||||
<WithFeatureToggle featureToggle={editorsCanAdmin}>
|
||||
<td className="width-5 team-permissions">
|
||||
<div className="gf-form">
|
||||
{signedInUserIsTeamAdmin && (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
options={teamsPermissionLevels}
|
||||
onChange={item => this.onPermissionChange(item, member)}
|
||||
className="gf-form-select-box__control--menu-right"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
{!signedInUserIsTeamAdmin && <span>{value.label}</span>}
|
||||
</div>
|
||||
</td>
|
||||
</WithFeatureToggle>
|
||||
);
|
||||
}
|
||||
|
||||
renderLabels(labels: string[]) {
|
||||
if (!labels) {
|
||||
return <td />;
|
||||
}
|
||||
|
||||
return (
|
||||
<td>
|
||||
{labels.map(label => (
|
||||
<TagBadge key={label} label={label} removeIcon={false} count={0} onClick={() => {}} />
|
||||
))}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { member, syncEnabled, signedInUserIsTeamAdmin } = this.props;
|
||||
return (
|
||||
<tr key={member.userId}>
|
||||
<td className="width-4 text-center">
|
||||
<img className="filter-table__avatar" src={member.avatarUrl} />
|
||||
</td>
|
||||
<td>{member.login}</td>
|
||||
<td>{member.email}</td>
|
||||
{this.renderPermissions(member)}
|
||||
{syncEnabled && this.renderLabels(member.labels)}
|
||||
<td className="text-right">
|
||||
<DeleteButton onConfirm={() => this.onRemoveMember(member)} disabled={!signedInUserIsTeamAdmin} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
removeTeamMember,
|
||||
updateTeamMember,
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(TeamMemberRow);
|
@ -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 = {
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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)(
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
`;
|
@ -69,6 +69,13 @@ exports[`Render should render component 1`] = `
|
||||
<th>
|
||||
Email
|
||||
</th>
|
||||
<Component
|
||||
featureToggle={false}
|
||||
>
|
||||
<th>
|
||||
Permission
|
||||
</th>
|
||||
</Component>
|
||||
<th
|
||||
style={
|
||||
Object {
|
||||
@ -153,6 +160,13 @@ exports[`Render should render team members 1`] = `
|
||||
<th>
|
||||
Email
|
||||
</th>
|
||||
<Component
|
||||
featureToggle={false}
|
||||
>
|
||||
<th>
|
||||
Permission
|
||||
</th>
|
||||
</Component>
|
||||
<th
|
||||
style={
|
||||
Object {
|
||||
@ -163,422 +177,106 @@ exports[`Render should render team members 1`] = `
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
<Connect(TeamMemberRow)
|
||||
editorsCanAdmin={false}
|
||||
key="1"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
testUser-1
|
||||
</td>
|
||||
<td>
|
||||
test@test.com
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<DeleteButton
|
||||
onConfirm={[Function]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="2"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
testUser-2
|
||||
</td>
|
||||
<td>
|
||||
test@test.com
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<DeleteButton
|
||||
onConfirm={[Function]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="3"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
testUser-3
|
||||
</td>
|
||||
<td>
|
||||
test@test.com
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<DeleteButton
|
||||
onConfirm={[Function]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="4"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
testUser-4
|
||||
</td>
|
||||
<td>
|
||||
test@test.com
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<DeleteButton
|
||||
onConfirm={[Function]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="5"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
testUser-5
|
||||
</td>
|
||||
<td>
|
||||
test@test.com
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<DeleteButton
|
||||
onConfirm={[Function]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should render team members when sync enabled 1`] = `
|
||||
<div>
|
||||
<div
|
||||
className="page-action-bar"
|
||||
>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<ForwardRef
|
||||
inputClassName="gf-form-input"
|
||||
labelClassName="gf-form--has-input-icon gf-form--grow"
|
||||
onChange={[Function]}
|
||||
placeholder="Search members"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="page-action-bar__spacer"
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary pull-right"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Add member
|
||||
</button>
|
||||
</div>
|
||||
<Component
|
||||
in={false}
|
||||
>
|
||||
<div
|
||||
className="cta-form"
|
||||
>
|
||||
<button
|
||||
className="cta-form__close btn btn-transparent"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-close"
|
||||
/>
|
||||
</button>
|
||||
<h5>
|
||||
Add team member
|
||||
</h5>
|
||||
<div
|
||||
className="gf-form-inline"
|
||||
>
|
||||
<UserPicker
|
||||
className="min-width-30"
|
||||
onSelected={[Function]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
<div
|
||||
className="admin-list-table"
|
||||
>
|
||||
<table
|
||||
className="filter-table filter-table--hover form-inline"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>
|
||||
Name
|
||||
</th>
|
||||
<th>
|
||||
Email
|
||||
</th>
|
||||
<th />
|
||||
<th
|
||||
style={
|
||||
Object {
|
||||
"width": "1%",
|
||||
}
|
||||
member={
|
||||
Object {
|
||||
"avatarUrl": "some/url/",
|
||||
"email": "test@test.com",
|
||||
"labels": Array [
|
||||
"label 1",
|
||||
"label 2",
|
||||
],
|
||||
"login": "testUser-1",
|
||||
"permission": 0,
|
||||
"teamId": 1,
|
||||
"userId": 1,
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
key="1"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
testUser-1
|
||||
</td>
|
||||
<td>
|
||||
test@test.com
|
||||
</td>
|
||||
<td>
|
||||
<TagBadge
|
||||
count={0}
|
||||
key="label 1"
|
||||
label="label 1"
|
||||
onClick={[Function]}
|
||||
removeIcon={false}
|
||||
/>
|
||||
<TagBadge
|
||||
count={0}
|
||||
key="label 2"
|
||||
label="label 2"
|
||||
onClick={[Function]}
|
||||
removeIcon={false}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<DeleteButton
|
||||
onConfirm={[Function]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
}
|
||||
signedInUserIsTeamAdmin={true}
|
||||
syncEnabled={false}
|
||||
/>
|
||||
<Connect(TeamMemberRow)
|
||||
editorsCanAdmin={false}
|
||||
key="2"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
testUser-2
|
||||
</td>
|
||||
<td>
|
||||
test@test.com
|
||||
</td>
|
||||
<td>
|
||||
<TagBadge
|
||||
count={0}
|
||||
key="label 1"
|
||||
label="label 1"
|
||||
onClick={[Function]}
|
||||
removeIcon={false}
|
||||
/>
|
||||
<TagBadge
|
||||
count={0}
|
||||
key="label 2"
|
||||
label="label 2"
|
||||
onClick={[Function]}
|
||||
removeIcon={false}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<DeleteButton
|
||||
onConfirm={[Function]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
member={
|
||||
Object {
|
||||
"avatarUrl": "some/url/",
|
||||
"email": "test@test.com",
|
||||
"labels": Array [
|
||||
"label 1",
|
||||
"label 2",
|
||||
],
|
||||
"login": "testUser-2",
|
||||
"permission": 0,
|
||||
"teamId": 1,
|
||||
"userId": 2,
|
||||
}
|
||||
}
|
||||
signedInUserIsTeamAdmin={true}
|
||||
syncEnabled={false}
|
||||
/>
|
||||
<Connect(TeamMemberRow)
|
||||
editorsCanAdmin={false}
|
||||
key="3"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
testUser-3
|
||||
</td>
|
||||
<td>
|
||||
test@test.com
|
||||
</td>
|
||||
<td>
|
||||
<TagBadge
|
||||
count={0}
|
||||
key="label 1"
|
||||
label="label 1"
|
||||
onClick={[Function]}
|
||||
removeIcon={false}
|
||||
/>
|
||||
<TagBadge
|
||||
count={0}
|
||||
key="label 2"
|
||||
label="label 2"
|
||||
onClick={[Function]}
|
||||
removeIcon={false}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<DeleteButton
|
||||
onConfirm={[Function]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
member={
|
||||
Object {
|
||||
"avatarUrl": "some/url/",
|
||||
"email": "test@test.com",
|
||||
"labels": Array [
|
||||
"label 1",
|
||||
"label 2",
|
||||
],
|
||||
"login": "testUser-3",
|
||||
"permission": 0,
|
||||
"teamId": 1,
|
||||
"userId": 3,
|
||||
}
|
||||
}
|
||||
signedInUserIsTeamAdmin={true}
|
||||
syncEnabled={false}
|
||||
/>
|
||||
<Connect(TeamMemberRow)
|
||||
editorsCanAdmin={false}
|
||||
key="4"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
testUser-4
|
||||
</td>
|
||||
<td>
|
||||
test@test.com
|
||||
</td>
|
||||
<td>
|
||||
<TagBadge
|
||||
count={0}
|
||||
key="label 1"
|
||||
label="label 1"
|
||||
onClick={[Function]}
|
||||
removeIcon={false}
|
||||
/>
|
||||
<TagBadge
|
||||
count={0}
|
||||
key="label 2"
|
||||
label="label 2"
|
||||
onClick={[Function]}
|
||||
removeIcon={false}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<DeleteButton
|
||||
onConfirm={[Function]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
member={
|
||||
Object {
|
||||
"avatarUrl": "some/url/",
|
||||
"email": "test@test.com",
|
||||
"labels": Array [
|
||||
"label 1",
|
||||
"label 2",
|
||||
],
|
||||
"login": "testUser-4",
|
||||
"permission": 0,
|
||||
"teamId": 1,
|
||||
"userId": 4,
|
||||
}
|
||||
}
|
||||
signedInUserIsTeamAdmin={true}
|
||||
syncEnabled={false}
|
||||
/>
|
||||
<Connect(TeamMemberRow)
|
||||
editorsCanAdmin={false}
|
||||
key="5"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
testUser-5
|
||||
</td>
|
||||
<td>
|
||||
test@test.com
|
||||
</td>
|
||||
<td>
|
||||
<TagBadge
|
||||
count={0}
|
||||
key="label 1"
|
||||
label="label 1"
|
||||
onClick={[Function]}
|
||||
removeIcon={false}
|
||||
/>
|
||||
<TagBadge
|
||||
count={0}
|
||||
key="label 2"
|
||||
label="label 2"
|
||||
onClick={[Function]}
|
||||
removeIcon={false}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<DeleteButton
|
||||
onConfirm={[Function]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
member={
|
||||
Object {
|
||||
"avatarUrl": "some/url/",
|
||||
"email": "test@test.com",
|
||||
"labels": Array [
|
||||
"label 1",
|
||||
"label 2",
|
||||
],
|
||||
"login": "testUser-5",
|
||||
"permission": 4,
|
||||
"teamId": 1,
|
||||
"userId": 5,
|
||||
}
|
||||
}
|
||||
signedInUserIsTeamAdmin={true}
|
||||
syncEnabled={false}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -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>
|
||||
`;
|
||||
|
@ -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());
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
@ -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.',
|
||||
},
|
||||
];
|
||||
|
@ -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 {
|
||||
|
@ -19,3 +19,9 @@ td.admin-settings-key {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-list-table {
|
||||
.team-permissions {
|
||||
padding-right: 120px;
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user