Browse Source

Merge branch '7883_new_url_structure' into 7883_frontend_step2

Marcus Efraimsson 8 years ago
parent
commit
ef90b3e49c
100 changed files with 3221 additions and 1536 deletions
  1. 1 1
      docs/sources/administration/provisioning.md
  2. 1 1
      docs/sources/index.md
  3. 3 2
      package.json
  4. 53 24
      pkg/api/alerting.go
  5. 97 0
      pkg/api/alerting_test.go
  6. 65 0
      pkg/api/annotations.go
  7. 242 0
      pkg/api/annotations_test.go
  8. 14 12
      pkg/api/api.go
  9. 105 0
      pkg/api/common_test.go
  10. 31 18
      pkg/api/dashboard.go
  11. 16 0
      pkg/api/dashboard_acl.go
  12. 92 8
      pkg/api/dashboard_acl_test.go
  13. 17 18
      pkg/api/dashboard_test.go
  14. 0 91
      pkg/api/datasources_test.go
  15. 1 0
      pkg/api/dtos/alerting.go
  16. 1 0
      pkg/api/dtos/dashboard.go
  17. 3 3
      pkg/api/index.go
  18. 2 0
      pkg/middleware/dashboard_redirect_test.go
  19. 0 4
      pkg/models/alert.go
  20. 51 13
      pkg/models/dashboards.go
  21. 2 0
      pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go
  22. 119 119
      pkg/services/alerting/ticker_test.go
  23. 10 8
      pkg/services/annotations/annotations.go
  24. 42 4
      pkg/services/guardian/guardian.go
  25. 0 7
      pkg/services/sqlstore/alert.go
  26. 12 0
      pkg/services/sqlstore/annotation.go
  27. 36 0
      pkg/services/sqlstore/annotation_test.go
  28. 187 23
      pkg/services/sqlstore/dashboard.go
  29. 69 46
      pkg/services/sqlstore/dashboard_acl.go
  30. 18 0
      pkg/services/sqlstore/dashboard_acl_test.go
  31. 349 0
      pkg/services/sqlstore/dashboard_folder_test.go
  32. 156 203
      pkg/services/sqlstore/dashboard_test.go
  33. 2 3
      pkg/services/sqlstore/dashboard_version_test.go
  34. 4 0
      pkg/services/sqlstore/migrations/dashboard_mig.go
  35. 1 0
      public/app/containers/AlertRuleList/AlertRuleList.jest.tsx
  36. 20 6
      public/app/containers/AlertRuleList/AlertRuleList.tsx
  37. 3 2
      public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap
  38. 5 0
      public/app/containers/IContainerProps.ts
  39. 49 0
      public/app/containers/ManageDashboards/FolderPermissions.tsx
  40. 78 0
      public/app/containers/ManageDashboards/FolderSettings.jest.tsx
  41. 153 0
      public/app/containers/ManageDashboards/FolderSettings.tsx
  42. 1 1
      public/app/containers/ServerStats/ServerStats.jest.tsx
  43. 1 1
      public/app/containers/ServerStats/__snapshots__/ServerStats.jest.tsx.snap
  44. 10 2
      public/app/core/angular_wrappers.ts
  45. 53 0
      public/app/core/components/PageHeader/PageHeader.jest.tsx
  46. 73 58
      public/app/core/components/PageHeader/PageHeader.tsx
  47. 46 0
      public/app/core/components/Permissions/DashboardPermissions.tsx
  48. 40 0
      public/app/core/components/Permissions/DisabledPermissionsListItem.tsx
  49. 5 0
      public/app/core/components/Permissions/FolderInfo.ts
  50. 73 0
      public/app/core/components/Permissions/Permissions.jest.tsx
  51. 161 0
      public/app/core/components/Permissions/Permissions.tsx
  52. 13 0
      public/app/core/components/Permissions/PermissionsInfo.tsx
  53. 64 0
      public/app/core/components/Permissions/PermissionsList.tsx
  54. 65 0
      public/app/core/components/Permissions/PermissionsListItem.tsx
  55. 56 0
      public/app/core/components/Picker/DescriptionOption.tsx
  56. 48 0
      public/app/core/components/Picker/DescriptionPicker.tsx
  57. 3 3
      public/app/core/components/Picker/PickerOption.jest.tsx
  58. 0 0
      public/app/core/components/Picker/PickerOption.tsx
  59. 79 0
      public/app/core/components/Picker/TeamPicker.tsx
  60. 1 2
      public/app/core/components/Picker/UserPicker.jest.tsx
  61. 79 0
      public/app/core/components/Picker/UserPicker.tsx
  62. 1 1
      public/app/core/components/Picker/__snapshots__/PickerOption.jest.tsx.snap
  63. 0 0
      public/app/core/components/Picker/__snapshots__/UserPicker.jest.tsx.snap
  64. 32 0
      public/app/core/components/Picker/withPicker.tsx
  65. 1 1
      public/app/core/components/Tooltip/Popover.jest.tsx
  66. 1 1
      public/app/core/components/Tooltip/Tooltip.jest.tsx
  67. 1 1
      public/app/core/components/Tooltip/__snapshots__/Popover.jest.tsx.snap
  68. 1 1
      public/app/core/components/Tooltip/__snapshots__/Tooltip.jest.tsx.snap
  69. 3 2
      public/app/core/components/Tooltip/withTooltip.tsx
  70. 0 108
      public/app/core/components/UserPicker/UserPicker.tsx
  71. 7 6
      public/app/core/components/manage_dashboards/manage_dashboards.html
  72. 60 20
      public/app/core/components/manage_dashboards/manage_dashboards.ts
  73. 2 1
      public/app/core/controllers/invited_ctrl.ts
  74. 2 1
      public/app/core/controllers/reset_password_ctrl.ts
  75. 0 1
      public/app/core/core.ts
  76. 0 150
      public/app/core/directives/dash_edit_link.js
  77. 3 3
      public/app/core/services/bridge_srv.ts
  78. 1 1
      public/app/core/specs/bridge_srv.jest.ts
  79. 4 4
      public/app/core/specs/manage_dashboards.jest.ts
  80. 5 1
      public/app/core/utils/url.ts
  81. 1 1
      public/app/features/annotations/annotation_tooltip.ts
  82. 0 126
      public/app/features/dashboard/acl/acl.html
  83. 0 201
      public/app/features/dashboard/acl/acl.ts
  84. 0 188
      public/app/features/dashboard/acl/specs/acl_specs.ts
  85. 0 3
      public/app/features/dashboard/all.ts
  86. 9 0
      public/app/features/dashboard/dashboard_model.ts
  87. 1 1
      public/app/features/dashboard/dashboard_srv.ts
  88. 11 17
      public/app/features/dashboard/folder_page_loader.ts
  89. 5 0
      public/app/features/dashboard/folder_permissions_ctrl.ts
  90. 4 6
      public/app/features/dashboard/folder_picker/folder_picker.ts
  91. 2 2
      public/app/features/dashboard/partials/folder_dashboards.html
  92. 3 3
      public/app/features/dashboard/partials/folder_permissions.html
  93. 10 0
      public/app/features/dashboard/settings/settings.html
  94. 9 0
      public/app/features/dashboard/settings/settings.ts
  95. 51 0
      public/app/features/dashboard/specs/repeat.jest.ts
  96. 1 1
      public/app/features/dashlinks/editor.html
  97. 6 1
      public/app/features/org/partials/team_details.html
  98. 1 0
      public/app/features/org/team_details_ctrl.ts
  99. 1 0
      public/app/features/playlist/playlist_search.ts
  100. 1 0
      public/app/features/plugins/all.ts

+ 1 - 1
docs/sources/administration/provisioning.md

@@ -3,6 +3,7 @@ title = "Provisioning"
 description = ""
 keywords = ["grafana", "provisioning"]
 type = "docs"
+aliases = ["/installation/provisioning"]
 [menu.docs]
 parent = "admin"
 weight = 8
@@ -66,7 +67,6 @@ Tool | Project
 -----|------------
 Puppet | [https://forge.puppet.com/puppet/grafana](https://forge.puppet.com/puppet/grafana)
 Ansible | [https://github.com/cloudalchemy/ansible-grafana](https://github.com/cloudalchemy/ansible-grafana)
-Ansible | [https://github.com/picotrading/ansible-grafana](https://github.com/picotrading/ansible-grafana)
 Chef | [https://github.com/JonathanTron/chef-grafana](https://github.com/JonathanTron/chef-grafana)
 Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://github.com/salt-formulas/salt-formula-grafana)
 

+ 1 - 1
docs/sources/index.md

@@ -18,7 +18,7 @@ other domains including industrial sensors, home automation, weather, and proces
 - [Installing on Mac OS X](installation/mac)
 - [Installing on Windows](installation/windows)
 - [Installing on Docker](installation/docker)
-- [Installing using Provisioning (Chef, Puppet, Salt, Ansible, etc)](installation/provisioning)
+- [Installing using Provisioning (Chef, Puppet, Salt, Ansible, etc)](administration/provisioning#configuration-management-tools)
 - [Nightly Builds](https://grafana.com/grafana/download)
 
 For other platforms Read the [build from source]({{< relref "project/building_from_source.md" >}})

+ 3 - 2
package.json

@@ -68,6 +68,7 @@
     "karma-webpack": "^2.0.4",
     "lint-staged": "^6.0.0",
     "load-grunt-tasks": "3.5.2",
+    "mobx-react-devtools": "^4.2.15",
     "mocha": "^4.0.1",
     "ng-annotate-loader": "^0.6.1",
     "ng-annotate-webpack-plugin": "^0.2.1-pre",
@@ -91,7 +92,7 @@
     "typescript": "^2.6.2",
     "webpack": "^3.10.0",
     "webpack-bundle-analyzer": "^2.9.0",
-    "webpack-cleanup-plugin": "^0.5.1",    
+    "webpack-cleanup-plugin": "^0.5.1",
     "webpack-merge": "^4.1.0",
     "zone.js": "^0.7.2"
   },
@@ -148,8 +149,8 @@
     "react": "^16.2.0",
     "react-dom": "^16.2.0",
     "react-grid-layout": "^0.16.2",
-    "react-popper": "^0.7.5",
     "react-highlight-words": "^0.10.0",
+    "react-popper": "^0.7.5",
     "react-select": "^1.1.0",
     "react-sizeme": "^2.3.6",
     "remarkable": "^1.7.1",

+ 53 - 24
pkg/api/alerting.go

@@ -8,6 +8,7 @@ import (
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/alerting"
+	"github.com/grafana/grafana/pkg/services/guardian"
 )
 
 func ValidateOrgAlert(c *middleware.Context) {
@@ -62,9 +63,22 @@ func GetAlerts(c *middleware.Context) Response {
 		return ApiError(500, "List alerts failed", err)
 	}
 
+	alertDTOs, resp := transformToDTOs(query.Result, c)
+	if resp != nil {
+		return resp
+	}
+
+	return Json(200, alertDTOs)
+}
+
+func transformToDTOs(alerts []*models.Alert, c *middleware.Context) ([]*dtos.AlertRule, Response) {
+	if len(alerts) == 0 {
+		return []*dtos.AlertRule{}, nil
+	}
+
 	dashboardIds := make([]int64, 0)
 	alertDTOs := make([]*dtos.AlertRule, 0)
-	for _, alert := range query.Result {
+	for _, alert := range alerts {
 		dashboardIds = append(dashboardIds, alert.DashboardId)
 		alertDTOs = append(alertDTOs, &dtos.AlertRule{
 			Id:             alert.Id,
@@ -83,10 +97,8 @@ func GetAlerts(c *middleware.Context) Response {
 		DashboardIds: dashboardIds,
 	}
 
-	if len(alertDTOs) > 0 {
-		if err := bus.Dispatch(&dashboardsQuery); err != nil {
-			return ApiError(500, "List alerts failed", err)
-		}
+	if err := bus.Dispatch(&dashboardsQuery); err != nil {
+		return nil, ApiError(500, "List alerts failed", err)
 	}
 
 	//TODO: should be possible to speed this up with lookup table
@@ -98,7 +110,26 @@ func GetAlerts(c *middleware.Context) Response {
 		}
 	}
 
-	return Json(200, alertDTOs)
+	permissionsQuery := models.GetDashboardPermissionsForUserQuery{
+		DashboardIds: dashboardIds,
+		OrgId:        c.OrgId,
+		UserId:       c.SignedInUser.UserId,
+		OrgRole:      c.SignedInUser.OrgRole,
+	}
+
+	if err := bus.Dispatch(&permissionsQuery); err != nil {
+		return nil, ApiError(500, "List alerts failed", err)
+	}
+
+	for _, alert := range alertDTOs {
+		for _, perm := range permissionsQuery.Result {
+			if alert.DashboardId == perm.DashboardId {
+				alert.CanEdit = perm.Permission > 1
+			}
+		}
+	}
+
+	return alertDTOs, nil
 }
 
 // POST /api/alerts/test
@@ -155,24 +186,6 @@ func GetAlert(c *middleware.Context) Response {
 	return Json(200, &query.Result)
 }
 
-// DEL /api/alerts/:id
-func DelAlert(c *middleware.Context) Response {
-	alertId := c.ParamsInt64(":alertId")
-
-	if alertId == 0 {
-		return ApiError(401, "Failed to parse alertid", nil)
-	}
-
-	cmd := models.DeleteAlertCommand{AlertId: alertId}
-
-	if err := bus.Dispatch(&cmd); err != nil {
-		return ApiError(500, "Failed to delete alert", err)
-	}
-
-	var resp = map[string]interface{}{"alertId": alertId}
-	return Json(200, resp)
-}
-
 func GetAlertNotifiers(c *middleware.Context) Response {
 	return Json(200, alerting.GetNotifiers())
 }
@@ -267,6 +280,22 @@ func NotificationTest(c *middleware.Context, dto dtos.NotificationTestCommand) R
 //POST /api/alerts/:alertId/pause
 func PauseAlert(c *middleware.Context, dto dtos.PauseAlertCommand) Response {
 	alertId := c.ParamsInt64("alertId")
+
+	query := models.GetAlertByIdQuery{Id: alertId}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return ApiError(500, "Get Alert failed", err)
+	}
+
+	guardian := guardian.NewDashboardGuardian(query.Result.DashboardId, c.OrgId, c.SignedInUser)
+	if canEdit, err := guardian.CanEdit(); err != nil || !canEdit {
+		if err != nil {
+			return ApiError(500, "Error while checking permissions for Alert", err)
+		}
+
+		return ApiError(403, "Access denied to this dashboard and alert", nil)
+	}
+
 	cmd := models.PauseAlertCommand{
 		OrgId:    c.OrgId,
 		AlertIds: []int64{alertId},

+ 97 - 0
pkg/api/alerting_test.go

@@ -0,0 +1,97 @@
+package api
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestAlertingApiEndpoint(t *testing.T) {
+	Convey("Given an alert in a dashboard with an acl", t, func() {
+
+		singleAlert := &m.Alert{Id: 1, DashboardId: 1, Name: "singlealert"}
+
+		bus.AddHandler("test", func(query *m.GetAlertByIdQuery) error {
+			query.Result = singleAlert
+			return nil
+		})
+
+		viewerRole := m.ROLE_VIEWER
+		editorRole := m.ROLE_EDITOR
+
+		aclMockResp := []*m.DashboardAclInfoDTO{}
+		bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+			query.Result = aclMockResp
+			return nil
+		})
+
+		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
+			query.Result = []*m.Team{}
+			return nil
+		})
+
+		Convey("When user is editor and not in the ACL", func() {
+			Convey("Should not be able to pause the alert", func() {
+				cmd := dtos.PauseAlertCommand{
+					AlertId: 1,
+					Paused:  true,
+				}
+				postAlertScenario("When calling POST on", "/api/alerts/1/pause", "/api/alerts/:alertId/pause", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
+					CallPauseAlert(sc)
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+		})
+
+		Convey("When user is editor and dashboard has default ACL", func() {
+			aclMockResp = []*m.DashboardAclInfoDTO{
+				{Role: &viewerRole, Permission: m.PERMISSION_VIEW},
+				{Role: &editorRole, Permission: m.PERMISSION_EDIT},
+			}
+
+			Convey("Should be able to pause the alert", func() {
+				cmd := dtos.PauseAlertCommand{
+					AlertId: 1,
+					Paused:  true,
+				}
+				postAlertScenario("When calling POST on", "/api/alerts/1/pause", "/api/alerts/:alertId/pause", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
+					CallPauseAlert(sc)
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+			})
+		})
+	})
+}
+
+func CallPauseAlert(sc *scenarioContext) {
+	bus.AddHandler("test", func(cmd *m.PauseAlertCommand) error {
+		return nil
+	})
+
+	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+}
+
+func postAlertScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PauseAlertCommand, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.UserId = TestUserID
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = role
+
+			return PauseAlert(c, cmd)
+		})
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}

+ 65 - 0
pkg/api/annotations.go

@@ -7,7 +7,9 @@ import (
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/annotations"
+	"github.com/grafana/grafana/pkg/services/guardian"
 	"github.com/grafana/grafana/pkg/util"
 )
 
@@ -51,6 +53,10 @@ func (e *CreateAnnotationError) Error() string {
 }
 
 func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response {
+	if canSave, err := canSaveByDashboardId(c, cmd.DashboardId); err != nil || !canSave {
+		return dashboardGuardianResponse(err)
+	}
+
 	repo := annotations.GetRepository()
 
 	if cmd.Text == "" {
@@ -178,6 +184,10 @@ func UpdateAnnotation(c *middleware.Context, cmd dtos.UpdateAnnotationsCmd) Resp
 
 	repo := annotations.GetRepository()
 
+	if resp := canSave(c, repo, annotationId); resp != nil {
+		return resp
+	}
+
 	item := annotations.Item{
 		OrgId:  c.OrgId,
 		UserId: c.UserId,
@@ -228,6 +238,10 @@ func DeleteAnnotationById(c *middleware.Context) Response {
 	repo := annotations.GetRepository()
 	annotationId := c.ParamsInt64(":annotationId")
 
+	if resp := canSave(c, repo, annotationId); resp != nil {
+		return resp
+	}
+
 	err := repo.Delete(&annotations.DeleteParams{
 		Id: annotationId,
 	})
@@ -243,6 +257,10 @@ func DeleteAnnotationRegion(c *middleware.Context) Response {
 	repo := annotations.GetRepository()
 	regionId := c.ParamsInt64(":regionId")
 
+	if resp := canSave(c, repo, regionId); resp != nil {
+		return resp
+	}
+
 	err := repo.Delete(&annotations.DeleteParams{
 		RegionId: regionId,
 	})
@@ -253,3 +271,50 @@ func DeleteAnnotationRegion(c *middleware.Context) Response {
 
 	return ApiSuccess("Annotation region deleted")
 }
+
+func canSaveByDashboardId(c *middleware.Context, dashboardId int64) (bool, error) {
+	if dashboardId == 0 && !c.SignedInUser.HasRole(m.ROLE_EDITOR) {
+		return false, nil
+	}
+
+	if dashboardId > 0 {
+		guardian := guardian.NewDashboardGuardian(dashboardId, c.OrgId, c.SignedInUser)
+		if canEdit, err := guardian.CanEdit(); err != nil || !canEdit {
+			return false, err
+		}
+	}
+
+	return true, nil
+}
+
+func canSave(c *middleware.Context, repo annotations.Repository, annotationId int64) Response {
+	items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationId, OrgId: c.OrgId})
+
+	if err != nil || len(items) == 0 {
+		return ApiError(500, "Could not find annotation to update", err)
+	}
+
+	dashboardId := items[0].DashboardId
+
+	if canSave, err := canSaveByDashboardId(c, dashboardId); err != nil || !canSave {
+		return dashboardGuardianResponse(err)
+	}
+
+	return nil
+}
+
+func canSaveByRegionId(c *middleware.Context, repo annotations.Repository, regionId int64) Response {
+	items, err := repo.Find(&annotations.ItemQuery{RegionId: regionId, OrgId: c.OrgId})
+
+	if err != nil || len(items) == 0 {
+		return ApiError(500, "Could not find annotation to update", err)
+	}
+
+	dashboardId := items[0].DashboardId
+
+	if canSave, err := canSaveByDashboardId(c, dashboardId); err != nil || !canSave {
+		return dashboardGuardianResponse(err)
+	}
+
+	return nil
+}

+ 242 - 0
pkg/api/annotations_test.go

@@ -0,0 +1,242 @@
+package api
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/annotations"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestAnnotationsApiEndpoint(t *testing.T) {
+	Convey("Given an annotation without a dashboard id", t, func() {
+		cmd := dtos.PostAnnotationsCmd{
+			Time:     1000,
+			Text:     "annotation text",
+			Tags:     []string{"tag1", "tag2"},
+			IsRegion: false,
+		}
+
+		updateCmd := dtos.UpdateAnnotationsCmd{
+			Time:     1000,
+			Text:     "annotation text",
+			Tags:     []string{"tag1", "tag2"},
+			IsRegion: false,
+		}
+
+		Convey("When user is an Org Viewer", func() {
+			role := m.ROLE_VIEWER
+			Convey("Should not be allowed to save an annotation", func() {
+				postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) {
+					sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+
+				putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) {
+					sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+
+				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteAnnotationById
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+
+				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteAnnotationRegion
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+		})
+
+		Convey("When user is an Org Editor", func() {
+			role := m.ROLE_EDITOR
+			Convey("Should be able to save an annotation", func() {
+				postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) {
+					sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+
+				putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) {
+					sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+
+				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteAnnotationById
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+
+				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteAnnotationRegion
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+			})
+		})
+	})
+
+	Convey("Given an annotation with a dashboard id and the dashboard does not have an acl", t, func() {
+		cmd := dtos.PostAnnotationsCmd{
+			Time:        1000,
+			Text:        "annotation text",
+			Tags:        []string{"tag1", "tag2"},
+			IsRegion:    false,
+			DashboardId: 1,
+			PanelId:     1,
+		}
+
+		updateCmd := dtos.UpdateAnnotationsCmd{
+			Time:     1000,
+			Text:     "annotation text",
+			Tags:     []string{"tag1", "tag2"},
+			IsRegion: false,
+			Id:       1,
+		}
+
+		viewerRole := m.ROLE_VIEWER
+		editorRole := m.ROLE_EDITOR
+
+		aclMockResp := []*m.DashboardAclInfoDTO{
+			{Role: &viewerRole, Permission: m.PERMISSION_VIEW},
+			{Role: &editorRole, Permission: m.PERMISSION_EDIT},
+		}
+
+		bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+			query.Result = aclMockResp
+			return nil
+		})
+
+		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
+			query.Result = []*m.Team{}
+			return nil
+		})
+
+		Convey("When user is an Org Viewer", func() {
+			role := m.ROLE_VIEWER
+			Convey("Should not be allowed to save an annotation", func() {
+				postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) {
+					sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+
+				putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) {
+					sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+
+				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteAnnotationById
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+
+				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteAnnotationRegion
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+		})
+
+		Convey("When user is an Org Editor", func() {
+			role := m.ROLE_EDITOR
+			Convey("Should be able to save an annotation", func() {
+				postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) {
+					sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+
+				putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) {
+					sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+
+				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteAnnotationById
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+
+				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteAnnotationRegion
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+			})
+		})
+	})
+}
+
+type fakeAnnotationsRepo struct {
+}
+
+func (repo *fakeAnnotationsRepo) Delete(params *annotations.DeleteParams) error {
+	return nil
+}
+func (repo *fakeAnnotationsRepo) Save(item *annotations.Item) error {
+	item.Id = 1
+	return nil
+}
+func (repo *fakeAnnotationsRepo) Update(item *annotations.Item) error {
+	return nil
+}
+func (repo *fakeAnnotationsRepo) Find(query *annotations.ItemQuery) ([]*annotations.ItemDTO, error) {
+	annotations := []*annotations.ItemDTO{{Id: 1}}
+	return annotations, nil
+}
+
+var fakeAnnoRepo *fakeAnnotationsRepo
+
+func postAnnotationScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PostAnnotationsCmd, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.UserId = TestUserID
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = role
+
+			return PostAnnotation(c, cmd)
+		})
+
+		fakeAnnoRepo = &fakeAnnotationsRepo{}
+		annotations.SetRepository(fakeAnnoRepo)
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}
+
+func putAnnotationScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.UpdateAnnotationsCmd, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.UserId = TestUserID
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = role
+
+			return UpdateAnnotation(c, cmd)
+		})
+
+		fakeAnnoRepo = &fakeAnnotationsRepo{}
+		annotations.SetRepository(fakeAnnoRepo)
+
+		sc.m.Put(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}

+ 14 - 12
pkg/api/api.go

@@ -150,13 +150,13 @@ func (hs *HttpServer) registerRoutes() {
 		apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
 			teamsRoute.Get("/:teamId", wrap(GetTeamById))
 			teamsRoute.Get("/search", wrap(SearchTeams))
-			teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam))
-			teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
-			teamsRoute.Delete("/:teamId", wrap(DeleteTeamById))
-			teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers))
-			teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
-			teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember))
-		}, reqOrgAdmin)
+			teamsRoute.Post("/", quota("teams"), reqOrgAdmin, bind(m.CreateTeamCommand{}), wrap(CreateTeam))
+			teamsRoute.Put("/:teamId", reqOrgAdmin, bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
+			teamsRoute.Delete("/:teamId", reqOrgAdmin, wrap(DeleteTeamById))
+			teamsRoute.Get("/:teamId/members", reqOrgAdmin, wrap(GetTeamMembers))
+			teamsRoute.Post("/:teamId/members", reqOrgAdmin, quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
+			teamsRoute.Delete("/:teamId/members/:userId", reqOrgAdmin, wrap(RemoveTeamMember))
+		})
 
 		// org information available to all users.
 		apiRoute.Group("/org", func(orgRoute RouteRegister) {
@@ -252,19 +252,21 @@ func (hs *HttpServer) registerRoutes() {
 			dashboardRoute.Delete("/uid/:uid", wrap(DeleteDashboardByUid))
 
 			dashboardRoute.Get("/db/:slug", wrap(GetDashboard))
-			dashboardRoute.Delete("/db/:slug", reqEditorRole, wrap(DeleteDashboard))
+			dashboardRoute.Delete("/db/:slug", wrap(DeleteDashboard))
 
 			dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), wrap(CalculateDashboardDiff))
 
-			dashboardRoute.Post("/db", reqEditorRole, bind(m.SaveDashboardCommand{}), wrap(PostDashboard))
+			dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), wrap(PostDashboard))
 			dashboardRoute.Get("/home", wrap(GetHomeDashboard))
 			dashboardRoute.Get("/tags", GetDashboardTags)
 			dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard))
 
+			dashboardRoute.Get("/folders", wrap(GetFoldersForSignedInUser))
+
 			dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) {
 				dashIdRoute.Get("/versions", wrap(GetDashboardVersions))
 				dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))
-				dashIdRoute.Post("/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
+				dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
 
 				dashIdRoute.Group("/acl", func(aclRoute RouteRegister) {
 					aclRoute.Get("/", wrap(GetDashboardAclList))
@@ -326,8 +328,8 @@ func (hs *HttpServer) registerRoutes() {
 			annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationById))
 			annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation))
 			annotationsRoute.Delete("/region/:regionId", wrap(DeleteAnnotationRegion))
-			annotationsRoute.Post("/graphite", bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation))
-		}, reqEditorRole)
+			annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation))
+		})
 
 		// error test
 		r.Get("/metrics/error", wrap(GenerateError))

+ 105 - 0
pkg/api/common_test.go

@@ -0,0 +1,105 @@
+package api
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+
+	"github.com/go-macaron/session"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	"github.com/grafana/grafana/pkg/models"
+	macaron "gopkg.in/macaron.v1"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func loggedInUserScenario(desc string, url string, fn scenarioFunc) {
+	loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn)
+}
+
+func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.UserId = TestUserID
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = role
+			if sc.handlerFunc != nil {
+				return sc.handlerFunc(sc.context)
+			}
+
+			return nil
+		})
+
+		switch method {
+		case "GET":
+			sc.m.Get(routePattern, sc.defaultHandler)
+		case "DELETE":
+			sc.m.Delete(routePattern, sc.defaultHandler)
+		}
+
+		fn(sc)
+	})
+}
+
+func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
+	sc.resp = httptest.NewRecorder()
+	req, err := http.NewRequest(method, url, nil)
+	So(err, ShouldBeNil)
+	sc.req = req
+
+	return sc
+}
+
+func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext {
+	sc.resp = httptest.NewRecorder()
+	req, err := http.NewRequest(method, url, nil)
+	q := req.URL.Query()
+	for k, v := range queryParams {
+		q.Add(k, v)
+	}
+	req.URL.RawQuery = q.Encode()
+	So(err, ShouldBeNil)
+	sc.req = req
+
+	return sc
+}
+
+type scenarioContext struct {
+	m              *macaron.Macaron
+	context        *middleware.Context
+	resp           *httptest.ResponseRecorder
+	handlerFunc    handlerFunc
+	defaultHandler macaron.Handler
+	req            *http.Request
+	url            string
+}
+
+func (sc *scenarioContext) exec() {
+	sc.m.ServeHTTP(sc.resp, sc.req)
+}
+
+type scenarioFunc func(c *scenarioContext)
+type handlerFunc func(c *middleware.Context) Response
+
+func setupScenarioContext(url string) *scenarioContext {
+	sc := &scenarioContext{
+		url: url,
+	}
+	viewsPath, _ := filepath.Abs("../../public/views")
+
+	sc.m = macaron.New()
+	sc.m.Use(macaron.Renderer(macaron.RenderOptions{
+		Directory: viewsPath,
+		Delims:    macaron.Delims{Left: "[[", Right: "]]"},
+	}))
+
+	sc.m.Use(middleware.GetContextHandler())
+	sc.m.Use(middleware.Sessioner(&session.Options{}))
+
+	return sc
+}

+ 31 - 18
pkg/api/dashboard.go

@@ -38,9 +38,9 @@ func isDashboardStarredByUser(c *middleware.Context, dashId int64) (bool, error)
 func dashboardGuardianResponse(err error) Response {
 	if err != nil {
 		return ApiError(500, "Error while checking dashboard permissions", err)
-	} else {
-		return ApiError(403, "Access denied to this dashboard", nil)
 	}
+
+	return ApiError(403, "Access denied to this dashboard", nil)
 }
 
 func GetDashboard(c *middleware.Context) Response {
@@ -51,7 +51,6 @@ func GetDashboard(c *middleware.Context) Response {
 
 	guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
 	if canView, err := guardian.CanView(); err != nil || !canView {
-		fmt.Printf("%v", err)
 		return dashboardGuardianResponse(err)
 	}
 
@@ -90,6 +89,7 @@ func GetDashboard(c *middleware.Context) Response {
 		IsFolder:    dash.IsFolder,
 		FolderId:    dash.FolderId,
 		FolderTitle: "Root",
+		Url:         dash.GetUrl(),
 	}
 
 	// lookup folder title
@@ -99,12 +99,7 @@ func GetDashboard(c *middleware.Context) Response {
 			return ApiError(500, "Dashboard folder could not be read", err)
 		}
 		meta.FolderTitle = query.Result.Title
-	}
-
-	if dash.IsFolder {
-		meta.Url = m.GetFolderUrl(dash.Uid, dash.Slug)
-	} else {
-		meta.Url = m.GetDashboardUrl(dash.Uid, dash.Slug)
+		meta.FolderSlug = query.Result.Slug
 	}
 
 	// make sure db version is in sync with json model version
@@ -201,7 +196,14 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 
 	dash := cmd.GetDashboardModel()
 
-	guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
+	dashId := dash.Id
+
+	// if new dashboard, use parent folder permissions instead
+	if dashId == 0 {
+		dashId = cmd.FolderId
+	}
+
+	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
 	if canSave, err := guardian.CanSave(); err != nil || !canSave {
 		return dashboardGuardianResponse(err)
 	}
@@ -244,7 +246,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 	}
 
 	if err != nil {
-		if err == m.ErrDashboardWithSameNameExists {
+		if err == m.ErrDashboardWithSameUIDExists {
 			return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()})
 		}
 		if err == m.ErrDashboardVersionMismatch {
@@ -268,12 +270,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 		return ApiError(500, "Invalid alert data. Cannot save dashboard", err)
 	}
 
-	var url string
-	if dash.IsFolder {
-		url = m.GetFolderUrl(dashboard.Uid, dashboard.Slug)
-	} else {
-		url = m.GetDashboardUrl(dashboard.Uid, dashboard.Slug)
-	}
+	dashboard.IsFolder = dash.IsFolder
 
 	c.TimeRequest(metrics.M_Api_Dashboard_Save)
 	return Json(200, util.DynMap{
@@ -282,7 +279,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 		"version": dashboard.Version,
 		"id":      dashboard.Id,
 		"uid":     dashboard.Uid,
-		"url":     url,
+		"url":     dashboard.GetUrl(),
 	})
 }
 
@@ -489,3 +486,19 @@ func GetDashboardTags(c *middleware.Context) {
 
 	c.JSON(200, query.Result)
 }
+
+func GetFoldersForSignedInUser(c *middleware.Context) Response {
+	title := c.Query("query")
+	query := m.GetFoldersForSignedInUserQuery{
+		OrgId:        c.OrgId,
+		SignedInUser: c.SignedInUser,
+		Title:        title,
+	}
+
+	err := bus.Dispatch(&query)
+	if err != nil {
+		return ApiError(500, "Failed to get folders from database", err)
+	}
+
+	return Json(200, query.Result)
+}

+ 16 - 0
pkg/api/dashboard_acl.go

@@ -51,6 +51,14 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom
 		})
 	}
 
+	if okToUpdate, err := guardian.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate {
+		if err != nil {
+			return ApiError(500, "Error while checking dashboard permissions", err)
+		}
+
+		return ApiError(403, "Cannot remove own admin permission for a folder", nil)
+	}
+
 	if err := bus.Dispatch(&cmd); err != nil {
 		if err == m.ErrDashboardAclInfoMissing || err == m.ErrDashboardPermissionDashboardEmpty {
 			return ApiError(409, err.Error(), err)
@@ -70,6 +78,14 @@ func DeleteDashboardAcl(c *middleware.Context) Response {
 		return dashboardGuardianResponse(err)
 	}
 
+	if okToDelete, err := guardian.CheckPermissionBeforeRemove(m.PERMISSION_ADMIN, aclId); err != nil || !okToDelete {
+		if err != nil {
+			return ApiError(500, "Error while checking dashboard permissions", err)
+		}
+
+		return ApiError(403, "Cannot remove own admin permission for a folder", nil)
+	}
+
 	cmd := m.RemoveDashboardAclCommand{OrgId: c.OrgId, AclId: aclId}
 	if err := bus.Dispatch(&cmd); err != nil {
 		return ApiError(500, "Failed to delete permission for user", err)

+ 92 - 8
pkg/api/dashboard_acl_test.go

@@ -3,8 +3,10 @@ package api
 import (
 	"testing"
 
+	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 
 	. "github.com/smartystreets/goconvey/convey"
@@ -37,6 +39,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 			return nil
 		})
 
+		// This tests four scenarios:
+		// 1. user is an org admin
+		// 2. user is an org editor AND has been granted admin permission for the dashboard
+		// 3. user is an org viewer AND has been granted edit permission for the dashboard
+		// 4. user is an org editor AND has no permissions for the dashboard
+
 		Convey("When user is org admin", func() {
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) {
 				Convey("Should be able to access ACL", func() {
@@ -54,9 +62,9 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 			})
 		})
 
-		Convey("When user is editor and has admin permission in the ACL", func() {
+		Convey("When user is org editor and has admin permission in the ACL", func() {
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) {
-				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
+				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
 
 				Convey("Should be able to access ACL", func() {
 					sc.handlerFunc = GetDashboardAclList
@@ -67,7 +75,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 			})
 
 			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
-				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
+				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
 
 				bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error {
 					return nil
@@ -81,6 +89,52 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 				})
 			})
 
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/6", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
+				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
+
+				bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error {
+					return nil
+				})
+
+				Convey("Should not be able to delete their own Admin permission", func() {
+					sc.handlerFunc = DeleteDashboardAcl
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+
+			Convey("Should not be able to downgrade their own Admin permission", func() {
+				cmd := dtos.UpdateDashboardAclCommand{
+					Items: []dtos.DashboardAclUpdateItem{
+						{UserId: TestUserID, Permission: m.PERMISSION_EDIT},
+					},
+				}
+
+				postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
+					mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
+
+					CallPostAcl(sc)
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+
+			Convey("Should be able to update permissions", func() {
+				cmd := dtos.UpdateDashboardAclCommand{
+					Items: []dtos.DashboardAclUpdateItem{
+						{UserId: TestUserID, Permission: m.PERMISSION_ADMIN},
+						{UserId: 2, Permission: m.PERMISSION_EDIT},
+					},
+				}
+
+				postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
+					mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
+
+					CallPostAcl(sc)
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+			})
+
 			Convey("When user is a member of a team in the ACL with admin permission", func() {
 				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardsId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
 					teamResp = append(teamResp, &m.Team{Id: 2, OrgId: 1, Name: "UG2"})
@@ -99,11 +153,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 			})
 		})
 
-		Convey("When user is editor and has edit permission in the ACL", func() {
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) {
+		Convey("When user is org viewer and has edit permission in the ACL", func() {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_VIEWER, func(sc *scenarioContext) {
 				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT})
 
-				Convey("Should not be able to access ACL", func() {
+				// Getting the permissions is an Admin permission
+				Convey("Should not be able to get list of permissions from ACL", func() {
 					sc.handlerFunc = GetDashboardAclList
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
@@ -111,7 +166,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 				})
 			})
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_VIEWER, func(sc *scenarioContext) {
 				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT})
 
 				bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error {
@@ -127,7 +182,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 			})
 		})
 
-		Convey("When user is editor and not in the ACL", func() {
+		Convey("When user is org editor and not in the ACL", func() {
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) {
 
 				Convey("Should not be able to access ACL", func() {
@@ -172,3 +227,32 @@ func transformDashboardAclsToDTOs(acls []*m.DashboardAclInfoDTO) []*m.DashboardA
 
 	return dtos
 }
+
+func CallPostAcl(sc *scenarioContext) {
+	bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error {
+		return nil
+	})
+
+	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+}
+
+func postAclScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.UserId = TestUserID
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = role
+
+			return UpdateDashboardAcl(c, cmd)
+		})
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}

+ 17 - 18
pkg/api/dashboard_test.go

@@ -2,12 +2,8 @@ package api
 
 import (
 	"encoding/json"
-	"path/filepath"
 	"testing"
 
-	macaron "gopkg.in/macaron.v1"
-
-	"github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -32,6 +28,10 @@ func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem)
 
 var fakeRepo *fakeDashboardRepo
 
+// This tests two main scenarios. If a user has access to execute an action on a dashboard:
+// 1. and the dashboard is in a folder which does not have an acl
+// 2. and the dashboard is in a folder which does have an acl
+
 func TestDashboardApiEndpoint(t *testing.T) {
 	Convey("Given a dashboard with a parent folder which does not have an acl", t, func() {
 		fakeDash := m.NewDashboard("Child dash")
@@ -79,6 +79,10 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			}),
 		}
 
+		// This tests two scenarios:
+		// 1. user is an org viewer
+		// 2. user is an org editor
+
 		Convey("When user is an Org Viewer", func() {
 			role := m.ROLE_VIEWER
 
@@ -279,6 +283,14 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			}),
 		}
 
+		// This tests six scenarios:
+		// 1. user is an org viewer AND has no permissions for this dashboard
+		// 2. user is an org editor AND has no permissions for this dashboard
+		// 3. user is an org viewer AND has been granted edit permission for the dashboard
+		// 4. user is an org viewer AND all viewers have edit permission for this dashboard
+		// 5. user is an org viewer AND has been granted an admin permission
+		// 6. user is an org editor AND has been granted a view permission
+
 		Convey("When user is an Org Viewer and has no permissions for this dashboard", func() {
 			role := m.ROLE_VIEWER
 
@@ -800,20 +812,7 @@ func postDashboardScenario(desc string, url string, routePattern string, role m.
 	Convey(desc+" "+url, func() {
 		defer bus.ClearBusHandlers()
 
-		sc := &scenarioContext{
-			url: url,
-		}
-		viewsPath, _ := filepath.Abs("../../public/views")
-
-		sc.m = macaron.New()
-		sc.m.Use(macaron.Renderer(macaron.RenderOptions{
-			Directory: viewsPath,
-			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
-		}))
-
-		sc.m.Use(middleware.GetContextHandler())
-		sc.m.Use(middleware.Sessioner(&session.Options{}))
-
+		sc := setupScenarioContext(url)
 		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
 			sc.context = c
 			sc.context.UserId = TestUserID

+ 0 - 91
pkg/api/datasources_test.go

@@ -2,17 +2,11 @@ package api
 
 import (
 	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"path/filepath"
 	"testing"
 
 	"github.com/grafana/grafana/pkg/models"
-	macaron "gopkg.in/macaron.v1"
 
-	"github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/bus"
-	"github.com/grafana/grafana/pkg/middleware"
 	. "github.com/smartystreets/goconvey/convey"
 )
 
@@ -54,88 +48,3 @@ func TestDataSourcesProxy(t *testing.T) {
 		})
 	})
 }
-
-func loggedInUserScenario(desc string, url string, fn scenarioFunc) {
-	loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn)
-}
-
-func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) {
-	Convey(desc+" "+url, func() {
-		defer bus.ClearBusHandlers()
-
-		sc := &scenarioContext{
-			url: url,
-		}
-		viewsPath, _ := filepath.Abs("../../public/views")
-
-		sc.m = macaron.New()
-		sc.m.Use(macaron.Renderer(macaron.RenderOptions{
-			Directory: viewsPath,
-			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
-		}))
-
-		sc.m.Use(middleware.GetContextHandler())
-		sc.m.Use(middleware.Sessioner(&session.Options{}))
-
-		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
-			sc.context = c
-			sc.context.UserId = TestUserID
-			sc.context.OrgId = TestOrgID
-			sc.context.OrgRole = role
-			if sc.handlerFunc != nil {
-				return sc.handlerFunc(sc.context)
-			}
-
-			return nil
-		})
-
-		switch method {
-		case "GET":
-			sc.m.Get(routePattern, sc.defaultHandler)
-		case "DELETE":
-			sc.m.Delete(routePattern, sc.defaultHandler)
-		}
-
-		fn(sc)
-	})
-}
-
-func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
-	sc.resp = httptest.NewRecorder()
-	req, err := http.NewRequest(method, url, nil)
-	So(err, ShouldBeNil)
-	sc.req = req
-
-	return sc
-}
-
-func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext {
-	sc.resp = httptest.NewRecorder()
-	req, err := http.NewRequest(method, url, nil)
-	q := req.URL.Query()
-	for k, v := range queryParams {
-		q.Add(k, v)
-	}
-	req.URL.RawQuery = q.Encode()
-	So(err, ShouldBeNil)
-	sc.req = req
-
-	return sc
-}
-
-type scenarioContext struct {
-	m              *macaron.Macaron
-	context        *middleware.Context
-	resp           *httptest.ResponseRecorder
-	handlerFunc    handlerFunc
-	defaultHandler macaron.Handler
-	req            *http.Request
-	url            string
-}
-
-func (sc *scenarioContext) exec() {
-	sc.m.ServeHTTP(sc.resp, sc.req)
-}
-
-type scenarioFunc func(c *scenarioContext)
-type handlerFunc func(c *middleware.Context) Response

+ 1 - 0
pkg/api/dtos/alerting.go

@@ -20,6 +20,7 @@ type AlertRule struct {
 	EvalData       *simplejson.Json `json:"evalData"`
 	ExecutionError string           `json:"executionError"`
 	DashbboardUri  string           `json:"dashboardUri"`
+	CanEdit        bool             `json:"canEdit"`
 }
 
 type AlertNotification struct {

+ 1 - 0
pkg/api/dtos/dashboard.go

@@ -27,6 +27,7 @@ type DashboardMeta struct {
 	IsFolder    bool      `json:"isFolder"`
 	FolderId    int64     `json:"folderId"`
 	FolderTitle string    `json:"folderTitle"`
+	FolderSlug  string    `json:"folderSlug"`
 }
 
 type DashboardFullWithMeta struct {

+ 3 - 3
pkg/api/index.go

@@ -102,8 +102,8 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 	}
 
 	dashboardChildNavs := []*dtos.NavLink{
-		{Text: "Home", Url: setting.AppSubUrl + "/", Icon: "gicon gicon-home", HideFromTabs: true},
-		{Divider: true, HideFromTabs: true},
+		{Text: "Home", Id: "home", Url: setting.AppSubUrl + "/", Icon: "gicon gicon-home", HideFromTabs: true},
+		{Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true},
 		{Text: "Manage", Id: "manage-dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "gicon gicon-manage"},
 		{Text: "Playlists", Id: "playlists", Url: setting.AppSubUrl + "/playlists", Icon: "gicon gicon-playlists"},
 		{Text: "Snapshots", Id: "snapshots", Url: setting.AppSubUrl + "/dashboard/snapshots", Icon: "gicon gicon-snapshots"},
@@ -261,7 +261,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 
 		if c.IsGrafanaAdmin {
 			cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{
-				Divider: true, HideFromTabs: true,
+				Divider: true, HideFromTabs: true, Id: "admin-divider", Text: "Text",
 			})
 			cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{
 				Text:         "Server Admin",

+ 2 - 0
pkg/middleware/dashboard_redirect_test.go

@@ -6,6 +6,7 @@ import (
 
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/util"
 	. "github.com/smartystreets/goconvey/convey"
 )
 
@@ -19,6 +20,7 @@ func TestMiddlewareDashboardRedirect(t *testing.T) {
 		fakeDash.Id = 1
 		fakeDash.FolderId = 1
 		fakeDash.HasAcl = false
+		fakeDash.Uid = util.GenerateShortUid()
 
 		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
 			query.Result = fakeDash

+ 0 - 4
pkg/models/alert.go

@@ -159,10 +159,6 @@ type SetAlertStateCommand struct {
 	Timestamp time.Time
 }
 
-type DeleteAlertCommand struct {
-	AlertId int64
-}
-
 //Queries
 type GetAlertsQuery struct {
 	OrgId       int64

+ 51 - 13
pkg/models/dashboards.go

@@ -9,20 +9,21 @@ import (
 	"github.com/gosimple/slug"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/setting"
-	"github.com/grafana/grafana/pkg/util"
 )
 
 // Typed errors
 var (
-	ErrDashboardNotFound                 = errors.New("Dashboard not found")
-	ErrDashboardSnapshotNotFound         = errors.New("Dashboard snapshot not found")
-	ErrDashboardWithSameNameExists       = errors.New("A dashboard with the same name already exists")
-	ErrDashboardVersionMismatch          = errors.New("The dashboard has been changed by someone else")
-	ErrDashboardTitleEmpty               = errors.New("Dashboard title cannot be empty")
-	ErrDashboardFolderCannotHaveParent   = errors.New("A Dashboard Folder cannot be added to another folder")
-	ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard")
-	ErrDashboardFailedToUpdateAlertData  = errors.New("Failed to save alert data")
-	ErrDashboardsWithSameSlugExists      = errors.New("Multiple dashboards with the same slug exists")
+	ErrDashboardNotFound                   = errors.New("Dashboard not found")
+	ErrDashboardSnapshotNotFound           = errors.New("Dashboard snapshot not found")
+	ErrDashboardWithSameUIDExists          = errors.New("A dashboard with the same uid already exists")
+	ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists")
+	ErrDashboardVersionMismatch            = errors.New("The dashboard has been changed by someone else")
+	ErrDashboardTitleEmpty                 = errors.New("Dashboard title cannot be empty")
+	ErrDashboardFolderCannotHaveParent     = errors.New("A Dashboard Folder cannot be added to another folder")
+	ErrDashboardContainsInvalidAlertData   = errors.New("Invalid alert data. Cannot save dashboard")
+	ErrDashboardFailedToUpdateAlertData    = errors.New("Failed to save alert data")
+	ErrDashboardsWithSameSlugExists        = errors.New("Multiple dashboards with the same slug exists")
+	ErrDashboardFailedGenerateUniqueUid    = errors.New("Failed to generate unique dashboard id")
 )
 
 type UpdatePluginDashboardError struct {
@@ -66,7 +67,6 @@ type Dashboard struct {
 // NewDashboard creates a new dashboard
 func NewDashboard(title string) *Dashboard {
 	dash := &Dashboard{}
-	dash.Uid = util.GenerateShortUid()
 	dash.Data = simplejson.New()
 	dash.Data.Set("title", title)
 	dash.Title = title
@@ -115,8 +115,6 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard {
 
 	if uid, err := dash.Data.Get("uid").String(); err == nil {
 		dash.Uid = uid
-	} else {
-		dash.Uid = util.GenerateShortUid()
 	}
 
 	return dash
@@ -159,6 +157,20 @@ func SlugifyTitle(title string) string {
 	return slug.Make(strings.ToLower(title))
 }
 
+// GetUrl return the html url for a folder if it's folder, otherwise for a dashboard
+func (dash *Dashboard) GetUrl() string {
+	return GetDashboardFolderUrl(dash.IsFolder, dash.Uid, dash.Slug)
+}
+
+// GetDashboardFolderUrl return the html url for a folder if it's folder, otherwise for a dashboard
+func GetDashboardFolderUrl(isFolder bool, uid string, slug string) string {
+	if isFolder {
+		return GetFolderUrl(uid, slug)
+	}
+
+	return GetDashboardUrl(uid, slug)
+}
+
 // GetDashboardUrl return the html url for a dashboard
 func GetDashboardUrl(uid string, slug string) string {
 	return fmt.Sprintf("%s/d/%s/%s", setting.AppSubUrl, uid, slug)
@@ -222,6 +234,14 @@ type GetDashboardsQuery struct {
 	Result       []*Dashboard
 }
 
+type GetDashboardPermissionsForUserQuery struct {
+	DashboardIds []int64
+	OrgId        int64
+	UserId       int64
+	OrgRole      RoleType
+	Result       []*DashboardPermissionForUser
+}
+
 type GetDashboardsByPluginIdQuery struct {
 	OrgId    int64
 	PluginId string
@@ -239,3 +259,21 @@ type GetDashboardsBySlugQuery struct {
 
 	Result []*Dashboard
 }
+
+type GetFoldersForSignedInUserQuery struct {
+	OrgId        int64
+	SignedInUser *SignedInUser
+	Title        string
+	Result       []*DashboardFolder
+}
+
+type DashboardFolder struct {
+	Id    int64  `json:"id"`
+	Title string `json:"title"`
+}
+
+type DashboardPermissionForUser struct {
+	DashboardId    int64          `json:"dashboardId"`
+	Permission     PermissionType `json:"permission"`
+	PermissionName string         `json:"permissionName"`
+}

+ 2 - 0
pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go

@@ -71,6 +71,7 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou
 		qr := &tsdb.QueryResult{
 			RefId:  r.RefId,
 			Series: []*tsdb.TimeSeries{},
+			Tables: []*tsdb.Table{},
 		}
 
 		if r.Error != "" {
@@ -124,6 +125,7 @@ func (tw *DatasourcePluginWrapper) mapTable(t *datasource.Table) (*tsdb.Table, e
 		})
 	}
 
+	table.Rows = make([]tsdb.RowValues, 0)
 	for _, r := range t.GetRows() {
 		row := tsdb.RowValues{}
 		for _, rv := range r.Values {

+ 119 - 119
pkg/services/alerting/ticker_test.go

@@ -1,121 +1,121 @@
 package alerting
 
-import (
-	"testing"
-	"time"
-
-	"github.com/benbjohnson/clock"
-)
-
-func inspectTick(tick time.Time, last time.Time, offset time.Duration, t *testing.T) {
-	if !tick.Equal(last.Add(time.Duration(1) * time.Second)) {
-		t.Fatalf("expected a tick 1 second more than prev, %s. got: %s", last, tick)
-	}
-}
-
-// returns the new last tick seen
-func assertAdvanceUntil(ticker *Ticker, last, desiredLast time.Time, offset, wait time.Duration, t *testing.T) time.Time {
-	for {
-		select {
-		case tick := <-ticker.C:
-			inspectTick(tick, last, offset, t)
-			last = tick
-		case <-time.NewTimer(wait).C:
-			if last.Before(desiredLast) {
-				t.Fatalf("waited %s for ticker to advance to %s, but only went up to %s", wait, desiredLast, last)
-			}
-			if last.After(desiredLast) {
-				t.Fatalf("timer advanced too far. should only have gone up to %s, but it went up to %s", desiredLast, last)
-			}
-			return last
-		}
-	}
-}
-
-func assertNoAdvance(ticker *Ticker, desiredLast time.Time, wait time.Duration, t *testing.T) {
-	for {
-		select {
-		case tick := <-ticker.C:
-			t.Fatalf("timer should have stayed at %s, instead it advanced to %s", desiredLast, tick)
-		case <-time.NewTimer(wait).C:
-			return
-		}
-	}
-}
-
-func TestTickerRetro1Hour(t *testing.T) {
-	offset := time.Duration(10) * time.Second
-	last := time.Unix(0, 0)
-	mock := clock.NewMock()
-	mock.Add(time.Duration(1) * time.Hour)
-	desiredLast := mock.Now().Add(-offset)
-	ticker := NewTicker(last, offset, mock)
-
-	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(10)*time.Millisecond, t)
-	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
-
-}
-
-func TestAdvanceWithUpdateOffset(t *testing.T) {
-	offset := time.Duration(10) * time.Second
-	last := time.Unix(0, 0)
-	mock := clock.NewMock()
-	mock.Add(time.Duration(1) * time.Hour)
-	desiredLast := mock.Now().Add(-offset)
-	ticker := NewTicker(last, offset, mock)
-
-	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(10)*time.Millisecond, t)
-	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
-
-	// lowering offset should see a few more ticks
-	offset = time.Duration(5) * time.Second
-	ticker.updateOffset(offset)
-	desiredLast = mock.Now().Add(-offset)
-	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(9)*time.Millisecond, t)
-	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
-
-	// advancing clock should see even more ticks
-	mock.Add(time.Duration(1) * time.Hour)
-	desiredLast = mock.Now().Add(-offset)
-	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(8)*time.Millisecond, t)
-	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
-
-}
-
-func getCase(lastSeconds, offsetSeconds int) (time.Time, time.Duration) {
-	last := time.Unix(int64(lastSeconds), 0)
-	offset := time.Duration(offsetSeconds) * time.Second
-	return last, offset
-}
-
-func TestTickerNoAdvance(t *testing.T) {
-
-	// it's 00:01:00 now. what are some cases where we don't want the ticker to advance?
-	mock := clock.NewMock()
-	mock.Add(time.Duration(60) * time.Second)
-
-	type Case struct {
-		last   int
-		offset int
-	}
-
-	// note that some cases add up to now, others go into the future
-	cases := []Case{
-		{50, 10},
-		{50, 30},
-		{59, 1},
-		{59, 10},
-		{59, 30},
-		{60, 1},
-		{60, 10},
-		{60, 30},
-		{90, 1},
-		{90, 10},
-		{90, 30},
-	}
-	for _, c := range cases {
-		last, offset := getCase(c.last, c.offset)
-		ticker := NewTicker(last, offset, mock)
-		assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
-	}
-}
+//import (
+//	"testing"
+//	"time"
+//
+//	"github.com/benbjohnson/clock"
+//)
+//
+//func inspectTick(tick time.Time, last time.Time, offset time.Duration, t *testing.T) {
+//	if !tick.Equal(last.Add(time.Duration(1) * time.Second)) {
+//		t.Fatalf("expected a tick 1 second more than prev, %s. got: %s", last, tick)
+//	}
+//}
+//
+//// returns the new last tick seen
+//func assertAdvanceUntil(ticker *Ticker, last, desiredLast time.Time, offset, wait time.Duration, t *testing.T) time.Time {
+//	for {
+//		select {
+//		case tick := <-ticker.C:
+//			inspectTick(tick, last, offset, t)
+//			last = tick
+//		case <-time.NewTimer(wait).C:
+//			if last.Before(desiredLast) {
+//				t.Fatalf("waited %s for ticker to advance to %s, but only went up to %s", wait, desiredLast, last)
+//			}
+//			if last.After(desiredLast) {
+//				t.Fatalf("timer advanced too far. should only have gone up to %s, but it went up to %s", desiredLast, last)
+//			}
+//			return last
+//		}
+//	}
+//}
+//
+//func assertNoAdvance(ticker *Ticker, desiredLast time.Time, wait time.Duration, t *testing.T) {
+//	for {
+//		select {
+//		case tick := <-ticker.C:
+//			t.Fatalf("timer should have stayed at %s, instead it advanced to %s", desiredLast, tick)
+//		case <-time.NewTimer(wait).C:
+//			return
+//		}
+//	}
+//}
+//
+//func TestTickerRetro1Hour(t *testing.T) {
+//	offset := time.Duration(10) * time.Second
+//	last := time.Unix(0, 0)
+//	mock := clock.NewMock()
+//	mock.Add(time.Duration(1) * time.Hour)
+//	desiredLast := mock.Now().Add(-offset)
+//	ticker := NewTicker(last, offset, mock)
+//
+//	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(10)*time.Millisecond, t)
+//	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
+//
+//}
+//
+//func TestAdvanceWithUpdateOffset(t *testing.T) {
+//	offset := time.Duration(10) * time.Second
+//	last := time.Unix(0, 0)
+//	mock := clock.NewMock()
+//	mock.Add(time.Duration(1) * time.Hour)
+//	desiredLast := mock.Now().Add(-offset)
+//	ticker := NewTicker(last, offset, mock)
+//
+//	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(10)*time.Millisecond, t)
+//	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
+//
+//	// lowering offset should see a few more ticks
+//	offset = time.Duration(5) * time.Second
+//	ticker.updateOffset(offset)
+//	desiredLast = mock.Now().Add(-offset)
+//	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(9)*time.Millisecond, t)
+//	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
+//
+//	// advancing clock should see even more ticks
+//	mock.Add(time.Duration(1) * time.Hour)
+//	desiredLast = mock.Now().Add(-offset)
+//	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(8)*time.Millisecond, t)
+//	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
+//
+//}
+//
+//func getCase(lastSeconds, offsetSeconds int) (time.Time, time.Duration) {
+//	last := time.Unix(int64(lastSeconds), 0)
+//	offset := time.Duration(offsetSeconds) * time.Second
+//	return last, offset
+//}
+//
+//func TestTickerNoAdvance(t *testing.T) {
+//
+//	// it's 00:01:00 now. what are some cases where we don't want the ticker to advance?
+//	mock := clock.NewMock()
+//	mock.Add(time.Duration(60) * time.Second)
+//
+//	type Case struct {
+//		last   int
+//		offset int
+//	}
+//
+//	// note that some cases add up to now, others go into the future
+//	cases := []Case{
+//		{50, 10},
+//		{50, 30},
+//		{59, 1},
+//		{59, 10},
+//		{59, 30},
+//		{60, 1},
+//		{60, 10},
+//		{60, 30},
+//		{90, 1},
+//		{90, 10},
+//		{90, 30},
+//	}
+//	for _, c := range cases {
+//		last, offset := getCase(c.last, c.offset)
+//		ticker := NewTicker(last, offset, mock)
+//		assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
+//	}
+//}

+ 10 - 8
pkg/services/annotations/annotations.go

@@ -10,14 +10,16 @@ type Repository interface {
 }
 
 type ItemQuery struct {
-	OrgId       int64    `json:"orgId"`
-	From        int64    `json:"from"`
-	To          int64    `json:"to"`
-	AlertId     int64    `json:"alertId"`
-	DashboardId int64    `json:"dashboardId"`
-	PanelId     int64    `json:"panelId"`
-	Tags        []string `json:"tags"`
-	Type        string   `json:"type"`
+	OrgId        int64    `json:"orgId"`
+	From         int64    `json:"from"`
+	To           int64    `json:"to"`
+	AlertId      int64    `json:"alertId"`
+	DashboardId  int64    `json:"dashboardId"`
+	PanelId      int64    `json:"panelId"`
+	AnnotationId int64    `json:"annotationId"`
+	RegionId     int64    `json:"regionId"`
+	Tags         []string `json:"tags"`
+	Type         string   `json:"type"`
 
 	Limit int64 `json:"limit"`
 }

+ 42 - 4
pkg/services/guardian/guardian.go

@@ -55,6 +55,10 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er
 		return false, err
 	}
 
+	return g.checkAcl(permission, acl)
+}
+
+func (g *DashboardGuardian) checkAcl(permission m.PermissionType, acl []*m.DashboardAclInfoDTO) (bool, error) {
 	orgRole := g.user.OrgRole
 	teamAclItems := []*m.DashboardAclInfoDTO{}
 
@@ -79,18 +83,18 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er
 		}
 	}
 
-	// do we have group rules?
+	// do we have team rules?
 	if len(teamAclItems) == 0 {
 		return false, nil
 	}
 
-	// load groups
+	// load teams
 	teams, err := g.getTeams()
 	if err != nil {
 		return false, err
 	}
 
-	// evalute group rules
+	// evalute team rules
 	for _, p := range acl {
 		for _, ug := range teams {
 			if ug.Id == p.TeamId && p.Permission >= permission {
@@ -102,7 +106,41 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er
 	return false, nil
 }
 
-// Returns dashboard acl
+func (g *DashboardGuardian) CheckPermissionBeforeRemove(permission m.PermissionType, aclIdToRemove int64) (bool, error) {
+	if g.user.OrgRole == m.ROLE_ADMIN {
+		return true, nil
+	}
+
+	acl, err := g.GetAcl()
+	if err != nil {
+		return false, err
+	}
+
+	for i, p := range acl {
+		if p.Id == aclIdToRemove {
+			acl = append(acl[:i], acl[i+1:]...)
+			break
+		}
+	}
+
+	return g.checkAcl(permission, acl)
+}
+
+func (g *DashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) {
+	if g.user.OrgRole == m.ROLE_ADMIN {
+		return true, nil
+	}
+
+	acl := []*m.DashboardAclInfoDTO{}
+
+	for _, p := range updatePermissions {
+		acl = append(acl, &m.DashboardAclInfoDTO{UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission})
+	}
+
+	return g.checkAcl(permission, acl)
+}
+
+// GetAcl returns dashboard acl
 func (g *DashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
 	if g.acl != nil {
 		return g.acl, nil

+ 0 - 7
pkg/services/sqlstore/alert.go

@@ -14,7 +14,6 @@ func init() {
 	bus.AddHandler("sql", SaveAlerts)
 	bus.AddHandler("sql", HandleAlertsQuery)
 	bus.AddHandler("sql", GetAlertById)
-	bus.AddHandler("sql", DeleteAlertById)
 	bus.AddHandler("sql", GetAllAlertQueryHandler)
 	bus.AddHandler("sql", SetAlertState)
 	bus.AddHandler("sql", GetAlertStatesForDashboard)
@@ -61,12 +60,6 @@ func deleteAlertByIdInternal(alertId int64, reason string, sess *DBSession) erro
 	return nil
 }
 
-func DeleteAlertById(cmd *m.DeleteAlertCommand) error {
-	return inTransaction(func(sess *DBSession) error {
-		return deleteAlertByIdInternal(cmd.AlertId, "DeleteAlertCommand", sess)
-	})
-}
-
 func HandleAlertsQuery(query *m.GetAlertsQuery) error {
 	var sql bytes.Buffer
 	params := make([]interface{}, 0)

+ 12 - 0
pkg/services/sqlstore/annotation.go

@@ -138,6 +138,17 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
 	sql.WriteString(`WHERE annotation.org_id = ?`)
 	params = append(params, query.OrgId)
 
+	if query.AnnotationId != 0 {
+		fmt.Print("annotation query")
+		sql.WriteString(` AND annotation.id = ?`)
+		params = append(params, query.AnnotationId)
+	}
+
+	if query.RegionId != 0 {
+		sql.WriteString(` AND annotation.region_id = ?`)
+		params = append(params, query.RegionId)
+	}
+
 	if query.AlertId != 0 {
 		sql.WriteString(` AND annotation.alert_id = ?`)
 		params = append(params, query.AlertId)
@@ -197,6 +208,7 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
 	sql.WriteString(fmt.Sprintf(" ORDER BY epoch DESC LIMIT %v", query.Limit))
 
 	items := make([]*annotations.ItemDTO, 0)
+
 	if err := x.Sql(sql.String(), params...).Find(&items); err != nil {
 		return nil, err
 	}

+ 36 - 0
pkg/services/sqlstore/annotation_test.go

@@ -51,6 +51,20 @@ func TestAnnotations(t *testing.T) {
 			So(err, ShouldBeNil)
 			So(annotation.Id, ShouldBeGreaterThan, 0)
 
+			annotation2 := &annotations.Item{
+				OrgId:       1,
+				UserId:      1,
+				DashboardId: 2,
+				Text:        "hello",
+				Type:        "alert",
+				Epoch:       20,
+				Tags:        []string{"outage", "error", "type:outage", "server:server-1"},
+				RegionId:    1,
+			}
+			err = repo.Save(annotation2)
+			So(err, ShouldBeNil)
+			So(annotation2.Id, ShouldBeGreaterThan, 0)
+
 			Convey("Can query for annotation", func() {
 				items, err := repo.Find(&annotations.ItemQuery{
 					OrgId:       1,
@@ -67,6 +81,28 @@ func TestAnnotations(t *testing.T) {
 				})
 			})
 
+			Convey("Can query for annotation by id", func() {
+				items, err := repo.Find(&annotations.ItemQuery{
+					OrgId:        1,
+					AnnotationId: annotation2.Id,
+				})
+
+				So(err, ShouldBeNil)
+				So(items, ShouldHaveLength, 1)
+				So(items[0].Id, ShouldEqual, annotation2.Id)
+			})
+
+			Convey("Can query for annotation by region id", func() {
+				items, err := repo.Find(&annotations.ItemQuery{
+					OrgId:    1,
+					RegionId: annotation2.RegionId,
+				})
+
+				So(err, ShouldBeNil)
+				So(items, ShouldHaveLength, 1)
+				So(items[0].Id, ShouldEqual, annotation2.Id)
+			})
+
 			Convey("Should not find any when item is outside time range", func() {
 				items, err := repo.Find(&annotations.ItemQuery{
 					OrgId:       1,

+ 187 - 23
pkg/services/sqlstore/dashboard.go

@@ -1,12 +1,14 @@
 package sqlstore
 
 import (
+	"strings"
 	"time"
 
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/metrics"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/search"
+	"github.com/grafana/grafana/pkg/util"
 )
 
 func init() {
@@ -18,16 +20,20 @@ func init() {
 	bus.AddHandler("sql", GetDashboardTags)
 	bus.AddHandler("sql", GetDashboardSlugById)
 	bus.AddHandler("sql", GetDashboardsByPluginId)
+	bus.AddHandler("sql", GetFoldersForSignedInUser)
+	bus.AddHandler("sql", GetDashboardPermissionsForUser)
 }
 
+var generateNewUid func() string = util.GenerateShortUid
+
 func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 		dash := cmd.GetDashboardModel()
 
 		// try get existing dashboard
-		var existing, sameTitle m.Dashboard
+		var existing m.Dashboard
 
-		if dash.Id > 0 {
+		if dash.Id != 0 {
 			dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing)
 			if err != nil {
 				return err
@@ -49,23 +55,38 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 			if existing.PluginId != "" && cmd.Overwrite == false {
 				return m.UpdatePluginDashboardError{PluginId: existing.PluginId}
 			}
-		}
+		} else if dash.Uid != "" {
+			var sameUid m.Dashboard
+			sameUidExists, err := sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&sameUid)
+			if err != nil {
+				return err
+			}
 
-		sameTitleExists, err := sess.Where("org_id=? AND slug=?", dash.OrgId, dash.Slug).Get(&sameTitle)
-		if err != nil {
-			return err
+			if sameUidExists {
+				// another dashboard with same uid
+				if dash.Id != sameUid.Id {
+					if cmd.Overwrite {
+						dash.Id = sameUid.Id
+						dash.Version = sameUid.Version
+					} else {
+						return m.ErrDashboardWithSameUIDExists
+					}
+				}
+			}
 		}
 
-		if sameTitleExists {
-			// another dashboard with same name
-			if dash.Id != sameTitle.Id {
-				if cmd.Overwrite {
-					dash.Id = sameTitle.Id
-					dash.Version = sameTitle.Version
-				} else {
-					return m.ErrDashboardWithSameNameExists
-				}
+		if dash.Uid == "" {
+			uid, err := generateNewDashboardUid(sess, dash.OrgId)
+			if err != nil {
+				return err
 			}
+			dash.Uid = uid
+			dash.Data.Set("uid", uid)
+		}
+
+		err := guaranteeDashboardNameIsUniqueInFolder(sess, dash)
+		if err != nil {
+			return err
 		}
 
 		err = setHasAcl(sess, dash)
@@ -89,7 +110,7 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 				dash.Updated = cmd.UpdatedAt
 			}
 
-			affectedRows, err = sess.MustCols("folder_id", "has_acl").Id(dash.Id).Update(dash)
+			affectedRows, err = sess.MustCols("folder_id", "has_acl").ID(dash.Id).Update(dash)
 		}
 
 		if err != nil {
@@ -138,6 +159,39 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 		return err
 	})
 }
+func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) {
+	for i := 0; i < 3; i++ {
+		uid := generateNewUid()
+
+		exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&m.Dashboard{})
+		if err != nil {
+			return "", err
+		}
+
+		if !exists {
+			return uid, nil
+		}
+	}
+
+	return "", m.ErrDashboardFailedGenerateUniqueUid
+}
+
+func guaranteeDashboardNameIsUniqueInFolder(sess *DBSession, dash *m.Dashboard) error {
+	var sameNameInFolder m.Dashboard
+	sameNameInFolderExist, err := sess.Where("org_id=? AND title=? AND folder_id = ? AND uid <> ?",
+		dash.OrgId, dash.Title, dash.FolderId, dash.Uid).
+		Get(&sameNameInFolder)
+
+	if err != nil {
+		return err
+	}
+
+	if sameNameInFolderExist {
+		return m.ErrDashboardWithSameNameInFolderExists
+	}
+
+	return nil
+}
 
 func setHasAcl(sess *DBSession, dash *m.Dashboard) error {
 	// check if parent has acl
@@ -258,18 +312,12 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard
 	for _, item := range res {
 		hit, exists := hits[item.Id]
 		if !exists {
-			var url string
-			if item.IsFolder {
-				url = m.GetFolderUrl(item.Uid, item.Slug)
-			} else {
-				url = m.GetDashboardUrl(item.Uid, item.Slug)
-			}
 			hit = &search.Hit{
 				Id:          item.Id,
 				Uid:         item.Uid,
 				Title:       item.Title,
 				Uri:         "db/" + item.Slug,
-				Url:         url,
+				Url:         m.GetDashboardFolderUrl(item.IsFolder, item.Uid, item.Slug),
 				Type:        getHitType(item),
 				FolderId:    item.FolderId,
 				FolderTitle: item.FolderTitle,
@@ -301,6 +349,52 @@ func GetDashboardTags(query *m.GetDashboardTagsQuery) error {
 	return err
 }
 
+func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error {
+	query.Result = make([]*m.DashboardFolder, 0)
+	var err error
+
+	if query.SignedInUser.OrgRole == m.ROLE_ADMIN {
+		sql := `SELECT distinct d.id, d.title
+		FROM dashboard AS d WHERE d.is_folder = ?
+		ORDER BY d.title ASC`
+
+		err = x.Sql(sql, dialect.BooleanStr(true)).Find(&query.Result)
+	} else {
+		params := make([]interface{}, 0)
+		sql := `SELECT distinct d.id, d.title
+		FROM dashboard AS d
+			LEFT JOIN dashboard_acl AS da ON d.id = da.dashboard_id
+			LEFT JOIN team_member AS ugm ON ugm.team_id =  da.team_id
+			LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ?
+			LEFT JOIN org_user ouRole ON ouRole.role = 'Editor' AND ouRole.user_id = ? AND ouRole.org_id = ?`
+		params = append(params, query.SignedInUser.UserId)
+		params = append(params, query.SignedInUser.UserId)
+		params = append(params, query.OrgId)
+
+		sql += `WHERE
+			d.org_id = ? AND
+			d.is_folder = 1 AND
+			(
+				(d.has_acl = 1 AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
+				OR (d.has_acl = 0 AND ouRole.id IS NOT NULL)
+			)`
+		params = append(params, query.OrgId)
+		params = append(params, query.SignedInUser.UserId)
+		params = append(params, query.SignedInUser.UserId)
+
+		if len(query.Title) > 0 {
+			sql += " AND d.title " + dialect.LikeStr() + " ?"
+			params = append(params, "%"+query.Title+"%")
+		}
+
+		sql += ` ORDER BY d.title ASC`
+
+		err = x.Sql(sql, params...).Find(&query.Result)
+	}
+
+	return err
+}
+
 func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 		dashboard := m.Dashboard{Id: cmd.Id, OrgId: cmd.OrgId}
@@ -353,6 +447,76 @@ func GetDashboards(query *m.GetDashboardsQuery) error {
 	return nil
 }
 
+// GetDashboardPermissionsForUser returns the maximum permission the specified user has for a dashboard(s)
+// The function takes in a list of dashboard ids and the user id and role
+func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery) error {
+	if len(query.DashboardIds) == 0 {
+		return m.ErrCommandValidationFailed
+	}
+
+	if query.OrgRole == m.ROLE_ADMIN {
+		var permissions = make([]*m.DashboardPermissionForUser, 0)
+		for _, d := range query.DashboardIds {
+			permissions = append(permissions, &m.DashboardPermissionForUser{
+				DashboardId:    d,
+				Permission:     m.PERMISSION_ADMIN,
+				PermissionName: m.PERMISSION_ADMIN.String(),
+			})
+		}
+		query.Result = permissions
+
+		return nil
+	}
+
+	params := make([]interface{}, 0)
+
+	// check dashboards that have ACLs via user id, team id or role
+	sql := `SELECT d.id AS dashboard_id, MAX(COALESCE(da.permission, pt.permission)) AS permission
+	FROM dashboard AS d
+		LEFT JOIN dashboard_acl as da on d.folder_id = da.dashboard_id or d.id = da.dashboard_id
+		LEFT JOIN team_member as ugm on ugm.team_id =  da.team_id
+		LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ?
+	`
+	params = append(params, query.UserId)
+
+	//check the user's role for dashboards that do not have hasAcl set
+	sql += `LEFT JOIN org_user ouRole ON ouRole.user_id = ? AND ouRole.org_id = ?`
+	params = append(params, query.UserId)
+	params = append(params, query.OrgId)
+
+	sql += `
+		LEFT JOIN (SELECT 1 AS permission, 'Viewer' AS 'role'
+			UNION SELECT 2 AS permission, 'Editor' AS 'role'
+			UNION SELECT 4 AS permission, 'Admin' AS 'role') pt ON ouRole.role = pt.role
+	WHERE
+	d.Id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `) `
+	for _, id := range query.DashboardIds {
+		params = append(params, id)
+	}
+
+	sql += ` AND
+	d.org_id = ? AND
+	  (
+		(d.has_acl = ?  AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
+		OR (d.has_acl = ? AND ouRole.id IS NOT NULL)
+	)
+	group by d.id
+	order by d.id asc`
+	params = append(params, dialect.BooleanStr(true))
+	params = append(params, query.OrgId)
+	params = append(params, query.UserId)
+	params = append(params, query.UserId)
+	params = append(params, dialect.BooleanStr(false))
+
+	err := x.Sql(sql, params...).Find(&query.Result)
+
+	for _, p := range query.Result {
+		p.PermissionName = p.Permission.String()
+	}
+
+	return err
+}
+
 func GetDashboardsByPluginId(query *m.GetDashboardsByPluginIdQuery) error {
 	var dashboards = make([]*m.Dashboard, 0)
 	whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false)

+ 69 - 46
pkg/services/sqlstore/dashboard_acl.go

@@ -126,14 +126,10 @@ func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error {
 }
 
 func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
-	dashboardFilter := fmt.Sprintf(`IN (
-    SELECT %d
-    UNION
-    SELECT folder_id from dashboard where id = %d
-  )`, query.DashboardId, query.DashboardId)
-
-	rawSQL := `
-	SELECT
+	var err error
+
+	if query.DashboardId == 0 {
+		sql := `SELECT
 		da.id,
 		da.org_id,
 		da.dashboard_id,
@@ -143,44 +139,71 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 		da.role,
 		da.created,
 		da.updated,
-		u.login AS user_login,
-		u.email AS user_email,
-		ug.name AS team
-  FROM` + dialect.Quote("dashboard_acl") + ` as da
-		LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id
-		LEFT OUTER JOIN team ug on ug.id = da.team_id
-	WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ?
-
-	-- Also include default permission if has_acl = 0
-
-	UNION
-		SELECT
-			da.id,
-			da.org_id,
-			da.dashboard_id,
-			da.user_id,
-			da.team_id,
-			da.permission,
-			da.role,
-			da.created,
-			da.updated,
-			'' as user_login,
-			'' as user_email,
-			'' as team
-			FROM dashboard_acl as da,
-        dashboard as dash
-        LEFT JOIN dashboard folder on dash.folder_id = folder.id
-			WHERE
-				dash.id = ? AND (
-					dash.has_acl = ` + dialect.BooleanStr(false) + ` or
-					folder.has_acl = ` + dialect.BooleanStr(false) + `
-				) AND
-				da.dashboard_id = -1
-	ORDER BY 1 ASC
-	`
-
-	query.Result = make([]*m.DashboardAclInfoDTO, 0)
-	err := x.SQL(rawSQL, query.OrgId, query.DashboardId).Find(&query.Result)
+		'' as user_login,
+		'' as user_email,
+		'' as team
+		FROM dashboard_acl as da
+		WHERE da.dashboard_id = -1`
+		query.Result = make([]*m.DashboardAclInfoDTO, 0)
+		err = x.SQL(sql).Find(&query.Result)
+
+	} else {
+		dashboardFilter := fmt.Sprintf(`IN (
+			SELECT %d
+			UNION
+			SELECT folder_id from dashboard where id = %d
+		  )`, query.DashboardId, query.DashboardId)
+
+		rawSQL := `
+			SELECT
+				da.id,
+				da.org_id,
+				da.dashboard_id,
+				da.user_id,
+				da.team_id,
+				da.permission,
+				da.role,
+				da.created,
+				da.updated,
+				u.login AS user_login,
+				u.email AS user_email,
+				ug.name AS team
+		  FROM` + dialect.Quote("dashboard_acl") + ` as da
+				LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id
+				LEFT OUTER JOIN team ug on ug.id = da.team_id
+			WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ?
+
+			-- Also include default permission if has_acl = 0
+
+			UNION
+				SELECT
+					da.id,
+					da.org_id,
+					da.dashboard_id,
+					da.user_id,
+					da.team_id,
+					da.permission,
+					da.role,
+					da.created,
+					da.updated,
+					'' as user_login,
+					'' as user_email,
+					'' as team
+					FROM dashboard_acl as da,
+				dashboard as dash
+				LEFT JOIN dashboard folder on dash.folder_id = folder.id
+					WHERE
+						dash.id = ? AND (
+							dash.has_acl = ` + dialect.BooleanStr(false) + ` or
+							folder.has_acl = ` + dialect.BooleanStr(false) + `
+						) AND
+						da.dashboard_id = -1
+			ORDER BY 1 ASC
+			`
+
+		query.Result = make([]*m.DashboardAclInfoDTO, 0)
+		err = x.SQL(rawSQL, query.OrgId, query.DashboardId).Find(&query.Result)
+	}
 
 	for _, p := range query.Result {
 		p.PermissionName = p.Permission.String()

+ 18 - 0
pkg/services/sqlstore/dashboard_acl_test.go

@@ -232,5 +232,23 @@ func TestDashboardAclDataAccess(t *testing.T) {
 
 			})
 		})
+
+		Convey("Given a root folder", func() {
+			var rootFolderId int64 = 0
+
+			Convey("When reading dashboard acl should return default permissions", func() {
+				query := m.GetDashboardAclInfoListQuery{DashboardId: rootFolderId, OrgId: 1}
+
+				err := GetDashboardAclInfoList(&query)
+				So(err, ShouldBeNil)
+
+				So(len(query.Result), ShouldEqual, 2)
+				defaultPermissionsId := -1
+				So(query.Result[0].DashboardId, ShouldEqual, defaultPermissionsId)
+				So(*query.Result[0].Role, ShouldEqual, m.ROLE_VIEWER)
+				So(query.Result[1].DashboardId, ShouldEqual, defaultPermissionsId)
+				So(*query.Result[1].Role, ShouldEqual, m.ROLE_EDITOR)
+			})
+		})
 	})
 }

+ 349 - 0
pkg/services/sqlstore/dashboard_folder_test.go

@@ -0,0 +1,349 @@
+package sqlstore
+
+import (
+	"testing"
+
+	"github.com/go-xorm/xorm"
+	. "github.com/smartystreets/goconvey/convey"
+
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/search"
+)
+
+func TestDashboardFolderDataAccess(t *testing.T) {
+	var x *xorm.Engine
+
+	Convey("Testing DB", t, func() {
+		x = InitTestDB(t)
+
+		Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() {
+			folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
+			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp")
+			childDash := insertTestDashboard("test dash 23", 1, folder.Id, false, "prod", "webapp")
+			insertTestDashboard("test dash 45", 1, folder.Id, false, "prod")
+
+			currentUser := createUser("viewer", "Viewer", false)
+
+			Convey("and no acls are set", func() {
+				Convey("should return all dashboards", func() {
+					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].Id, ShouldEqual, folder.Id)
+					So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+				})
+			})
+
+			Convey("and acl is set for dashboard folder", func() {
+				var otherUser int64 = 999
+				updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
+
+				Convey("should not return folder", func() {
+					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
+				})
+
+				Convey("when the user is given permission", func() {
+					updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT)
+
+					Convey("should be able to access folder", func() {
+						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].Id, ShouldEqual, folder.Id)
+						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("when the user is an admin", func() {
+					Convey("should be able to access folder", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{
+								UserId:  currentUser.Id,
+								OrgId:   1,
+								OrgRole: m.ROLE_ADMIN,
+							},
+							OrgId:        1,
+							DashboardIds: []int64{folder.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].Id, ShouldEqual, folder.Id)
+						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+			})
+
+			Convey("and acl is set for dashboard child and folder has all permissions removed", func() {
+				var otherUser int64 = 999
+				aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
+				removeAcl(aclId)
+				updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT)
+
+				Convey("should not return folder or child", func() {
+					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
+				})
+
+				Convey("when the user is given permission to child", func() {
+					updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT)
+
+					Convey("should be able to search for child dashboard but not folder", func() {
+						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].Id, ShouldEqual, childDash.Id)
+						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("when the user is an admin", func() {
+					Convey("should be able to search for child dash and folder", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{
+								UserId:  currentUser.Id,
+								OrgId:   1,
+								OrgRole: m.ROLE_ADMIN,
+							},
+							OrgId:        1,
+							DashboardIds: []int64{folder.Id, dashInRoot.Id, childDash.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 3)
+						So(query.Result[0].Id, ShouldEqual, folder.Id)
+						So(query.Result[1].Id, ShouldEqual, childDash.Id)
+						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+			})
+		})
+
+		Convey("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func() {
+			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
+			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
+			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod")
+			childDash1 := insertTestDashboard("child dash 1", 1, folder1.Id, false, "prod")
+			childDash2 := insertTestDashboard("child dash 2", 1, folder2.Id, false, "prod")
+
+			currentUser := createUser("viewer", "Viewer", false)
+			var rootFolderId int64 = 0
+
+			Convey("and one folder is expanded, the other collapsed", func() {
+				Convey("should return dashboards in root and expanded folder", func() {
+					query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 4)
+					So(query.Result[0].Id, ShouldEqual, folder1.Id)
+					So(query.Result[1].Id, ShouldEqual, folder2.Id)
+					So(query.Result[2].Id, ShouldEqual, childDash1.Id)
+					So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
+				})
+			})
+
+			Convey("and acl is set for one dashboard folder", func() {
+				var otherUser int64 = 999
+				updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT)
+
+				Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() {
+					movedDash := moveDashboard(1, childDash2.Data, folder1.Id)
+					So(movedDash.HasAcl, ShouldBeTrue)
+
+					Convey("should not return folder with acl or its children", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
+							OrgId:        1,
+							DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 1)
+						So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() {
+					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
+					So(movedDash.HasAcl, ShouldBeFalse)
+
+					Convey("should return folder without acl and its children", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
+							OrgId:        1,
+							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 4)
+						So(query.Result[0].Id, ShouldEqual, folder2.Id)
+						So(query.Result[1].Id, ShouldEqual, childDash1.Id)
+						So(query.Result[2].Id, ShouldEqual, childDash2.Id)
+						So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("and a dashboard with an acl is moved to the folder without an acl", func() {
+					updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT)
+					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
+					So(movedDash.HasAcl, ShouldBeTrue)
+
+					Convey("should return folder without acl but not the dashboard with acl", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
+							OrgId:        1,
+							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 3)
+						So(query.Result[0].Id, ShouldEqual, folder2.Id)
+						So(query.Result[1].Id, ShouldEqual, childDash2.Id)
+						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+			})
+		})
+
+		Convey("Given two dashboard folders", func() {
+
+			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
+			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
+
+			adminUser := createUser("admin", "Admin", true)
+			editorUser := createUser("editor", "Editor", false)
+			viewerUser := createUser("viewer", "Viewer", false)
+
+			Convey("Admin users", func() {
+				Convey("Should have write access to all dashboard folders", func() {
+					query := m.GetFoldersForSignedInUserQuery{
+						OrgId:        1,
+						SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN},
+					}
+
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].Id, ShouldEqual, folder1.Id)
+					So(query.Result[1].Id, ShouldEqual, folder2.Id)
+				})
+
+				Convey("should have write access to all folders and dashboards", func() {
+					query := m.GetDashboardPermissionsForUserQuery{
+						DashboardIds: []int64{folder1.Id, folder2.Id},
+						OrgId:        1,
+						UserId:       adminUser.Id,
+						OrgRole:      m.ROLE_ADMIN,
+					}
+
+					err := GetDashboardPermissionsForUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
+					So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
+					So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
+					So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_ADMIN)
+				})
+			})
+
+			Convey("Editor users", func() {
+				query := m.GetFoldersForSignedInUserQuery{
+					OrgId:        1,
+					SignedInUser: &m.SignedInUser{UserId: editorUser.Id, OrgRole: m.ROLE_EDITOR},
+				}
+
+				Convey("Should have write access to all dashboard folders with default ACL", func() {
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].Id, ShouldEqual, folder1.Id)
+					So(query.Result[1].Id, ShouldEqual, folder2.Id)
+				})
+
+				Convey("should have edit access to folders with default ACL", func() {
+					query := m.GetDashboardPermissionsForUserQuery{
+						DashboardIds: []int64{folder1.Id, folder2.Id},
+						OrgId:        1,
+						UserId:       editorUser.Id,
+						OrgRole:      m.ROLE_EDITOR,
+					}
+
+					err := GetDashboardPermissionsForUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
+					So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT)
+					So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
+					So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_EDIT)
+				})
+
+				Convey("Should have write access to one dashboard folder if default role changed to view for one folder", func() {
+					updateTestDashboardWithAcl(folder1.Id, editorUser.Id, m.PERMISSION_VIEW)
+
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, folder2.Id)
+				})
+
+			})
+
+			Convey("Viewer users", func() {
+				query := m.GetFoldersForSignedInUserQuery{
+					OrgId:        1,
+					SignedInUser: &m.SignedInUser{UserId: viewerUser.Id, OrgRole: m.ROLE_VIEWER},
+				}
+
+				Convey("Should have no write access to any dashboard folders with default ACL", func() {
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 0)
+				})
+
+				Convey("should have view access to folders with default ACL", func() {
+					query := m.GetDashboardPermissionsForUserQuery{
+						DashboardIds: []int64{folder1.Id, folder2.Id},
+						OrgId:        1,
+						UserId:       viewerUser.Id,
+						OrgRole:      m.ROLE_VIEWER,
+					}
+
+					err := GetDashboardPermissionsForUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
+					So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_VIEW)
+					So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
+					So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_VIEW)
+				})
+
+				Convey("Should be able to get one dashboard folder if default role changed to edit for one folder", func() {
+					updateTestDashboardWithAcl(folder1.Id, viewerUser.Id, m.PERMISSION_EDIT)
+
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, folder1.Id)
+				})
+			})
+		})
+	})
+}

+ 156 - 203
pkg/services/sqlstore/dashboard_test.go

@@ -5,12 +5,12 @@ import (
 	"testing"
 
 	"github.com/go-xorm/xorm"
-	. "github.com/smartystreets/goconvey/convey"
-
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/search"
 	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
+	. "github.com/smartystreets/goconvey/convey"
 )
 
 func TestDashboardDataAccess(t *testing.T) {
@@ -196,20 +196,172 @@ func TestDashboardDataAccess(t *testing.T) {
 				})
 			})
 
-			Convey("Should not be able to save dashboard with same name", func() {
+			Convey("Should be able to save dashboards with same name in different folders", func() {
+				firstSaveCmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    nil,
+						"title": "test dash folder and title",
+						"tags":  []interface{}{},
+						"uid":   "randomHash",
+					}),
+					FolderId: 3,
+				}
+
+				err := SaveDashboard(&firstSaveCmd)
+				So(err, ShouldBeNil)
+
+				secondSaveCmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    nil,
+						"title": "test dash folder and title",
+						"tags":  []interface{}{},
+						"uid":   "moreRandomHash",
+					}),
+					FolderId: 1,
+				}
+
+				err = SaveDashboard(&secondSaveCmd)
+				So(err, ShouldBeNil)
+			})
+
+			Convey("Should not be able to save dashboard with same name in the same folder", func() {
+				firstSaveCmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    nil,
+						"title": "test dash folder and title",
+						"tags":  []interface{}{},
+						"uid":   "randomHash",
+					}),
+					FolderId: 3,
+				}
+
+				err := SaveDashboard(&firstSaveCmd)
+				So(err, ShouldBeNil)
+
+				secondSaveCmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    nil,
+						"title": "test dash folder and title",
+						"tags":  []interface{}{},
+						"uid":   "moreRandomHash",
+					}),
+					FolderId: 3,
+				}
+
+				err = SaveDashboard(&secondSaveCmd)
+				So(err, ShouldEqual, m.ErrDashboardWithSameNameInFolderExists)
+			})
+
+			Convey("Should not be able to save dashboard with same uid", func() {
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 						"id":    nil,
 						"title": "test dash 23",
-						"tags":  []interface{}{},
+						"uid":   "dsfalkjngailuedt",
 					}),
 				}
 
 				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+				err = SaveDashboard(&cmd)
 				So(err, ShouldNotBeNil)
 			})
 
+			Convey("Should be able to update dashboard with the same title and folder id", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"uid":   "randomHash",
+						"title": "folderId",
+						"style": "light",
+						"tags":  []interface{}{},
+					}),
+					FolderId: 2,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+				So(cmd.Result.FolderId, ShouldEqual, 2)
+
+				cmd = m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":      cmd.Result.Id,
+						"uid":     "randomHash",
+						"title":   "folderId",
+						"style":   "dark",
+						"version": cmd.Result.Version,
+						"tags":    []interface{}{},
+					}),
+					FolderId: 2,
+				}
+
+				err = SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+			})
+
+			Convey("Should not be able to update using just uid", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"uid":     savedDash.Uid,
+						"title":   "folderId",
+						"version": savedDash.Version,
+						"tags":    []interface{}{},
+					}),
+					FolderId: savedDash.FolderId,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldEqual, m.ErrDashboardWithSameUIDExists)
+			})
+
+			Convey("Should be able to update using just uid with overwrite", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"uid":     savedDash.Uid,
+						"title":   "folderId",
+						"version": savedDash.Version,
+						"tags":    []interface{}{},
+					}),
+					FolderId:  savedDash.FolderId,
+					Overwrite: true,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+			})
+
+			Convey("Should retry generation of uid once if it fails.", func() {
+				timesCalled := 0
+				generateNewUid = func() string {
+					timesCalled += 1
+					if timesCalled <= 2 {
+						return savedDash.Uid
+					} else {
+						return util.GenerateShortUid()
+					}
+				}
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"title": "new dash 12334",
+						"tags":  []interface{}{},
+					}),
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+
+				generateNewUid = util.GenerateShortUid
+			})
+
 			Convey("Should be able to update dashboard and remove folderId", func() {
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
@@ -299,205 +451,6 @@ func TestDashboardDataAccess(t *testing.T) {
 			})
 		})
 
-		Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() {
-			folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
-			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp")
-			childDash := insertTestDashboard("test dash 23", 1, folder.Id, false, "prod", "webapp")
-			insertTestDashboard("test dash 45", 1, folder.Id, false, "prod")
-
-			currentUser := createUser("viewer", "Viewer", false)
-
-			Convey("and no acls are set", func() {
-				Convey("should return all dashboards", func() {
-					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
-					err := SearchDashboards(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 2)
-					So(query.Result[0].Id, ShouldEqual, folder.Id)
-					So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
-				})
-			})
-
-			Convey("and acl is set for dashboard folder", func() {
-				var otherUser int64 = 999
-				updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
-
-				Convey("should not return folder", func() {
-					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
-					err := SearchDashboards(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 1)
-					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
-				})
-
-				Convey("when the user is given permission", func() {
-					updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT)
-
-					Convey("should be able to access folder", func() {
-						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 2)
-						So(query.Result[0].Id, ShouldEqual, folder.Id)
-						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-
-				Convey("when the user is an admin", func() {
-					Convey("should be able to access folder", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{
-								UserId:  currentUser.Id,
-								OrgId:   1,
-								OrgRole: m.ROLE_ADMIN,
-							},
-							OrgId:        1,
-							DashboardIds: []int64{folder.Id, dashInRoot.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 2)
-						So(query.Result[0].Id, ShouldEqual, folder.Id)
-						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-			})
-
-			Convey("and acl is set for dashboard child and folder has all permissions removed", func() {
-				var otherUser int64 = 999
-				aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
-				removeAcl(aclId)
-				updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT)
-
-				Convey("should not return folder or child", func() {
-					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
-					err := SearchDashboards(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 1)
-					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
-				})
-
-				Convey("when the user is given permission to child", func() {
-					updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT)
-
-					Convey("should be able to search for child dashboard but not folder", func() {
-						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 2)
-						So(query.Result[0].Id, ShouldEqual, childDash.Id)
-						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-
-				Convey("when the user is an admin", func() {
-					Convey("should be able to search for child dash and folder", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{
-								UserId:  currentUser.Id,
-								OrgId:   1,
-								OrgRole: m.ROLE_ADMIN,
-							},
-							OrgId:        1,
-							DashboardIds: []int64{folder.Id, dashInRoot.Id, childDash.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 3)
-						So(query.Result[0].Id, ShouldEqual, folder.Id)
-						So(query.Result[1].Id, ShouldEqual, childDash.Id)
-						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-			})
-		})
-
-		Convey("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func() {
-			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
-			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
-			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod")
-			childDash1 := insertTestDashboard("child dash 1", 1, folder1.Id, false, "prod")
-			childDash2 := insertTestDashboard("child dash 2", 1, folder2.Id, false, "prod")
-
-			currentUser := createUser("viewer", "Viewer", false)
-			var rootFolderId int64 = 0
-
-			Convey("and one folder is expanded, the other collapsed", func() {
-				Convey("should return dashboards in root and expanded folder", func() {
-					query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1}
-					err := SearchDashboards(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 4)
-					So(query.Result[0].Id, ShouldEqual, folder1.Id)
-					So(query.Result[1].Id, ShouldEqual, folder2.Id)
-					So(query.Result[2].Id, ShouldEqual, childDash1.Id)
-					So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
-				})
-			})
-
-			Convey("and acl is set for one dashboard folder", func() {
-				var otherUser int64 = 999
-				updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT)
-
-				Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() {
-					movedDash := moveDashboard(1, childDash2.Data, folder1.Id)
-					So(movedDash.HasAcl, ShouldBeTrue)
-
-					Convey("should not return folder with acl or its children", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
-							OrgId:        1,
-							DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 1)
-						So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-
-				Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() {
-					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
-					So(movedDash.HasAcl, ShouldBeFalse)
-
-					Convey("should return folder without acl and its children", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
-							OrgId:        1,
-							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 4)
-						So(query.Result[0].Id, ShouldEqual, folder2.Id)
-						So(query.Result[1].Id, ShouldEqual, childDash1.Id)
-						So(query.Result[2].Id, ShouldEqual, childDash2.Id)
-						So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-
-				Convey("and a dashboard with an acl is moved to the folder without an acl", func() {
-					updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT)
-					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
-					So(movedDash.HasAcl, ShouldBeTrue)
-
-					Convey("should return folder without acl but not the dashboard with acl", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
-							OrgId:        1,
-							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 3)
-						So(query.Result[0].Id, ShouldEqual, folder2.Id)
-						So(query.Result[1].Id, ShouldEqual, childDash2.Id)
-						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-			})
-		})
-
 		Convey("Given a plugin with imported dashboards", func() {
 			pluginId := "test-app"
 

+ 2 - 3
pkg/services/sqlstore/dashboard_version_test.go

@@ -12,7 +12,7 @@ import (
 )
 
 func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) {
-	data["title"] = dashboard.Title
+	data["uid"] = dashboard.Uid
 
 	saveCmd := m.SaveDashboardCommand{
 		OrgId:     dashboard.OrgId,
@@ -44,12 +44,11 @@ func TestGetDashboardVersion(t *testing.T) {
 
 			dashCmd := m.GetDashboardQuery{
 				OrgId: savedDash.OrgId,
-				Slug:  savedDash.Slug,
+				Uid:   savedDash.Uid,
 			}
 
 			err = GetDashboard(&dashCmd)
 			So(err, ShouldBeNil)
-			dashCmd.Result.Data.Del("uid")
 			eq := reflect.DeepEqual(dashCmd.Result.Data, query.Result.Data)
 			So(eq, ShouldEqual, true)
 		})

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

@@ -163,4 +163,8 @@ func addDashboardMigration(mg *Migrator) {
 	mg.AddMigration("Add unique index dashboard_org_id_uid", NewAddIndexMigration(dashboardV2, &Index{
 		Cols: []string{"org_id", "uid"}, Type: UniqueIndex,
 	}))
+
+	mg.AddMigration("Remove unique index org_id_slug", NewDropIndexMigration(dashboardV2, &Index{
+		Cols: []string{"org_id", "slug"}, Type: UniqueIndex,
+	}))
 }

+ 1 - 0
public/app/containers/AlertRuleList/AlertRuleList.jest.tsx

@@ -24,6 +24,7 @@ describe('AlertRuleList', () => {
           evalData: {},
           executionError: '',
           dashboardUri: 'db/mygool',
+          canEdit: true,
         },
       ])
     );

+ 20 - 6
public/app/containers/AlertRuleList/AlertRuleList.tsx

@@ -147,7 +147,8 @@ export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
         <div className="alert-rule-item__body">
           <div className="alert-rule-item__header">
             <div className="alert-rule-item__name">
-              <a href={ruleUrl}>{this.renderText(rule.name)}</a>
+              {rule.canEdit && <a href={ruleUrl}>{this.renderText(rule.name)}</a>}
+              {!rule.canEdit && <span>{this.renderText(rule.name)}</span>}
             </div>
             <div className="alert-rule-item__text">
               <span className={`${rule.stateClass}`}>{this.renderText(rule.stateText)}</span>
@@ -156,17 +157,30 @@ export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
           </div>
           {rule.info && <div className="small muted alert-rule-item__info">{this.renderText(rule.info)}</div>}
         </div>
+
         <div className="alert-rule-item__actions">
-          <a
+          <button
             className="btn btn-small btn-inverse alert-list__btn width-2"
             title="Pausing an alert rule prevents it from executing"
             onClick={this.toggleState}
+            disabled={!rule.canEdit}
           >
             <i className={stateClass} />
-          </a>
-          <a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
-            <i className="icon-gf icon-gf-settings" />
-          </a>
+          </button>
+          {rule.canEdit && (
+            <a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
+              <i className="icon-gf icon-gf-settings" />
+            </a>
+          )}
+          {!rule.canEdit && (
+            <button
+              className="btn btn-small btn-inverse alert-list__btn width-2"
+              title="Edit alert rule"
+              disabled={true}
+            >
+              <i className="icon-gf icon-gf-settings" />
+            </button>
+          )}
         </div>
       </li>
     );

+ 3 - 2
public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap

@@ -80,15 +80,16 @@ exports[`AlertRuleList should render 1 rule 1`] = `
   <div
     className="alert-rule-item__actions"
   >
-    <a
+    <button
       className="btn btn-small btn-inverse alert-list__btn width-2"
+      disabled={false}
       onClick={[Function]}
       title="Pausing an alert rule prevents it from executing"
     >
       <i
         className="fa fa-pause"
       />
-    </a>
+    </button>
     <a
       className="btn btn-small btn-inverse alert-list__btn width-2"
       href="dashboard/db/mygool?panelId=3&fullscreen&edit&tab=alert"

+ 5 - 0
public/app/containers/IContainerProps.ts

@@ -1,15 +1,20 @@
 import { SearchStore } from './../stores/SearchStore/SearchStore';
 import { ServerStatsStore } from './../stores/ServerStatsStore/ServerStatsStore';
 import { NavStore } from './../stores/NavStore/NavStore';
+import { PermissionsStore } from './../stores/PermissionsStore/PermissionsStore';
 import { AlertListStore } from './../stores/AlertListStore/AlertListStore';
 import { ViewStore } from './../stores/ViewStore/ViewStore';
+import { FolderStore } from './../stores/FolderStore/FolderStore';
 
 interface IContainerProps {
   search: typeof SearchStore.Type;
   serverStats: typeof ServerStatsStore.Type;
   nav: typeof NavStore.Type;
   alertList: typeof AlertListStore.Type;
+  permissions: typeof PermissionsStore.Type;
   view: typeof ViewStore.Type;
+  folder: typeof FolderStore.Type;
+  backendSrv: any;
 }
 
 export default IContainerProps;

+ 49 - 0
public/app/containers/ManageDashboards/FolderPermissions.tsx

@@ -0,0 +1,49 @@
+import React, { Component } from 'react';
+import { inject, observer } from 'mobx-react';
+import { toJS } from 'mobx';
+import IContainerProps from 'app/containers/IContainerProps';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import Permissions from 'app/core/components/Permissions/Permissions';
+import Tooltip from 'app/core/components/Tooltip/Tooltip';
+import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
+@inject('nav', 'folder', 'view', 'permissions')
+@observer
+export class FolderPermissions extends Component<IContainerProps, any> {
+  constructor(props) {
+    super(props);
+    this.loadStore();
+  }
+
+  loadStore() {
+    const { nav, folder, view } = this.props;
+    return folder.load(view.routeParams.get('slug') as string).then(res => {
+      return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions');
+    });
+  }
+
+  render() {
+    const { nav, folder, permissions, backendSrv } = this.props;
+
+    if (!folder.folder || !nav.main) {
+      return <h2>Loading</h2>;
+    }
+
+    const dashboardId = folder.folder.id;
+
+    return (
+      <div>
+        <PageHeader model={nav as any} />
+        <div className="page-container page-body">
+          <div className="page-sub-heading">
+            <h2 className="d-inline-block">Folder Permissions</h2>
+            <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
+              <i className="gicon gicon-question gicon--has-hover" />
+            </Tooltip>
+          </div>
+
+          <Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
+        </div>
+      </div>
+    );
+  }
+}

+ 78 - 0
public/app/containers/ManageDashboards/FolderSettings.jest.tsx

@@ -0,0 +1,78 @@
+import React from 'react';
+import { FolderSettings } from './FolderSettings';
+import { RootStore } from 'app/stores/RootStore/RootStore';
+import { backendSrv } from 'test/mocks/common';
+import { shallow } from 'enzyme';
+
+describe('FolderSettings', () => {
+  let wrapper;
+  let page;
+
+  beforeAll(() => {
+    backendSrv.getDashboard.mockReturnValue(
+      Promise.resolve({
+        dashboard: {
+          id: 1,
+          title: 'Folder Name',
+        },
+        meta: {
+          slug: 'folder-name',
+          canSave: true,
+        },
+      })
+    );
+
+    const store = RootStore.create(
+      {},
+      {
+        backendSrv: backendSrv,
+      }
+    );
+
+    wrapper = shallow(<FolderSettings backendSrv={backendSrv} {...store} />);
+    return wrapper
+      .dive()
+      .instance()
+      .loadStore()
+      .then(() => {
+        page = wrapper.dive();
+      });
+  });
+
+  it('should set the title input field', () => {
+    const titleInput = page.find('.gf-form-input');
+    expect(titleInput).toHaveLength(1);
+    expect(titleInput.prop('value')).toBe('Folder Name');
+  });
+
+  it('should update title and enable save button when changed', () => {
+    const titleInput = page.find('.gf-form-input');
+    const disabledSubmitButton = page.find('button[type="submit"]');
+    expect(disabledSubmitButton.prop('disabled')).toBe(true);
+
+    titleInput.simulate('change', { target: { value: 'New Title' } });
+
+    const updatedTitleInput = page.find('.gf-form-input');
+    expect(updatedTitleInput.prop('value')).toBe('New Title');
+    const enabledSubmitButton = page.find('button[type="submit"]');
+    expect(enabledSubmitButton.prop('disabled')).toBe(false);
+  });
+
+  it('should disable save button if title is changed back to old title', () => {
+    const titleInput = page.find('.gf-form-input');
+
+    titleInput.simulate('change', { target: { value: 'Folder Name' } });
+
+    const enabledSubmitButton = page.find('button[type="submit"]');
+    expect(enabledSubmitButton.prop('disabled')).toBe(true);
+  });
+
+  it('should disable save button if title is changed to empty string', () => {
+    const titleInput = page.find('.gf-form-input');
+
+    titleInput.simulate('change', { target: { value: '' } });
+
+    const enabledSubmitButton = page.find('button[type="submit"]');
+    expect(enabledSubmitButton.prop('disabled')).toBe(true);
+  });
+});

+ 153 - 0
public/app/containers/ManageDashboards/FolderSettings.tsx

@@ -0,0 +1,153 @@
+import React from 'react';
+import { inject, observer } from 'mobx-react';
+import { toJS } from 'mobx';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import IContainerProps from 'app/containers/IContainerProps';
+import { getSnapshot } from 'mobx-state-tree';
+import appEvents from 'app/core/app_events';
+
+@inject('nav', 'folder', 'view')
+@observer
+export class FolderSettings extends React.Component<IContainerProps, any> {
+  formSnapshot: any;
+  dashboard: any;
+
+  constructor(props) {
+    super(props);
+    this.loadStore();
+  }
+
+  loadStore() {
+    const { nav, folder, view } = this.props;
+
+    return folder.load(view.routeParams.get('slug') as string).then(res => {
+      this.formSnapshot = getSnapshot(folder);
+      this.dashboard = res.dashboard;
+
+      return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
+    });
+  }
+
+  onTitleChange(evt) {
+    this.props.folder.setTitle(this.getFormSnapshot().folder.title, evt.target.value);
+  }
+
+  getFormSnapshot() {
+    if (!this.formSnapshot) {
+      this.formSnapshot = getSnapshot(this.props.folder);
+    }
+
+    return this.formSnapshot;
+  }
+
+  save(evt) {
+    if (evt) {
+      evt.stopPropagation();
+      evt.preventDefault();
+    }
+
+    const { nav, folder, view } = this.props;
+
+    folder
+      .saveFolder(this.dashboard, { overwrite: false })
+      .then(newUrl => {
+        view.updatePathAndQuery(newUrl, '', '');
+
+        appEvents.emit('dashboard-saved');
+        appEvents.emit('alert-success', ['Folder saved']);
+      })
+      .then(() => {
+        return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
+      })
+      .catch(this.handleSaveFolderError);
+  }
+
+  delete(evt) {
+    if (evt) {
+      evt.stopPropagation();
+      evt.preventDefault();
+    }
+
+    const { folder, view } = this.props;
+    const title = folder.folder.title;
+
+    appEvents.emit('confirm-modal', {
+      title: 'Delete',
+      text: `Do you want to delete this folder and all its dashboards?`,
+      icon: 'fa-trash',
+      yesText: 'Delete',
+      onConfirm: () => {
+        return this.props.folder.deleteFolder().then(() => {
+          appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]);
+          view.updatePathAndQuery('dashboards', '', '');
+        });
+      },
+    });
+  }
+
+  handleSaveFolderError(err) {
+    if (err.data && err.data.status === 'version-mismatch') {
+      err.isHandled = true;
+
+      appEvents.emit('confirm-modal', {
+        title: 'Conflict',
+        text: 'Someone else has updated this folder.',
+        text2: 'Would you still like to save this folder?',
+        yesText: 'Save & Overwrite',
+        icon: 'fa-warning',
+        onConfirm: () => {
+          this.props.folder.saveFolder(this.dashboard, { overwrite: true });
+        },
+      });
+    }
+
+    if (err.data && err.data.status === 'name-exists') {
+      err.isHandled = true;
+
+      appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']);
+    }
+  }
+
+  render() {
+    const { nav, folder } = this.props;
+
+    if (!folder.folder || !nav.main) {
+      return <h2>Loading</h2>;
+    }
+
+    return (
+      <div>
+        <PageHeader model={nav as any} />
+        <div className="page-container page-body">
+          <h2 className="page-sub-heading">Folder Settings</h2>
+
+          <div className="section gf-form-group">
+            <form name="folderSettingsForm" onSubmit={this.save.bind(this)}>
+              <div className="gf-form">
+                <label className="gf-form-label width-7">Name</label>
+                <input
+                  type="text"
+                  className="gf-form-input width-30"
+                  value={folder.folder.title}
+                  onChange={this.onTitleChange.bind(this)}
+                />
+              </div>
+              <div className="gf-form-button-row">
+                <button
+                  type="submit"
+                  className="btn btn-success"
+                  disabled={!folder.folder.canSave || !folder.folder.hasChanged}
+                >
+                  <i className="fa fa-trash" /> Save
+                </button>
+                <button className="btn btn-danger" onClick={this.delete.bind(this)} disabled={!folder.folder.canSave}>
+                  <i className="fa fa-trash" /> Delete
+                </button>
+              </div>
+            </form>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}

+ 1 - 1
public/app/containers/ServerStats/ServerStats.jest.tsx

@@ -20,7 +20,7 @@ describe('ServerStats', () => {
       }
     );
 
-    const page = renderer.create(<ServerStats {...store} />);
+    const page = renderer.create(<ServerStats backendSrv={backendSrv} {...store} />);
 
     setTimeout(() => {
       expect(page.toJSON()).toMatchSnapshot();

+ 1 - 1
public/app/containers/ServerStats/__snapshots__/ServerStats.jest.tsx.snap

@@ -41,9 +41,9 @@ exports[`ServerStats Should render table with stats 1`] = `
             />
             <select
               className="gf-select-nav gf-form-input"
-              defaultValue="/url/server-stats"
               id="page-header-select-nav"
               onChange={[Function]}
+              value="/url/server-stats"
             >
               <option
                 value="/url/server-stats"

+ 10 - 2
public/app/core/angular_wrappers.ts

@@ -4,8 +4,9 @@ import PageHeader from './components/PageHeader/PageHeader';
 import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
 import LoginBackground from './components/Login/LoginBackground';
 import { SearchResult } from './components/search/SearchResult';
-import UserPicker from './components/UserPicker/UserPicker';
 import { TagFilter } from './components/TagFilter/TagFilter';
+import UserPicker from './components/Picker/UserPicker';
+import DashboardPermissions from './components/Permissions/DashboardPermissions';
 
 export function registerAngularDirectives() {
   react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
@@ -13,10 +14,17 @@ export function registerAngularDirectives() {
   react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
   react2AngularDirective('loginBackground', LoginBackground, []);
   react2AngularDirective('searchResult', SearchResult, []);
-  react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'teamId', 'refreshList']);
   react2AngularDirective('tagFilter', TagFilter, [
     'tags',
     ['onSelect', { watchDepth: 'reference' }],
     ['tagOptions', { watchDepth: 'reference' }],
   ]);
+  react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
+  react2AngularDirective('dashboardPermissions', DashboardPermissions, [
+    'backendSrv',
+    'dashboardId',
+    'folderTitle',
+    'folderSlug',
+    'folderId',
+  ]);
 }

+ 53 - 0
public/app/core/components/PageHeader/PageHeader.jest.tsx

@@ -0,0 +1,53 @@
+import React from 'react';
+import PageHeader from './PageHeader';
+import { shallow } from 'enzyme';
+
+describe('PageHeader', () => {
+  let wrapper;
+
+  describe('when the nav tree has a node with a title', () => {
+    beforeAll(() => {
+      const nav = {
+        main: {
+          icon: 'fa fa-folder-open',
+          id: 'node',
+          subTitle: 'node subtitle',
+          url: '',
+          text: 'node',
+        },
+        node: {},
+      };
+      wrapper = shallow(<PageHeader model={nav as any} />);
+    });
+
+    it('should render the title', () => {
+      const title = wrapper.find('.page-header__title');
+      expect(title.text()).toBe('node');
+    });
+  });
+
+  describe('when the nav tree has a node with breadcrumbs and a title', () => {
+    beforeAll(() => {
+      const nav = {
+        main: {
+          icon: 'fa fa-folder-open',
+          id: 'child',
+          subTitle: 'child subtitle',
+          url: '',
+          text: 'child',
+          breadcrumbs: [{ title: 'Parent', url: 'parentUrl' }],
+        },
+        node: {},
+      };
+      wrapper = shallow(<PageHeader model={nav as any} />);
+    });
+
+    it('should render the title with breadcrumbs first and then title last', () => {
+      const title = wrapper.find('.page-header__title');
+      expect(title.text()).toBe('Parent / child');
+
+      const parentLink = wrapper.find('.page-header__title > a.text-link');
+      expect(parentLink.prop('href')).toBe('parentUrl');
+    });
+  });
+});

+ 73 - 58
public/app/core/components/PageHeader/PageHeader.tsx

@@ -1,55 +1,15 @@
 import React from 'react';
+import { observer } from 'mobx-react';
 import { NavModel, NavModelItem } from '../../nav_model_srv';
 import classNames from 'classnames';
 import appEvents from 'app/core/app_events';
+import { toJS } from 'mobx';
 
 export interface IProps {
   model: NavModel;
 }
 
-function TabItem(tab: NavModelItem) {
-  if (tab.hideFromTabs) {
-    return null;
-  }
-
-  let tabClasses = classNames({
-    'gf-tabs-link': true,
-    active: tab.active,
-  });
-
-  return (
-    <li className="gf-tabs-item" key={tab.url}>
-      <a className={tabClasses} target={tab.target} href={tab.url}>
-        <i className={tab.icon} />
-        {tab.text}
-      </a>
-    </li>
-  );
-}
-
-function SelectOption(navItem: NavModelItem) {
-  if (navItem.hideFromTabs) {
-    // TODO: Rename hideFromTabs => hideFromNav
-    return null;
-  }
-
-  return (
-    <option key={navItem.url} value={navItem.url}>
-      {navItem.text}
-    </option>
-  );
-}
-
-function Navigation({ main }: { main: NavModelItem }) {
-  return (
-    <nav>
-      <SelectNav customCss="page-header__select-nav" main={main} />
-      <Tabs customCss="page-header__tabs" main={main} />
-    </nav>
-  );
-}
-
-function SelectNav({ main, customCss }: { main: NavModelItem; customCss: string }) {
+const SelectNav = ({ main, customCss }: { main: NavModelItem; customCss: string }) => {
   const defaultSelectedItem = main.children.find(navItem => {
     return navItem.active === true;
   });
@@ -66,26 +26,81 @@ function SelectNav({ main, customCss }: { main: NavModelItem; customCss: string
       {/* Label to make it clickable */}
       <select
         className="gf-select-nav gf-form-input"
-        defaultValue={defaultSelectedItem.url}
+        value={defaultSelectedItem.url}
         onChange={gotoUrl}
         id="page-header-select-nav"
       >
-        {main.children.map(SelectOption)}
+        {main.children.map((navItem: NavModelItem) => {
+          if (navItem.hideFromTabs) {
+            // TODO: Rename hideFromTabs => hideFromNav
+            return null;
+          }
+          return (
+            <option key={navItem.url} value={navItem.url}>
+              {navItem.text}
+            </option>
+          );
+        })}
       </select>
     </div>
   );
-}
+};
 
-function Tabs({ main, customCss }: { main: NavModelItem; customCss: string }) {
-  return <ul className={`gf-tabs ${customCss}`}>{main.children.map(TabItem)}</ul>;
-}
+const Tabs = ({ main, customCss }: { main: NavModelItem; customCss: string }) => {
+  return (
+    <ul className={`gf-tabs ${customCss}`}>
+      {main.children.map((tab, idx) => {
+        if (tab.hideFromTabs) {
+          return null;
+        }
+
+        const tabClasses = classNames({
+          'gf-tabs-link': true,
+          active: tab.active,
+        });
+
+        return (
+          <li className="gf-tabs-item" key={tab.url}>
+            <a className={tabClasses} target={tab.target} href={tab.url}>
+              <i className={tab.icon} />
+              {tab.text}
+            </a>
+          </li>
+        );
+      })}
+    </ul>
+  );
+};
 
+const Navigation = ({ main }: { main: NavModelItem }) => {
+  return (
+    <nav>
+      <SelectNav customCss="page-header__select-nav" main={main} />
+      <Tabs customCss="page-header__tabs" main={main} />
+    </nav>
+  );
+};
+
+@observer
 export default class PageHeader extends React.Component<IProps, any> {
   constructor(props) {
     super(props);
   }
 
-  renderBreadcrumb(breadcrumbs) {
+  shouldComponentUpdate() {
+    //Hack to re-render on changed props from angular with the @observer decorator
+    return true;
+  }
+
+  renderTitle(title: string, breadcrumbs: any[]) {
+    if (!title && (!breadcrumbs || breadcrumbs.length === 0)) {
+      return null;
+    }
+
+    if (!breadcrumbs || breadcrumbs.length === 0) {
+      return <h1 className="page-header__title">{title}</h1>;
+    }
+
     const breadcrumbsResult = [];
     for (let i = 0; i < breadcrumbs.length; i++) {
       const bc = breadcrumbs[i];
@@ -99,7 +114,9 @@ export default class PageHeader extends React.Component<IProps, any> {
         breadcrumbsResult.push(<span key={i}> / {bc.title}</span>);
       }
     }
-    return breadcrumbsResult;
+    breadcrumbsResult.push(<span key={breadcrumbs.length + 1}> / {title}</span>);
+
+    return <h1 className="page-header__title">{breadcrumbsResult}</h1>;
   }
 
   renderHeaderTitle(main) {
@@ -111,11 +128,7 @@ export default class PageHeader extends React.Component<IProps, any> {
         </span>
 
         <div className="page-header__info-block">
-          {main.text && <h1 className="page-header__title">{main.text}</h1>}
-          {main.breadcrumbs &&
-            main.breadcrumbs.length > 0 && (
-              <h1 className="page-header__title">{this.renderBreadcrumb(main.breadcrumbs)}</h1>
-            )}
+          {this.renderTitle(main.text, main.breadcrumbs)}
           {main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
           {main.subType && (
             <div className="page-header__stamps">
@@ -135,12 +148,14 @@ export default class PageHeader extends React.Component<IProps, any> {
       return null;
     }
 
+    const main = toJS(model.main); // Convert to JS if its a mobx observable
+
     return (
       <div className="page-header-canvas">
         <div className="page-container">
           <div className="page-header">
-            {this.renderHeaderTitle(model.main)}
-            {model.main.children && <Navigation main={model.main} />}
+            {this.renderHeaderTitle(main)}
+            {main.children && <Navigation main={main} />}
           </div>
         </div>
       </div>

+ 46 - 0
public/app/core/components/Permissions/DashboardPermissions.tsx

@@ -0,0 +1,46 @@
+import React, { Component } from 'react';
+import { store } from 'app/stores/store';
+import Permissions from 'app/core/components/Permissions/Permissions';
+import Tooltip from 'app/core/components/Tooltip/Tooltip';
+import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
+
+export interface IProps {
+  dashboardId: number;
+  folderId: number;
+  folderTitle: string;
+  folderSlug: string;
+  backendSrv: any;
+}
+
+class DashboardPermissions extends Component<IProps, any> {
+  permissions: any;
+
+  constructor(props) {
+    super(props);
+    this.permissions = store.permissions;
+  }
+
+  render() {
+    const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props;
+
+    return (
+      <div>
+        <div className="dashboard-settings__header">
+          <h3 className="d-inline-block">Permissions</h3>
+          <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
+            <i className="gicon gicon-question gicon--has-hover" />
+          </Tooltip>
+        </div>
+        <Permissions
+          permissions={this.permissions}
+          isFolder={false}
+          dashboardId={dashboardId}
+          folderInfo={{ title: folderTitle, slug: folderSlug, id: folderId }}
+          backendSrv={backendSrv}
+        />
+      </div>
+    );
+  }
+}
+
+export default DashboardPermissions;

+ 40 - 0
public/app/core/components/Permissions/DisabledPermissionsListItem.tsx

@@ -0,0 +1,40 @@
+import React, { Component } from 'react';
+import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
+import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
+
+export interface IProps {
+  item: any;
+}
+
+export default class DisabledPermissionListItem extends Component<IProps, any> {
+  render() {
+    const { item } = this.props;
+
+    return (
+      <tr className="gf-form-disabled">
+        <td style={{ width: '100%' }}>
+          <i className={`fa--permissions-list ${item.icon}`} />
+          <span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
+        </td>
+        <td />
+        <td className="query-keyword">Can</td>
+        <td>
+          <div className="gf-form">
+            <DescriptionPicker
+              optionsWithDesc={permissionOptions}
+              handlePicked={() => {}}
+              value={item.permission}
+              disabled={true}
+              className={'gf-form-input--form-dropdown-right'}
+            />
+          </div>
+        </td>
+        <td>
+          <button className="btn btn-inverse btn-small">
+            <i className="fa fa-lock" />
+          </button>
+        </td>
+      </tr>
+    );
+  }
+}

+ 5 - 0
public/app/core/components/Permissions/FolderInfo.ts

@@ -0,0 +1,5 @@
+export interface FolderInfo {
+  title: string;
+  id: number;
+  slug: string;
+}

+ 73 - 0
public/app/core/components/Permissions/Permissions.jest.tsx

@@ -0,0 +1,73 @@
+import React from 'react';
+import Permissions from './Permissions';
+import { RootStore } from 'app/stores/RootStore/RootStore';
+import { backendSrv } from 'test/mocks/common';
+import { shallow } from 'enzyme';
+
+describe('Permissions', () => {
+  let wrapper;
+
+  beforeAll(() => {
+    backendSrv.get.mockReturnValue(
+      Promise.resolve([
+        { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
+        { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
+        {
+          id: 4,
+          dashboardId: 1,
+          userId: 2,
+          userLogin: 'danlimerick',
+          userEmail: 'dan.limerick@gmail.com',
+          permission: 4,
+          permissionName: 'Admin',
+        },
+      ])
+    );
+
+    backendSrv.post = jest.fn();
+
+    const store = RootStore.create(
+      {},
+      {
+        backendSrv: backendSrv,
+      }
+    );
+
+    wrapper = shallow(<Permissions backendSrv={backendSrv} isFolder={true} dashboardId={1} {...store} />);
+    return wrapper.instance().loadStore(1, true);
+  });
+
+  describe('when permission for a user is added', () => {
+    it('should save permission to db', () => {
+      const userItem = {
+        id: 2,
+        login: 'user2',
+      };
+
+      wrapper
+        .instance()
+        .userPicked(userItem)
+        .then(() => {
+          expect(backendSrv.post.mock.calls.length).toBe(1);
+          expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+        });
+    });
+  });
+
+  describe('when permission for team is added', () => {
+    it('should save permission to db', () => {
+      const teamItem = {
+        id: 2,
+        name: 'ug1',
+      };
+
+      wrapper
+        .instance()
+        .teamPicked(teamItem)
+        .then(() => {
+          expect(backendSrv.post.mock.calls.length).toBe(1);
+          expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+        });
+    });
+  });
+});

+ 161 - 0
public/app/core/components/Permissions/Permissions.tsx

@@ -0,0 +1,161 @@
+import React, { Component } from 'react';
+import PermissionsList from './PermissionsList';
+import { observer } from 'mobx-react';
+import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
+import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
+import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
+import { FolderInfo } from './FolderInfo';
+
+export interface DashboardAcl {
+  id?: number;
+  dashboardId?: number;
+  userId?: number;
+  userLogin?: string;
+  userEmail?: string;
+  teamId?: number;
+  team?: string;
+  permission?: number;
+  permissionName?: string;
+  role?: string;
+  icon?: string;
+  nameHtml?: string;
+  inherited?: boolean;
+  sortName?: string;
+  sortRank?: number;
+}
+
+export interface IProps {
+  dashboardId: number;
+  folderInfo?: FolderInfo;
+  permissions?: any;
+  isFolder: boolean;
+  backendSrv: any;
+}
+
+@observer
+class Permissions extends Component<IProps, any> {
+  constructor(props) {
+    super(props);
+    const { dashboardId, isFolder, folderInfo } = this.props;
+    this.permissionChanged = this.permissionChanged.bind(this);
+    this.typeChanged = this.typeChanged.bind(this);
+    this.removeItem = this.removeItem.bind(this);
+    this.userPicked = this.userPicked.bind(this);
+    this.teamPicked = this.teamPicked.bind(this);
+    this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0);
+  }
+
+  loadStore(dashboardId, isFolder, isInRoot = false) {
+    return this.props.permissions.load(dashboardId, isFolder, isInRoot);
+  }
+
+  permissionChanged(index: number, permission: number, permissionName: string) {
+    const { permissions } = this.props;
+    permissions.updatePermissionOnIndex(index, permission, permissionName);
+  }
+
+  removeItem(index: number) {
+    const { permissions } = this.props;
+    permissions.removeStoreItem(index);
+  }
+
+  resetNewType() {
+    const { permissions } = this.props;
+    permissions.resetNewType();
+  }
+
+  typeChanged(evt) {
+    const { value } = evt.target;
+    const { permissions, dashboardId } = this.props;
+
+    if (value === 'Viewer' || value === 'Editor') {
+      permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId);
+      this.resetNewType();
+      return;
+    }
+
+    permissions.setNewType(value);
+  }
+
+  userPicked(user: User) {
+    const { permissions, dashboardId } = this.props;
+    return permissions.addStoreItem({
+      userId: user.id,
+      userLogin: user.login,
+      permission: 1,
+      dashboardId: dashboardId,
+    });
+  }
+
+  teamPicked(team: Team) {
+    const { permissions, dashboardId } = this.props;
+    return permissions.addStoreItem({
+      teamId: team.id,
+      team: team.name,
+      permission: 1,
+      dashboardId: dashboardId,
+    });
+  }
+
+  render() {
+    const { permissions, folderInfo, backendSrv } = this.props;
+
+    return (
+      <div className="gf-form-group">
+        <PermissionsList
+          permissions={permissions.items}
+          removeItem={this.removeItem}
+          permissionChanged={this.permissionChanged}
+          fetching={permissions.fetching}
+          folderInfo={folderInfo}
+        />
+        <div className="gf-form-inline">
+          <form name="addPermission" className="gf-form-group">
+            <h6 className="muted">Add Permission For</h6>
+            <div className="gf-form-inline">
+              <div className="gf-form">
+                <div className="gf-form-select-wrapper">
+                  <select
+                    className="gf-form-input gf-size-auto"
+                    value={permissions.newType}
+                    onChange={this.typeChanged}
+                  >
+                    {aclTypes.map((option, idx) => {
+                      return (
+                        <option key={idx} value={option.value}>
+                          {option.text}
+                        </option>
+                      );
+                    })}
+                  </select>
+                </div>
+              </div>
+
+              {permissions.newType === 'User' ? (
+                <div className="gf-form">
+                  <UserPicker backendSrv={backendSrv} handlePicked={this.userPicked} />
+                </div>
+              ) : null}
+
+              {permissions.newType === 'Group' ? (
+                <div className="gf-form">
+                  <TeamPicker backendSrv={backendSrv} handlePicked={this.teamPicked} />
+                </div>
+              ) : null}
+            </div>
+          </form>
+          {permissions.error ? (
+            <div className="gf-form width-17">
+              <span ng-if="ctrl.error" className="text-error p-l-1">
+                <i className="fa fa-warning" />
+                {permissions.error}
+              </span>
+            </div>
+          ) : null}
+        </div>
+      </div>
+    );
+  }
+}
+
+export default Permissions;

+ 13 - 0
public/app/core/components/Permissions/PermissionsInfo.tsx

@@ -0,0 +1,13 @@
+import React from 'react';
+
+export default () => {
+  return (
+    <div className="">
+      <h5>What are Permissions?</h5>
+      <p>
+        An Access Control List (ACL) model is used to limit access to Dashboard Folders. A user or a Team can be
+        assigned permissions for a folder or for a single dashboard.
+      </p>
+    </div>
+  );
+};

+ 64 - 0
public/app/core/components/Permissions/PermissionsList.tsx

@@ -0,0 +1,64 @@
+import React, { Component } from 'react';
+import PermissionsListItem from './PermissionsListItem';
+import DisabledPermissionsListItem from './DisabledPermissionsListItem';
+import { observer } from 'mobx-react';
+import { FolderInfo } from './FolderInfo';
+
+export interface IProps {
+  permissions: any[];
+  removeItem: any;
+  permissionChanged: any;
+  fetching: boolean;
+  folderInfo?: FolderInfo;
+}
+
+@observer
+class PermissionsList extends Component<IProps, any> {
+  render() {
+    const { permissions, removeItem, permissionChanged, fetching, folderInfo } = this.props;
+
+    return (
+      <table className="filter-table gf-form-group">
+        <tbody>
+          <DisabledPermissionsListItem
+            key={0}
+            item={{
+              nameHtml: 'Everyone with <span class="query-keyword">Admin</span> Role',
+              permission: 4,
+              icon: 'fa fa-fw fa-street-view',
+            }}
+          />
+          {permissions.map((item, idx) => {
+            return (
+              <PermissionsListItem
+                key={idx + 1}
+                item={item}
+                itemIndex={idx}
+                removeItem={removeItem}
+                permissionChanged={permissionChanged}
+                folderInfo={folderInfo}
+              />
+            );
+          })}
+          {fetching === true && permissions.length < 1 ? (
+            <tr>
+              <td colSpan={4}>
+                <em>Loading permissions...</em>
+              </td>
+            </tr>
+          ) : null}
+
+          {fetching === false && permissions.length < 1 ? (
+            <tr>
+              <td colSpan={4}>
+                <em>No permissions are set. Will only be accessible by admins.</em>
+              </td>
+            </tr>
+          ) : null}
+        </tbody>
+      </table>
+    );
+  }
+}
+
+export default PermissionsList;

+ 65 - 0
public/app/core/components/Permissions/PermissionsListItem.tsx

@@ -0,0 +1,65 @@
+import React from 'react';
+import { observer } from 'mobx-react';
+import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
+import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
+
+const setClassNameHelper = inherited => {
+  return inherited ? 'gf-form-disabled' : '';
+};
+
+export default observer(({ item, removeItem, permissionChanged, itemIndex, folderInfo }) => {
+  const handleRemoveItem = evt => {
+    evt.preventDefault();
+    removeItem(itemIndex);
+  };
+
+  const handleChangePermission = permissionOption => {
+    permissionChanged(itemIndex, permissionOption.value, permissionOption.label);
+  };
+
+  const inheritedFromRoot = item.dashboardId === -1 && folderInfo && folderInfo.id === 0;
+
+  return (
+    <tr className={setClassNameHelper(item.inherited)}>
+      <td style={{ width: '100%' }}>
+        <i className={`fa--permissions-list ${item.icon}`} />
+        <span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
+      </td>
+      <td>
+        {item.inherited &&
+          folderInfo && (
+            <em className="muted no-wrap">
+              Inherited from folder{' '}
+              <a className="text-link" href={`dashboards/folder/${folderInfo.id}/${folderInfo.slug}/permissions`}>
+                {folderInfo.title}
+              </a>{' '}
+            </em>
+          )}
+        {inheritedFromRoot && <em className="muted no-wrap">Default Permission</em>}
+      </td>
+      <td className="query-keyword">Can</td>
+      <td>
+        <div className="gf-form">
+          <DescriptionPicker
+            optionsWithDesc={permissionOptions}
+            handlePicked={handleChangePermission}
+            value={item.permission}
+            disabled={item.inherited}
+            className={'gf-form-input--form-dropdown-right'}
+          />
+        </div>
+      </td>
+      <td>
+        {!item.inherited ? (
+          <a className="btn btn-danger btn-small" onClick={handleRemoveItem}>
+            <i className="fa fa-remove" />
+          </a>
+        ) : (
+          <button className="btn btn-inverse btn-small">
+            <i className="fa fa-lock" />
+          </button>
+        )}
+      </td>
+    </tr>
+  );
+});

+ 56 - 0
public/app/core/components/Picker/DescriptionOption.tsx

@@ -0,0 +1,56 @@
+import React, { Component } from 'react';
+
+export interface IProps {
+  onSelect: any;
+  onFocus: any;
+  option: any;
+  isFocused: any;
+  className: any;
+}
+
+class DescriptionOption extends Component<IProps, any> {
+  constructor(props) {
+    super(props);
+    this.handleMouseDown = this.handleMouseDown.bind(this);
+    this.handleMouseEnter = this.handleMouseEnter.bind(this);
+    this.handleMouseMove = this.handleMouseMove.bind(this);
+  }
+
+  handleMouseDown(event) {
+    event.preventDefault();
+    event.stopPropagation();
+    this.props.onSelect(this.props.option, event);
+  }
+
+  handleMouseEnter(event) {
+    this.props.onFocus(this.props.option, event);
+  }
+
+  handleMouseMove(event) {
+    if (this.props.isFocused) {
+      return;
+    }
+    this.props.onFocus(this.props.option, event);
+  }
+
+  render() {
+    const { option, children, className } = this.props;
+    return (
+      <button
+        onMouseDown={this.handleMouseDown}
+        onMouseEnter={this.handleMouseEnter}
+        onMouseMove={this.handleMouseMove}
+        title={option.title}
+        className={`description-picker-option__button btn btn-link ${className} width-19`}
+      >
+        <div className="gf-form">{children}</div>
+        <div className="gf-form">
+          <div className="muted width-17">{option.description}</div>
+          {className.indexOf('is-selected') > -1 && <i className="fa fa-check" aria-hidden="true" />}
+        </div>
+      </button>
+    );
+  }
+}
+
+export default DescriptionOption;

+ 48 - 0
public/app/core/components/Picker/DescriptionPicker.tsx

@@ -0,0 +1,48 @@
+import React, { Component } from 'react';
+import Select from 'react-select';
+import DescriptionOption from './DescriptionOption';
+
+export interface IProps {
+  optionsWithDesc: OptionWithDescription[];
+  handlePicked: (permission) => void;
+  value: number;
+  disabled: boolean;
+  className?: string;
+}
+
+export interface OptionWithDescription {
+  value: any;
+  label: string;
+  description: string;
+}
+
+class DescriptionPicker extends Component<IProps, any> {
+  constructor(props) {
+    super(props);
+    this.state = {};
+  }
+
+  render() {
+    const { optionsWithDesc, handlePicked, value, disabled, className } = this.props;
+
+    return (
+      <div className="permissions-picker">
+        <Select
+          value={value}
+          valueKey="value"
+          multi={false}
+          clearable={false}
+          labelKey="label"
+          options={optionsWithDesc}
+          onChange={handlePicked}
+          className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
+          optionComponent={DescriptionOption}
+          placeholder="Choose"
+          disabled={disabled}
+        />
+      </div>
+    );
+  }
+}
+
+export default DescriptionPicker;

+ 3 - 3
public/app/core/components/UserPicker/UserPickerOption.jest.tsx → public/app/core/components/Picker/PickerOption.jest.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import renderer from 'react-test-renderer';
-import UserPickerOption from './UserPickerOption';
+import PickerOption from './PickerOption';
 
 const model = {
   onSelect: () => {},
@@ -14,9 +14,9 @@ const model = {
   className: 'class-for-user-picker',
 };
 
-describe('UserPickerOption', () => {
+describe('PickerOption', () => {
   it('renders correctly', () => {
-    const tree = renderer.create(<UserPickerOption {...model} />).toJSON();
+    const tree = renderer.create(<PickerOption {...model} />).toJSON();
     expect(tree).toMatchSnapshot();
   });
 });

+ 0 - 0
public/app/core/components/UserPicker/UserPickerOption.tsx → public/app/core/components/Picker/PickerOption.tsx


+ 79 - 0
public/app/core/components/Picker/TeamPicker.tsx

@@ -0,0 +1,79 @@
+import React, { Component } from 'react';
+import Select from 'react-select';
+import PickerOption from './PickerOption';
+import withPicker from './withPicker';
+import { debounce } from 'lodash';
+
+export interface IProps {
+  backendSrv: any;
+  isLoading: boolean;
+  toggleLoading: any;
+  handlePicked: (user) => void;
+}
+
+export interface Team {
+  id: number;
+  label: string;
+  name: string;
+  avatarUrl: string;
+}
+
+class TeamPicker extends Component<IProps, any> {
+  debouncedSearch: any;
+  backendSrv: any;
+
+  constructor(props) {
+    super(props);
+    this.state = {};
+    this.search = this.search.bind(this);
+
+    this.debouncedSearch = debounce(this.search, 300, {
+      leading: true,
+      trailing: false,
+    });
+  }
+
+  search(query?: string) {
+    const { toggleLoading, backendSrv } = this.props;
+
+    toggleLoading(true);
+    return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
+      const teams = result.teams.map(team => {
+        return {
+          id: team.id,
+          label: team.name,
+          name: team.name,
+          avatarUrl: team.avatarUrl,
+        };
+      });
+
+      toggleLoading(false);
+      return { options: teams };
+    });
+  }
+
+  render() {
+    const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
+    const { isLoading, handlePicked } = this.props;
+
+    return (
+      <div className="user-picker">
+        <AsyncComponent
+          valueKey="id"
+          multi={false}
+          labelKey="label"
+          cache={false}
+          isLoading={isLoading}
+          loadOptions={this.debouncedSearch}
+          loadingPlaceholder="Loading..."
+          onChange={handlePicked}
+          className="width-8 gf-form-input gf-form-input--form-dropdown"
+          optionComponent={PickerOption}
+          placeholder="Choose"
+        />
+      </div>
+    );
+  }
+}
+
+export default withPicker(TeamPicker);

+ 1 - 2
public/app/core/components/UserPicker/UserPicker.jest.tsx → public/app/core/components/Picker/UserPicker.jest.tsx

@@ -8,8 +8,7 @@ const model = {
       return new Promise((resolve, reject) => {});
     },
   },
-  refreshList: () => {},
-  teamId: '1',
+  handlePicked: () => {},
 };
 
 describe('UserPicker', () => {

+ 79 - 0
public/app/core/components/Picker/UserPicker.tsx

@@ -0,0 +1,79 @@
+import React, { Component } from 'react';
+import Select from 'react-select';
+import PickerOption from './PickerOption';
+import withPicker from './withPicker';
+import { debounce } from 'lodash';
+
+export interface IProps {
+  backendSrv: any;
+  isLoading: boolean;
+  toggleLoading: any;
+  handlePicked: (user) => void;
+}
+
+export interface User {
+  id: number;
+  label: string;
+  avatarUrl: string;
+  login: string;
+}
+
+class UserPicker extends Component<IProps, any> {
+  debouncedSearch: any;
+  backendSrv: any;
+
+  constructor(props) {
+    super(props);
+    this.state = {};
+    this.search = this.search.bind(this);
+
+    this.debouncedSearch = debounce(this.search, 300, {
+      leading: true,
+      trailing: false,
+    });
+  }
+
+  search(query?: string) {
+    const { toggleLoading, backendSrv } = this.props;
+
+    toggleLoading(true);
+    return backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
+      const users = result.users.map(user => {
+        return {
+          id: user.id,
+          label: `${user.login} - ${user.email}`,
+          avatarUrl: user.avatarUrl,
+          login: user.login,
+        };
+      });
+      toggleLoading(false);
+      return { options: users };
+    });
+  }
+
+  render() {
+    const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
+    const { isLoading, handlePicked } = this.props;
+
+    return (
+      <div className="user-picker">
+        <AsyncComponent
+          valueKey="id"
+          multi={false}
+          labelKey="label"
+          cache={false}
+          isLoading={isLoading}
+          loadOptions={this.debouncedSearch}
+          loadingPlaceholder="Loading..."
+          noResultsText="No users found"
+          onChange={handlePicked}
+          className="width-8 gf-form-input gf-form-input--form-dropdown"
+          optionComponent={PickerOption}
+          placeholder="Choose"
+        />
+      </div>
+    );
+  }
+}
+
+export default withPicker(UserPicker);

+ 1 - 1
public/app/core/components/UserPicker/__snapshots__/UserPickerOption.jest.tsx.snap → public/app/core/components/Picker/__snapshots__/PickerOption.jest.tsx.snap

@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`UserPickerOption renders correctly 1`] = `
+exports[`PickerOption renders correctly 1`] = `
 <button
   className="user-picker-option__button btn btn-link class-for-user-picker"
   onMouseDown={[Function]}

+ 0 - 0
public/app/core/components/UserPicker/__snapshots__/UserPicker.jest.tsx.snap → public/app/core/components/Picker/__snapshots__/UserPicker.jest.tsx.snap


+ 32 - 0
public/app/core/components/Picker/withPicker.tsx

@@ -0,0 +1,32 @@
+import React, { Component } from 'react';
+
+export interface IProps {
+  backendSrv: any;
+  handlePicked: (data) => void;
+}
+
+export default function withPicker(WrappedComponent) {
+  return class WithPicker extends Component<IProps, any> {
+    constructor(props) {
+      super(props);
+      this.toggleLoading = this.toggleLoading.bind(this);
+
+      this.state = {
+        isLoading: false,
+      };
+    }
+
+    toggleLoading(isLoading) {
+      this.setState(prevState => {
+        return {
+          ...prevState,
+          isLoading: isLoading,
+        };
+      });
+    }
+
+    render() {
+      return <WrappedComponent toggleLoading={this.toggleLoading} isLoading={this.state.isLoading} {...this.props} />;
+    }
+  };
+}

+ 1 - 1
public/app/core/components/Tooltip/Popover.jest.tsx

@@ -6,7 +6,7 @@ describe('Popover', () => {
   it('renders correctly', () => {
     const tree = renderer
       .create(
-        <Popover placement="auto" content="Popover text">
+        <Popover className="test-class" placement="auto" content="Popover text">
           <button>Button with Popover</button>
         </Popover>
       )

+ 1 - 1
public/app/core/components/Tooltip/Tooltip.jest.tsx

@@ -6,7 +6,7 @@ describe('Tooltip', () => {
   it('renders correctly', () => {
     const tree = renderer
       .create(
-        <Tooltip placement="auto" content="Tooltip text">
+        <Tooltip className="test-class" placement="auto" content="Tooltip text">
           <a href="http://www.grafana.com">Link with tooltip</a>
         </Tooltip>
       )

+ 1 - 1
public/app/core/components/Tooltip/__snapshots__/Popover.jest.tsx.snap

@@ -2,7 +2,7 @@
 
 exports[`Popover renders correctly 1`] = `
 <div
-  className="popper__manager"
+  className="popper__manager test-class"
 >
   <div
     className="popper__target"

+ 1 - 1
public/app/core/components/Tooltip/__snapshots__/Tooltip.jest.tsx.snap

@@ -2,7 +2,7 @@
 
 exports[`Tooltip renders correctly 1`] = `
 <div
-  className="popper__manager"
+  className="popper__manager test-class"
 >
   <div
     className="popper__target"

+ 3 - 2
public/app/core/components/Tooltip/withTooltip.tsx

@@ -4,6 +4,7 @@ import { Manager, Popper, Arrow } from 'react-popper';
 interface IwithTooltipProps {
   placement?: string;
   content: string | ((props: any) => JSX.Element);
+  className?: string;
 }
 
 export default function withTooltip(WrappedComponent) {
@@ -39,10 +40,10 @@ export default function withTooltip(WrappedComponent) {
     }
 
     render() {
-      const { content } = this.props;
+      const { content, className } = this.props;
 
       return (
-        <Manager className="popper__manager">
+        <Manager className={`popper__manager ${className || ''}`}>
           <WrappedComponent {...this.props} tooltipSetState={this.setState} />
           {this.state.show ? (
             <Popper placement={this.state.placement} className="popper">

+ 0 - 108
public/app/core/components/UserPicker/UserPicker.tsx

@@ -1,108 +0,0 @@
-import React, { Component } from 'react';
-import { debounce } from 'lodash';
-import Select from 'react-select';
-import UserPickerOption from './UserPickerOption';
-
-export interface IProps {
-  backendSrv: any;
-  teamId: string;
-  refreshList: any;
-}
-
-export interface User {
-  id: number;
-  name: string;
-  login: string;
-  email: string;
-}
-
-class UserPicker extends Component<IProps, any> {
-  debouncedSearchUsers: any;
-  backendSrv: any;
-  teamId: string;
-  refreshList: any;
-
-  constructor(props) {
-    super(props);
-    this.backendSrv = this.props.backendSrv;
-    this.teamId = this.props.teamId;
-    this.refreshList = this.props.refreshList;
-
-    this.searchUsers = this.searchUsers.bind(this);
-    this.handleChange = this.handleChange.bind(this);
-    this.addUser = this.addUser.bind(this);
-    this.toggleLoading = this.toggleLoading.bind(this);
-
-    this.debouncedSearchUsers = debounce(this.searchUsers, 300, {
-      leading: true,
-      trailing: false,
-    });
-
-    this.state = {
-      multi: false,
-      isLoading: false,
-    };
-  }
-
-  handleChange(user) {
-    this.addUser(user.id);
-  }
-
-  toggleLoading(isLoading) {
-    this.setState(prevState => {
-      return {
-        ...prevState,
-        isLoading: isLoading,
-      };
-    });
-  }
-
-  addUser(userId) {
-    this.toggleLoading(true);
-    this.backendSrv.post(`/api/teams/${this.teamId}/members`, { userId: userId }).then(() => {
-      this.refreshList();
-      this.toggleLoading(false);
-    });
-  }
-
-  searchUsers(query) {
-    this.toggleLoading(true);
-
-    return this.backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
-      const users = result.users.map(user => {
-        return {
-          id: user.id,
-          label: `${user.login} - ${user.email}`,
-          avatarUrl: user.avatarUrl,
-        };
-      });
-      this.toggleLoading(false);
-      return { options: users };
-    });
-  }
-
-  render() {
-    const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
-
-    return (
-      <div className="user-picker">
-        <AsyncComponent
-          valueKey="id"
-          multi={this.state.multi}
-          labelKey="label"
-          cache={false}
-          isLoading={this.state.isLoading}
-          loadOptions={this.debouncedSearchUsers}
-          loadingPlaceholder="Loading..."
-          noResultsText="No users found"
-          onChange={this.handleChange}
-          className="width-8 gf-form-input gf-form-input--form-dropdown"
-          optionComponent={UserPickerOption}
-          placeholder="Choose"
-        />
-      </div>
-    );
-  }
-}
-
-export default UserPicker;

+ 7 - 6
public/app/core/components/manage_dashboards/manage_dashboards.html

@@ -5,11 +5,11 @@
       <i class="gf-form-input-icon fa fa-search"></i>
     </label>
     <div class="page-action-bar__spacer"></div>
-    <a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}">
+    <a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}" ng-if="ctrl.isEditor || ctrl.canSave">
       <i class="fa fa-plus"></i>
       Dashboard
     </a>
-    <a class="btn btn-success" href="dashboards/folder/new" ng-if="!ctrl.folderId">
+    <a class="btn btn-success" href="dashboards/folder/new" ng-if="!ctrl.folderId && ctrl.isEditor">
       <i class="fa fa-plus"></i>
       Folder
     </a>
@@ -95,22 +95,23 @@
       </div>
     </div>
     <div class="search-results-container">
-        <dashboard-search-results
+      <dashboard-search-results
         results="ctrl.sections"
         editable="true"
         on-selection-changed="ctrl.selectionChanged()"
-        on-tag-selected="ctrl.filterByTag($tag)" />
+        on-tag-selected="ctrl.filterByTag($tag)"
+      />
     </div>
   </div>
 </div>
 
-<div ng-if="ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
+<div ng-if="ctrl.canSave && ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
   <empty-list-cta model="{
     title: 'This folder doesn\'t have any dashboards yet',
     buttonIcon: 'gicon gicon-dashboard-new',
     buttonLink: 'dashboard/new?folderId={{ctrl.folderId}}',
     buttonTitle: 'Create Dashboard',
-    proTip: 'Add dashboards into your folder at ->',
+    proTip: 'Add/move dashboards to your folder at ->',
     proTipLink: 'dashboards',
     proTipLinkTitle: 'Manage dashboards',
     proTipTarget: ''

+ 60 - 20
public/app/core/components/manage_dashboards/manage_dashboards.ts

@@ -3,22 +3,49 @@ import coreModule from 'app/core/core_module';
 import appEvents from 'app/core/app_events';
 import { SearchSrv } from 'app/core/services/search_srv';
 
+class Query {
+  query: string;
+  mode: string;
+  tag: any[];
+  starred: boolean;
+  skipRecent: boolean;
+  skipStarred: boolean;
+  folderIds: number[];
+}
+
 export class ManageDashboardsCtrl {
   public sections: any[];
-  tagFilterOptions: any[];
-  selectedTagFilter: any;
-  query: any;
+
+  query: Query;
   navModel: any;
+
+  selectAllChecked = false;
+
+  // enable/disable actions depending on the folders or dashboards selected
   canDelete = false;
   canMove = false;
+
+  // filter variables
   hasFilters = false;
-  selectAllChecked = false;
+  tagFilterOptions: any[];
+  selectedTagFilter: any;
   starredFilterOptions = [{ text: 'Filter by Starred', disabled: true }, { text: 'Yes' }, { text: 'No' }];
   selectedStarredFilter: any;
+
+  // used when managing dashboards for a specific folder
   folderId?: number;
+  folderSlug?: string;
+
+  // if user can add new folders and/or add new dashboards
+  canSave: boolean;
+
+  // if user has editor role or higher
+  isEditor: boolean;
 
   /** @ngInject */
-  constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv) {
+  constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) {
+    this.isEditor = this.contextSrv.isEditor;
+
     this.query = {
       query: '',
       mode: 'tree',
@@ -26,6 +53,7 @@ export class ManageDashboardsCtrl {
       starred: false,
       skipRecent: true,
       skipStarred: true,
+      folderIds: [],
     };
 
     if (this.folderId) {
@@ -34,15 +62,26 @@ export class ManageDashboardsCtrl {
 
     this.selectedStarredFilter = this.starredFilterOptions[0];
 
-    this.getDashboards().then(() => {
-      this.getTags();
+    this.refreshList().then(() => {
+      this.initTagFilter();
     });
   }
 
-  getDashboards() {
-    return this.searchSrv.search(this.query).then(result => {
-      return this.initDashboardList(result);
-    });
+  refreshList() {
+    return this.searchSrv
+      .search(this.query)
+      .then(result => {
+        return this.initDashboardList(result);
+      })
+      .then(() => {
+        if (!this.folderSlug) {
+          return;
+        }
+
+        return this.backendSrv.getDashboard('db', this.folderSlug).then(dash => {
+          this.canSave = dash.meta.canSave;
+        });
+      });
   }
 
   initDashboardList(result: any) {
@@ -176,7 +215,7 @@ export class ManageDashboardsCtrl {
         appEvents.emit('alert-success', [header, msg]);
       }
 
-      this.getDashboards();
+      this.refreshList();
     });
   }
 
@@ -203,12 +242,12 @@ export class ManageDashboardsCtrl {
       modalClass: 'modal--narrow',
       model: {
         dashboards: selectedDashboards,
-        afterSave: this.getDashboards.bind(this),
+        afterSave: this.refreshList.bind(this),
       },
     });
   }
 
-  getTags() {
+  initTagFilter() {
     return this.searchSrv.getDashboardTags().then(results => {
       this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
       this.selectedTagFilter = this.tagFilterOptions[0];
@@ -220,11 +259,11 @@ export class ManageDashboardsCtrl {
       this.query.tag.push(tag);
     }
 
-    return this.getDashboards();
+    return this.refreshList();
   }
 
   onQueryChange() {
-    return this.getDashboards();
+    return this.refreshList();
   }
 
   onTagFilterChange() {
@@ -235,7 +274,7 @@ export class ManageDashboardsCtrl {
 
   removeTag(tag, evt) {
     this.query.tag = _.without(this.query.tag, tag);
-    this.getDashboards();
+    this.refreshList();
     if (evt) {
       evt.stopPropagation();
       evt.preventDefault();
@@ -244,13 +283,13 @@ export class ManageDashboardsCtrl {
 
   removeStarred() {
     this.query.starred = false;
-    return this.getDashboards();
+    return this.refreshList();
   }
 
   onStarredFilterChange() {
     this.query.starred = this.selectedStarredFilter.text === 'Yes';
     this.selectedStarredFilter = this.starredFilterOptions[0];
-    return this.getDashboards();
+    return this.refreshList();
   }
 
   onSelectAllChanged() {
@@ -272,7 +311,7 @@ export class ManageDashboardsCtrl {
     this.query.query = '';
     this.query.tag = [];
     this.query.starred = false;
-    this.getDashboards();
+    this.refreshList();
   }
 
   createDashboardUrl() {
@@ -295,6 +334,7 @@ export function manageDashboardsDirective() {
     controllerAs: 'ctrl',
     scope: {
       folderId: '=',
+      folderSlug: '=',
     },
   };
 }

+ 2 - 1
public/app/core/controllers/invited_ctrl.ts

@@ -10,8 +10,9 @@ export class InvitedCtrl {
     $scope.navModel = {
       main: {
         icon: 'gicon gicon-branding',
+        text: 'Invite',
         subTitle: 'Register your Grafana account',
-        breadcrumbs: [{ title: 'Login', url: '/login' }, { title: 'Invite' }],
+        breadcrumbs: [{ title: 'Login', url: '/login' }],
       },
     };
 

+ 2 - 1
public/app/core/controllers/reset_password_ctrl.ts

@@ -16,8 +16,9 @@ export class ResetPasswordCtrl {
     $scope.navModel = {
       main: {
         icon: 'gicon gicon-branding',
+        text: 'Reset Password',
         subTitle: 'Reset your Grafana password',
-        breadcrumbs: [{ title: 'Login', url: 'login' }, { title: 'Reset Password' }],
+        breadcrumbs: [{ title: 'Login', url: 'login' }],
       },
     };
 

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

@@ -1,5 +1,4 @@
 import './directives/dash_class';
-import './directives/dash_edit_link';
 import './directives/dropdown_typeahead';
 import './directives/metric_segment';
 import './directives/misc';

+ 0 - 150
public/app/core/directives/dash_edit_link.js

@@ -1,150 +0,0 @@
-define([
-  'jquery',
-  'angular',
-  '../core_module',
-  'lodash',
-],
-function ($, angular, coreModule, _) {
-  'use strict';
-
-  var editViewMap = {
-    'settings':    { src: 'public/app/features/dashboard/partials/settings.html'},
-    'annotations': { src: 'public/app/features/annotations/partials/editor.html'},
-    'templating':  { src: 'public/app/features/templating/partials/editor.html'},
-    'history':     { html: '<gf-dashboard-history dashboard="dashboard"></gf-dashboard-history>'},
-    'timepicker':  { src: 'public/app/features/dashboard/timepicker/dropdown.html' },
-    'import':      { html: '<dash-import dismiss="dismiss()"></dash-import>', isModal: true },
-    'permissions': { html: '<dash-acl-modal dismiss="dismiss()"></dash-acl-modal>', isModal: true },
-    'new-folder':  {
-      isModal: true,
-      html: '<folder-modal dismiss="dismiss()"></folder-modal>',
-      modalClass: 'modal--narrow'
-    }
-  };
-
-  coreModule.default.directive('dashEditorView', function($compile, $location, $rootScope) {
-    return {
-      restrict: 'A',
-      link: function(scope, elem) {
-        var editorScope;
-        var modalScope;
-        var lastEditView;
-
-        function hideEditorPane(hideToShowOtherView) {
-          if (editorScope) {
-            editorScope.dismiss(hideToShowOtherView);
-          }
-        }
-
-        function showEditorPane(evt, options) {
-          if (options.editview) {
-            _.defaults(options, editViewMap[options.editview]);
-          }
-
-          if (lastEditView && lastEditView === options.editview) {
-            hideEditorPane(false);
-            return;
-          }
-
-          hideEditorPane(true);
-
-          lastEditView = options.editview;
-          editorScope = options.scope ? options.scope.$new() : scope.$new();
-
-          editorScope.dismiss = function(hideToShowOtherView) {
-            if (modalScope) {
-              modalScope.dismiss();
-              modalScope = null;
-            }
-
-            editorScope.$destroy();
-            lastEditView = null;
-            editorScope = null;
-            elem.removeClass('dash-edit-view--open');
-
-            if (!hideToShowOtherView) {
-              setTimeout(function() {
-                elem.empty();
-              }, 250);
-            }
-
-            if (options.editview) {
-              var urlParams = $location.search();
-              if (options.editview === urlParams.editview) {
-                delete urlParams.editview;
-
-                // even though we always are in apply phase here
-                // some angular bug is causing location search updates to
-                // not happen always so this is a hack fix or that
-                setTimeout(function() {
-                  $rootScope.$apply(function() {
-                    $location.search(urlParams);
-                  });
-                });
-              }
-            }
-          };
-
-          if (options.isModal) {
-            modalScope = $rootScope.$new();
-            modalScope.$on("$destroy", function() {
-              editorScope.dismiss();
-            });
-
-            $rootScope.appEvent('show-modal', {
-              templateHtml: options.html,
-              scope: modalScope,
-              backdrop: 'static',
-              modalClass: options.modalClass,
-            });
-
-            return;
-          }
-
-          var view;
-          if (options.src)  {
-            view = angular.element(document.createElement('div'));
-            view.html('<div class="tabbed-view" ng-include="' + "'" + options.src + "'" + '"></div>');
-          } else {
-            view = angular.element(document.createElement('div'));
-            view.addClass('tabbed-view');
-            view.html(options.html);
-          }
-
-          $compile(view)(editorScope);
-
-          setTimeout(function() {
-            elem.empty();
-            elem.append(view);
-            setTimeout(function() {
-              elem.addClass('dash-edit-view--open');
-            }, 10);
-          }, 10);
-        }
-
-        scope.$watch("ctrl.dashboardViewState.state.editview", function(newValue, oldValue) {
-          if (newValue) {
-            showEditorPane(null, {editview: newValue});
-          } else if (oldValue) {
-            if (lastEditView === oldValue) {
-              hideEditorPane();
-            }
-          }
-        });
-
-        scope.$on("$destroy", hideEditorPane);
-
-        scope.onAppEvent('hide-dash-editor', function() {
-          hideEditorPane(false);
-        });
-
-        scope.onAppEvent('show-dash-editor', showEditorPane);
-
-        scope.onAppEvent('panel-fullscreen-enter', function() {
-          scope.appEvent('hide-dash-editor');
-        });
-      }
-    };
-  });
-});
-

+ 3 - 3
public/app/core/services/bridge_srv.ts

@@ -10,7 +10,7 @@ export class BridgeSrv {
   private fullPageReloadRoutes;
 
   /** @ngInject */
-  constructor(private $location, private $timeout, private $window, private $rootScope) {
+  constructor(private $location, private $timeout, private $window, private $rootScope, private $route) {
     this.appSubUrl = config.appSubUrl;
     this.fullPageReloadRoutes = ['/logout'];
   }
@@ -29,14 +29,14 @@ export class BridgeSrv {
     this.$rootScope.$on('$routeUpdate', (evt, data) => {
       let angularUrl = this.$location.url();
       if (store.view.currentUrl !== angularUrl) {
-        store.view.updatePathAndQuery(this.$location.path(), this.$location.search());
+        store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
       }
     });
 
     this.$rootScope.$on('$routeChangeSuccess', (evt, data) => {
       let angularUrl = this.$location.url();
       if (store.view.currentUrl !== angularUrl) {
-        store.view.updatePathAndQuery(this.$location.path(), this.$location.search());
+        store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
       }
     });
 

+ 1 - 1
public/app/core/specs/bridge_srv.jest.ts

@@ -10,7 +10,7 @@ describe('BridgeSrv', () => {
   let searchSrv;
 
   beforeEach(() => {
-    searchSrv = new BridgeSrv(null, null, null, null);
+    searchSrv = new BridgeSrv(null, null, null, null, null);
   });
 
   describe('With /subUrl as appSubUrl', () => {

+ 4 - 4
public/app/core/specs/manage_dashboards.jest.ts

@@ -49,7 +49,7 @@ describe('ManageDashboards', () => {
         },
       ];
       ctrl = createCtrlWithStubs(response);
-      return ctrl.getDashboards();
+      return ctrl.refreshList();
     });
 
     it('should set checked to false on all sections and children', () => {
@@ -88,7 +88,7 @@ describe('ManageDashboards', () => {
       ];
       ctrl = createCtrlWithStubs(response);
       ctrl.folderId = 410;
-      return ctrl.getDashboards();
+      return ctrl.refreshList();
     });
 
     it('should set hide header to true on section', () => {
@@ -137,7 +137,7 @@ describe('ManageDashboards', () => {
         ctrl.canMove = true;
         ctrl.canDelete = true;
         ctrl.selectAllChecked = true;
-        return ctrl.getDashboards();
+        return ctrl.refreshList();
       });
 
       it('should set checked to false on all sections and children', () => {
@@ -567,5 +567,5 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) {
     },
   };
 
-  return new ManageDashboardsCtrl({}, { getNav: () => {} }, <SearchSrv>searchSrvStub);
+  return new ManageDashboardsCtrl({}, { getNav: () => {} }, <SearchSrv>searchSrvStub, { isEditor: true });
 }

+ 5 - 1
public/app/core/utils/url.ts

@@ -12,7 +12,11 @@ export function toUrlParams(a) {
 
   let add = function(k, v) {
     v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
-    s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
+    if (typeof v !== 'boolean') {
+      s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
+    } else {
+      s[s.length] = encodeURIComponent(k);
+    }
   };
 
   let buildParams = function(prefix, obj) {

+ 1 - 1
public/app/features/annotations/annotation_tooltip.ts

@@ -54,7 +54,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv,
       `;
 
       // Show edit icon only for users with at least Editor role
-      if (event.id && contextSrv.isEditor) {
+      if (event.id && dashboard.meta.canEdit) {
         header += `
           <span class="pointer graph-annotation__edit-icon" ng-click="onEdit()">
             <i class="fa fa-pencil-square"></i>

+ 0 - 126
public/app/features/dashboard/acl/acl.html

@@ -1,126 +0,0 @@
-<div class="modal-body">
-  <div class="modal-header">
-    <h2 class="modal-header-title">
-      <i class="fa fa-lock"></i>
-      <span class="p-l-1">Permissions</span>
-    </h2>
-
-    <a class="modal-header-close" ng-click="ctrl.dismiss();">
-      <i class="fa fa-remove"></i>
-    </a>
-  </div>
-
-  <div class="modal-content">
-    <table class="filter-table gf-form-group">
-      <tr ng-repeat="acl in ctrl.items" ng-class="{'gf-form-disabled': acl.inherited}">
-        <td style="width: 100%;">
-          <i class="{{acl.icon}}"></i>
-          <span ng-bind-html="acl.nameHtml"></span>
-        </td>
-        <td>
-          <em class="muted no-wrap" ng-show="acl.inherited">Inherited from folder</em>
-        </td>
-        <td class="query-keyword">Can</td>
-        <td>
-          <div class="gf-form-select-wrapper">
-            <select class="gf-form-input gf-size-auto" ng-model="acl.permission" ng-options="p.value as p.text for p in ctrl.permissionOptions" ng-change="ctrl.permissionChanged(acl)" ng-disabled="acl.inherited"></select>
-          </div>
-        </td>
-        <td>
-          <a class="btn btn-inverse btn-small" ng-click="ctrl.removeItem($index)" ng-hide="acl.inherited">
-            <i class="fa fa-remove"></i>
-          </a>
-        </td>
-      </tr>
-      <tr ng-show="ctrl.aclItems.length === 0">
-        <td colspan="4">
-          <em>No permissions. Will only be accessible by admins.</em>
-        </td>
-      </tr>
-    </table>
-
-    <div class="gf-form-inline">
-      <form name="addPermission" class="gf-form-group">
-        <h6 class="muted">Add Permission For</h6>
-        <div class="gf-form-inline">
-          <div class="gf-form">
-            <div class="gf-form-select-wrapper">
-              <select class="gf-form-input gf-size-auto" ng-model="ctrl.newType" ng-options="p.value as p.text for p in ctrl.aclTypes"  ng-change="ctrl.typeChanged()"></select>
-            </div>
-          </div>
-          <div class="gf-form" ng-show="ctrl.newType === 'User'">
-            <user-picker user-picked="ctrl.userPicked($user)"></user-picker>
-          </div>
-          <div class="gf-form" ng-show="ctrl.newType === 'Group'">
-            <team-picker team-picked="ctrl.groupPicked($group)"></team-picker>
-          </div>
-        </div>
-      </form>
-      <div class="gf-form width-17">
-        <span ng-if="ctrl.error" class="text-error p-l-1">
-          <i class="fa fa-warning"></i>
-          {{ctrl.error}}
-        </span>
-      </div>
-    </div>
-
-    <div class="gf-form-button-row text-center">
-      <button type="button" class="btn btn-danger" ng-disabled="!ctrl.canUpdate" ng-click="ctrl.update()">
-        Update Permissions
-      </button>
-      <a class="btn-text" ng-click="ctrl.dismiss();">Close</a>
-    </div>
-  </div>
-</div>
-
-  <!-- <br> -->
-  <!-- <br> -->
-  <!-- <br> -->
-  <!--  -->
-  <!-- <div class="permissionlist"> -->
-  <!--   <div class="permissionlist__section"> -->
-  <!--     <div class="permissionlist__section&#45;header"> -->
-  <!--       <h6>Permissions</h6> -->
-  <!--     </div> -->
-  <!--     <table class="filter&#45;table form&#45;inline"> -->
-  <!--       <thead> -->
-  <!--         <tr> -->
-  <!--           <th style="width: 50px;"></th> -->
-  <!--           <th>Name</th> -->
-  <!--           <th style="width: 220px;">Permission</th> -->
-  <!--           <th style="width: 120px"></th> -->
-  <!--         </tr> -->
-  <!--       </thead> -->
-  <!--       <tbody> -->
-  <!--         <tr ng&#45;repeat="permission in ctrl.userPermissions" class="permissionlist__item"> -->
-  <!--           <td><i class="fa fa&#45;fw fa&#45;user"></i></td> -->
-  <!--           <td>{{permission.userLogin}}</td> -->
-  <!--           <td class="text&#45;right"> -->
-  <!--             <a ng&#45;click="ctrl.removePermission(permission)" class="btn btn&#45;danger btn&#45;small"> -->
-  <!--               <i class="fa fa&#45;remove"></i> -->
-  <!--             </a> -->
-  <!--           </td> -->
-  <!--         </tr> -->
-  <!--         <tr ng&#45;repeat="permission in ctrl.teamPermissions" class="permissionlist__item"> -->
-  <!--           <td><i class="fa fa&#45;fw fa&#45;users"></i></td> -->
-  <!--           <td>{{permission.team}}</td> -->
-  <!--           <td><select class="gf&#45;form&#45;input gf&#45;size&#45;auto" ng&#45;model="permission.permissions" ng&#45;options="p.value as p.text for p in ctrl.permissionTypeOptions" ng&#45;change="ctrl.updatePermission(permission)"></select></td> -->
-  <!--           <td class="text&#45;right"> -->
-  <!--             <a ng&#45;click="ctrl.removePermission(permission)" class="btn btn&#45;danger btn&#45;small"> -->
-  <!--               <i class="fa fa&#45;remove"></i> -->
-  <!--             </a> -->
-  <!--           </td> -->
-  <!--         </tr> -->
-  <!--         <tr ng&#45;repeat="role in ctrl.roles" class="permissionlist__item"> -->
-  <!--           <td></td> -->
-  <!--           <td>{{role.name}}</td> -->
-  <!--           <td><select class="gf&#45;form&#45;input gf&#45;size&#45;auto" ng&#45;model="role.permissions" ng&#45;options="p.value as p.text for p in ctrl.roleOptions" ng&#45;change="ctrl.updatePermission(role)"></select></td> -->
-  <!--           <td class="text&#45;right"> -->
-  <!--  -->
-  <!--           </td> -->
-  <!--         </tr> -->
-  <!--       </tbody> -->
-  <!--     </table> -->
-  <!--   </div> -->
-  <!--   </div> -->
-  <!-- </div> -->

+ 0 - 201
public/app/features/dashboard/acl/acl.ts

@@ -1,201 +0,0 @@
-import coreModule from 'app/core/core_module';
-import _ from 'lodash';
-
-export class AclCtrl {
-  dashboard: any;
-  items: DashboardAcl[];
-  permissionOptions = [{ value: 1, text: 'View' }, { value: 2, text: 'Edit' }, { value: 4, text: 'Admin' }];
-  aclTypes = [
-    { value: 'Group', text: 'Team' },
-    { value: 'User', text: 'User' },
-    { value: 'Viewer', text: 'Everyone With Viewer Role' },
-    { value: 'Editor', text: 'Everyone With Editor Role' },
-  ];
-
-  dismiss: () => void;
-  newType: string;
-  canUpdate: boolean;
-  error: string;
-
-  readonly duplicateError = 'This permission exists already.';
-
-  /** @ngInject */
-  constructor(private backendSrv, dashboardSrv, private $sce, private $scope) {
-    this.items = [];
-    this.resetNewType();
-    this.dashboard = dashboardSrv.getCurrent();
-    this.get(this.dashboard.id);
-  }
-
-  resetNewType() {
-    this.newType = 'Group';
-  }
-
-  get(dashboardId: number) {
-    return this.backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`).then(result => {
-      this.items = _.map(result, this.prepareViewModel.bind(this));
-      this.sortItems();
-    });
-  }
-
-  sortItems() {
-    this.items = _.orderBy(this.items, ['sortRank', 'sortName'], ['desc', 'asc']);
-  }
-
-  prepareViewModel(item: DashboardAcl): DashboardAcl {
-    item.inherited = !this.dashboard.meta.isFolder && this.dashboard.id !== item.dashboardId;
-    item.sortRank = 0;
-
-    if (item.userId > 0) {
-      item.icon = 'fa fa-fw fa-user';
-      item.nameHtml = this.$sce.trustAsHtml(item.userLogin);
-      item.sortName = item.userLogin;
-      item.sortRank = 10;
-    } else if (item.teamId > 0) {
-      item.icon = 'fa fa-fw fa-users';
-      item.nameHtml = this.$sce.trustAsHtml(item.team);
-      item.sortName = item.team;
-      item.sortRank = 20;
-    } else if (item.role) {
-      item.icon = 'fa fa-fw fa-street-view';
-      item.nameHtml = this.$sce.trustAsHtml(`Everyone with <span class="query-keyword">${item.role}</span> Role`);
-      item.sortName = item.role;
-      item.sortRank = 30;
-      if (item.role === 'Viewer') {
-        item.sortRank += 1;
-      }
-    }
-
-    if (item.inherited) {
-      item.sortRank += 100;
-    }
-
-    return item;
-  }
-
-  update() {
-    var updated = [];
-    for (let item of this.items) {
-      if (item.inherited) {
-        continue;
-      }
-      updated.push({
-        id: item.id,
-        userId: item.userId,
-        teamId: item.teamId,
-        role: item.role,
-        permission: item.permission,
-      });
-    }
-
-    return this.backendSrv.post(`/api/dashboards/id/${this.dashboard.id}/acl`, { items: updated }).then(() => {
-      return this.dismiss();
-    });
-  }
-
-  typeChanged() {
-    if (this.newType === 'Viewer' || this.newType === 'Editor') {
-      this.addNewItem({ permission: 1, role: this.newType });
-      this.canUpdate = true;
-      this.resetNewType();
-    }
-  }
-
-  permissionChanged() {
-    this.canUpdate = true;
-  }
-
-  addNewItem(item) {
-    if (!this.isValid(item)) {
-      return;
-    }
-    this.error = '';
-
-    item.dashboardId = this.dashboard.id;
-
-    this.items.push(this.prepareViewModel(item));
-    this.sortItems();
-
-    this.canUpdate = true;
-  }
-
-  isValid(item) {
-    const dupe = _.find(this.items, it => {
-      return this.isDuplicate(it, item);
-    });
-
-    if (dupe) {
-      this.error = this.duplicateError;
-      return false;
-    }
-
-    return true;
-  }
-
-  isDuplicate(origItem, newItem) {
-    if (origItem.inherited) {
-      return false;
-    }
-
-    return (
-      (origItem.role && newItem.role && origItem.role === newItem.role) ||
-      (origItem.userId && newItem.userId && origItem.userId === newItem.userId) ||
-      (origItem.teamId && newItem.teamId && origItem.teamId === newItem.teamId)
-    );
-  }
-
-  userPicked(user) {
-    this.addNewItem({ userId: user.id, userLogin: user.login, permission: 1 });
-    this.$scope.$broadcast('user-picker-reset');
-  }
-
-  groupPicked(group) {
-    this.addNewItem({ teamId: group.id, team: group.name, permission: 1 });
-    this.$scope.$broadcast('team-picker-reset');
-  }
-
-  removeItem(index) {
-    this.items.splice(index, 1);
-    this.canUpdate = true;
-  }
-}
-
-export function dashAclModal() {
-  return {
-    restrict: 'E',
-    templateUrl: 'public/app/features/dashboard/acl/acl.html',
-    controller: AclCtrl,
-    bindToController: true,
-    controllerAs: 'ctrl',
-    scope: {
-      dismiss: '&',
-    },
-  };
-}
-
-export interface FormModel {
-  dashboardId: number;
-  userId?: number;
-  teamId?: number;
-  PermissionType: number;
-}
-
-export interface DashboardAcl {
-  id?: number;
-  dashboardId?: number;
-  userId?: number;
-  userLogin?: string;
-  userEmail?: string;
-  teamId?: number;
-  team?: string;
-  permission?: number;
-  permissionName?: string;
-  role?: string;
-  icon?: string;
-  nameHtml?: string;
-  inherited?: boolean;
-  sortName?: string;
-  sortRank?: number;
-}
-
-coreModule.directive('dashAclModal', dashAclModal);

+ 0 - 188
public/app/features/dashboard/acl/specs/acl_specs.ts

@@ -1,188 +0,0 @@
-import { describe, beforeEach, it, expect, sinon, angularMocks } from 'test/lib/common';
-import { AclCtrl } from '../acl';
-
-describe('AclCtrl', () => {
-  const ctx: any = {};
-  const backendSrv = {
-    get: sinon.stub().returns(Promise.resolve([])),
-    post: sinon.stub().returns(Promise.resolve([])),
-  };
-
-  const dashboardSrv = {
-    getCurrent: sinon.stub().returns({ id: 1, meta: { isFolder: false } }),
-  };
-
-  beforeEach(angularMocks.module('grafana.core'));
-  beforeEach(angularMocks.module('grafana.controllers'));
-
-  beforeEach(
-    angularMocks.inject(($rootScope, $controller, $q, $compile) => {
-      ctx.$q = $q;
-      ctx.scope = $rootScope.$new();
-      AclCtrl.prototype.dashboard = { dashboard: { id: 1 } };
-      ctx.ctrl = $controller(
-        AclCtrl,
-        {
-          $scope: ctx.scope,
-          backendSrv: backendSrv,
-          dashboardSrv: dashboardSrv,
-        },
-        {
-          dismiss: () => {
-            return;
-          },
-        }
-      );
-    })
-  );
-
-  describe('when permissions are added', () => {
-    beforeEach(() => {
-      backendSrv.get.reset();
-      backendSrv.post.reset();
-
-      const userItem = {
-        id: 2,
-        login: 'user2',
-      };
-
-      ctx.ctrl.userPicked(userItem);
-
-      const teamItem = {
-        id: 2,
-        name: 'ug1',
-      };
-
-      ctx.ctrl.groupPicked(teamItem);
-
-      ctx.ctrl.newType = 'Editor';
-      ctx.ctrl.typeChanged();
-
-      ctx.ctrl.newType = 'Viewer';
-      ctx.ctrl.typeChanged();
-    });
-
-    it('should sort the result by role, team and user', () => {
-      expect(ctx.ctrl.items[0].role).to.eql('Viewer');
-      expect(ctx.ctrl.items[1].role).to.eql('Editor');
-      expect(ctx.ctrl.items[2].teamId).to.eql(2);
-      expect(ctx.ctrl.items[3].userId).to.eql(2);
-    });
-
-    it('should save permissions to db', done => {
-      ctx.ctrl.update().then(() => {
-        done();
-      });
-
-      expect(backendSrv.post.getCall(0).args[0]).to.eql('/api/dashboards/id/1/acl');
-      expect(backendSrv.post.getCall(0).args[1].items[0].role).to.eql('Viewer');
-      expect(backendSrv.post.getCall(0).args[1].items[0].permission).to.eql(1);
-      expect(backendSrv.post.getCall(0).args[1].items[1].role).to.eql('Editor');
-      expect(backendSrv.post.getCall(0).args[1].items[1].permission).to.eql(1);
-      expect(backendSrv.post.getCall(0).args[1].items[2].teamId).to.eql(2);
-      expect(backendSrv.post.getCall(0).args[1].items[2].permission).to.eql(1);
-      expect(backendSrv.post.getCall(0).args[1].items[3].userId).to.eql(2);
-      expect(backendSrv.post.getCall(0).args[1].items[3].permission).to.eql(1);
-    });
-  });
-
-  describe('when duplicate role permissions are added', () => {
-    beforeEach(() => {
-      backendSrv.get.reset();
-      backendSrv.post.reset();
-      ctx.ctrl.items = [];
-
-      ctx.ctrl.newType = 'Editor';
-      ctx.ctrl.typeChanged();
-
-      ctx.ctrl.newType = 'Editor';
-      ctx.ctrl.typeChanged();
-    });
-
-    it('should throw a validation error', () => {
-      expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
-    });
-
-    it('should not add the duplicate permission', () => {
-      expect(ctx.ctrl.items.length).to.eql(1);
-    });
-  });
-
-  describe('when duplicate user permissions are added', () => {
-    beforeEach(() => {
-      backendSrv.get.reset();
-      backendSrv.post.reset();
-      ctx.ctrl.items = [];
-
-      const userItem = {
-        id: 2,
-        login: 'user2',
-      };
-
-      ctx.ctrl.userPicked(userItem);
-      ctx.ctrl.userPicked(userItem);
-    });
-
-    it('should throw a validation error', () => {
-      expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
-    });
-
-    it('should not add the duplicate permission', () => {
-      expect(ctx.ctrl.items.length).to.eql(1);
-    });
-  });
-
-  describe('when duplicate team permissions are added', () => {
-    beforeEach(() => {
-      backendSrv.get.reset();
-      backendSrv.post.reset();
-      ctx.ctrl.items = [];
-
-      const teamItem = {
-        id: 2,
-        name: 'ug1',
-      };
-
-      ctx.ctrl.groupPicked(teamItem);
-      ctx.ctrl.groupPicked(teamItem);
-    });
-
-    it('should throw a validation error', () => {
-      expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
-    });
-
-    it('should not add the duplicate permission', () => {
-      expect(ctx.ctrl.items.length).to.eql(1);
-    });
-  });
-
-  describe('when one inherited and one not inherited team permission are added', () => {
-    beforeEach(() => {
-      backendSrv.get.reset();
-      backendSrv.post.reset();
-      ctx.ctrl.items = [];
-
-      const inheritedTeamItem = {
-        id: 2,
-        name: 'ug1',
-        dashboardId: -1,
-      };
-
-      ctx.ctrl.items.push(inheritedTeamItem);
-
-      const teamItem = {
-        id: 2,
-        name: 'ug1',
-      };
-      ctx.ctrl.groupPicked(teamItem);
-    });
-
-    it('should not throw a validation error', () => {
-      expect(ctx.ctrl.error).to.eql('');
-    });
-
-    it('should add both permissions', () => {
-      expect(ctx.ctrl.items.length).to.eql(2);
-    });
-  });
-});

+ 0 - 3
public/app/features/dashboard/all.ts

@@ -23,7 +23,6 @@ import './repeat_option/repeat_option';
 import './dashgrid/DashboardGridDirective';
 import './dashgrid/PanelLoader';
 import './dashgrid/RowOptions';
-import './acl/acl';
 import './folder_picker/folder_picker';
 import './move_to_folder_modal/move_to_folder';
 import './settings/settings';
@@ -31,14 +30,12 @@ import './settings/settings';
 import coreModule from 'app/core/core_module';
 import { DashboardListCtrl } from './dashboard_list_ctrl';
 import { FolderDashboardsCtrl } from './folder_dashboards_ctrl';
-import { FolderPermissionsCtrl } from './folder_permissions_ctrl';
 import { FolderSettingsCtrl } from './folder_settings_ctrl';
 import { DashboardImportCtrl } from './dashboard_import_ctrl';
 import { CreateFolderCtrl } from './create_folder_ctrl';
 
 coreModule.controller('DashboardListCtrl', DashboardListCtrl);
 coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl);
-coreModule.controller('FolderPermissionsCtrl', FolderPermissionsCtrl);
 coreModule.controller('FolderSettingsCtrl', FolderSettingsCtrl);
 coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);
 coreModule.controller('CreateFolderCtrl', CreateFolderCtrl);

+ 9 - 0
public/app/features/dashboard/dashboard_model.ts

@@ -571,6 +571,7 @@ export class DashboardModel {
 
     if (row.collapsed) {
       row.collapsed = false;
+      let hasRepeat = false;
 
       if (row.panels.length > 0) {
         // Use first panel to figure out if it was moved or pushed
@@ -591,6 +592,10 @@ export class DashboardModel {
           // update insert post and y max
           insertPos += 1;
           yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h);
+
+          if (panel.repeat) {
+            hasRepeat = true;
+          }
         }
 
         const pushDownAmount = yMax - row.gridPos.y;
@@ -601,6 +606,10 @@ export class DashboardModel {
         }
 
         row.panels = [];
+
+        if (hasRepeat) {
+          this.processRepeats();
+        }
       }
 
       // sort panels

+ 1 - 1
public/app/features/dashboard/dashboard_srv.ts

@@ -85,7 +85,7 @@ export class DashboardSrv {
 
   save(clone, options) {
     options = options || {};
-    options.folderId = options.folderId || this.dash.meta.folderId;
+    options.folderId = options.folderId || this.dash.meta.folderId || clone.folderId;
 
     return this.backendSrv
       .saveDashboard(clone, options)

+ 11 - 17
public/app/features/dashboard/folder_page_loader.ts

@@ -1,5 +1,3 @@
-import _ from 'lodash';
-
 export class FolderPageLoader {
   constructor(private backendSrv) {}
 
@@ -11,7 +9,7 @@ export class FolderPageLoader {
         subTitle: 'Manage folder dashboards & permissions',
         url: '',
         text: '',
-        breadcrumbs: [{ title: 'Dashboards', url: 'dashboards' }, { title: ' ' }],
+        breadcrumbs: [{ title: 'Dashboards', url: 'dashboards' }],
         children: [
           {
             active: activeChildId === 'manage-folder-dashboards',
@@ -41,25 +39,21 @@ export class FolderPageLoader {
     return this.backendSrv.getDashboardByUid(uid).then(result => {
       ctrl.folderId = result.dashboard.id;
       const folderTitle = result.dashboard.title;
-      ctrl.navModel.main.text = '';
-      ctrl.navModel.main.breadcrumbs = [{ title: 'Dashboards', url: 'dashboards' }, { title: folderTitle }];
-
       const folderUrl = result.meta.url;
+      ctrl.navModel.main.text = folderTitle;
 
-      const dashTab = _.find(ctrl.navModel.main.children, {
-        id: 'manage-folder-dashboards',
-      });
+      const dashTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-dashboards');
       dashTab.url = folderUrl;
 
-      const permTab = _.find(ctrl.navModel.main.children, {
-        id: 'manage-folder-permissions',
-      });
-      permTab.url = folderUrl + '/permissions';
+      if (result.meta.canAdmin) {
+        const permTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-permissions');
+        permTab.url = folderUrl + '/permissions';
 
-      const settingsTab = _.find(ctrl.navModel.main.children, {
-        id: 'manage-folder-settings',
-      });
-      settingsTab.url = folderUrl + '/settings';
+        const settingsTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-settings');
+        settingsTab.url = folderUrl + '/settings';
+      } else {
+        ctrl.navModel.main.children = [dashTab];
+      }
 
       return result;
     });

+ 5 - 0
public/app/features/dashboard/folder_permissions_ctrl.ts

@@ -4,6 +4,8 @@ export class FolderPermissionsCtrl {
   navModel: any;
   folderId: number;
   uid: string;
+  dashboard: any;
+  meta: any;
 
   /** @ngInject */
   constructor(private backendSrv, navModelSrv, private $routeParams, $location) {
@@ -14,6 +16,9 @@ export class FolderPermissionsCtrl {
         if ($location.path() !== folder.meta.url) {
           $location.path(`${folder.meta.url}/permissions`).replace();
         }
+
+        this.dashboard = folder.dashboard;
+        this.meta = folder.meta;
       });
     }
   }

+ 4 - 6
public/app/features/dashboard/folder_picker/folder_picker.ts

@@ -30,12 +30,7 @@ export class FolderPickerCtrl {
   }
 
   getOptions(query) {
-    var params = {
-      query: query,
-      type: 'dash-folder',
-    };
-
-    return this.backendSrv.search(params).then(result => {
+    return this.backendSrv.get('api/dashboards/folders', { query: query }).then(result => {
       if (
         query === '' ||
         query.toLowerCase() === 'r' ||
@@ -120,6 +115,9 @@ export class FolderPickerCtrl {
     if (this.initialFolderId && this.initialFolderId > 0) {
       this.getOptions('').then(result => {
         this.folder = _.find(result, { value: this.initialFolderId });
+        if (!this.folder) {
+          this.folder = { text: this.initialTitle, value: this.initialFolderId };
+        }
         this.onFolderLoad();
       });
     } else {

+ 2 - 2
public/app/features/dashboard/partials/folder_dashboards.html

@@ -1,5 +1,5 @@
 <page-header ng-if="ctrl.navModel" model="ctrl.navModel"></page-header>
 
 <div class="page-container page-body">
-    <manage-dashboards ng-if="ctrl.folderId" folder-id="ctrl.folderId" />
-</div>
+    <manage-dashboards ng-if="ctrl.folderId && ctrl.folderSlug" folder-id="ctrl.folderId" folder-slug="ctrl.folderSlug" />
+</div>

+ 3 - 3
public/app/features/dashboard/partials/folder_permissions.html

@@ -1,7 +1,7 @@
 <page-header model="ctrl.navModel"></page-header>
 
 <div class="page-container page-body">
-	<h2 class="page-sub-heading">
-		Coming soon! Permissions will be added in Grafana 5.0 beta.
-	</h2>
+  <dashboard-permissions ng-if="ctrl.dashboard && ctrl.meta"
+    dashboardId="ctrl.dashboard.id"
+  />
 </div>

+ 10 - 0
public/app/features/dashboard/settings/settings.html

@@ -95,6 +95,16 @@
 	</div>
 </div>
 
+<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'permissions'" >
+  <dashboard-permissions ng-if="ctrl.dashboard"
+    dashboardId="ctrl.dashboard.id"
+    backendSrv="ctrl.backendSrv"
+    folderTitle="ctrl.dashboard.meta.folderTitle"
+    folderSlug="ctrl.dashboard.meta.folderSlug"
+    folderId="ctrl.dashboard.meta.folderId"
+  />
+</div>
+
 <div class="dashboard-settings__content" ng-if="ctrl.viewId === '404'">
   <h3 class="dashboard-settings__header">Settings view not found</h3>
 

+ 9 - 0
public/app/features/dashboard/settings/settings.ts

@@ -70,6 +70,14 @@ export class SettingsCtrl {
       });
     }
 
+    if (this.dashboard.id && this.dashboard.meta.canAdmin) {
+      this.sections.push({
+        title: 'Permissions',
+        id: 'permissions',
+        icon: 'fa fa-fw fa-lock',
+      });
+    }
+
     if (this.dashboard.meta.canMakeEditable) {
       this.sections.push({
         title: 'General',
@@ -183,6 +191,7 @@ export class SettingsCtrl {
   onFolderChange(folder) {
     this.dashboard.meta.folderId = folder.id;
     this.dashboard.meta.folderTitle = folder.title;
+    this.dashboard.meta.folderSlug = folder.slug;
   }
 }
 

+ 51 - 0
public/app/features/dashboard/specs/repeat.jest.ts

@@ -4,6 +4,57 @@ import { expect } from 'test/lib/common';
 
 jest.mock('app/core/services/context_srv', () => ({}));
 
+describe('given dashboard with panel repeat', function() {
+  var dashboard;
+
+  beforeEach(function() {
+    let dashboardJSON = {
+      panels: [
+        { id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } },
+        { id: 2, repeat: 'apps', repeatDirection: 'h', gridPos: { x: 0, y: 1, h: 2, w: 8 } },
+      ],
+      templating: {
+        list: [
+          {
+            name: 'apps',
+            current: {
+              text: 'se1, se2, se3',
+              value: ['se1', 'se2', 'se3'],
+            },
+            options: [
+              { text: 'se1', value: 'se1', selected: true },
+              { text: 'se2', value: 'se2', selected: true },
+              { text: 'se3', value: 'se3', selected: true },
+              { text: 'se4', value: 'se4', selected: false },
+            ],
+          },
+        ],
+      },
+    };
+    dashboard = new DashboardModel(dashboardJSON);
+    dashboard.processRepeats();
+  });
+
+  it('should repeat panels when row is expanding', function() {
+    expect(dashboard.panels.length).toBe(4);
+
+    // toggle row
+    dashboard.toggleRow(dashboard.panels[0]);
+    expect(dashboard.panels.length).toBe(1);
+
+    // change variable
+    dashboard.templating.list[0].options[2].selected = false;
+    dashboard.templating.list[0].current = {
+      text: 'se1, se2',
+      value: ['se1', 'se2'],
+    };
+
+    // toggle row back
+    dashboard.toggleRow(dashboard.panels[0]);
+    expect(dashboard.panels.length).toBe(3);
+  });
+});
+
 describe('given dashboard with panel repeat in horizontal direction', function() {
   var dashboard;
 

+ 1 - 1
public/app/features/dashlinks/editor.html

@@ -17,7 +17,7 @@
       <div class="grafana-info-box">
         <h5>What are Dashboard Links?</h5>
         <p>
-					Dashboad Links allow you to place links to other dashboards and web sites directly in below the dashboard header.
+					Dashboard Links allow you to place links to other dashboards and web sites directly in below the dashboard header.
         </p>
       </div>
     </div>

+ 6 - 1
public/app/features/org/partials/team_details.html

@@ -29,7 +29,11 @@
 		<form name="ctrl.addMemberForm" class="gf-form-group">
       <div class="gf-form">
         <span class="gf-form-label width-10">Add member</span>
-				<select-user-picker backendSrv="ctrl.backendSrv" teamId="ctrl.$routeParams.id" refreshList="ctrl.get" teamMembers="ctrl.teamMembers"></select-user-picker>
+				<!--
+				Old picker
+				<user-picker user-picked="ctrl.userPicked($user)"></user-picker>
+				-->
+				<select-user-picker handlePicked="ctrl.userPicked" backendSrv="ctrl.backendSrv"></select-user-picker>
       </div>
     </form>
 
@@ -60,3 +64,4 @@
 				This team has no members yet.
 			</em>
 		</div>
+

+ 1 - 0
public/app/features/org/team_details_ctrl.ts

@@ -8,6 +8,7 @@ export default class TeamDetailsCtrl {
   /** @ngInject **/
   constructor(private $scope, private backendSrv, private $routeParams, navModelSrv) {
     this.navModel = navModelSrv.getNav('cfg', 'teams', 0);
+    this.userPicked = this.userPicked.bind(this);
     this.get = this.get.bind(this);
     this.get();
   }

+ 1 - 0
public/app/features/playlist/playlist_search.ts

@@ -12,6 +12,7 @@ export class PlaylistSearchCtrl {
 
     $timeout(() => {
       this.query.query = '';
+      this.query.type = 'dash-db';
       this.searchDashboards();
     }, 100);
   }

+ 1 - 0
public/app/features/plugins/all.ts

@@ -3,6 +3,7 @@ import './plugin_page_ctrl';
 import './plugin_list_ctrl';
 import './import_list/import_list';
 import './ds_edit_ctrl';
+import './ds_dashboards_ctrl';
 import './ds_list_ctrl';
 import './datasource_srv';
 import './plugin_component';

Some files were not shown because too many files changed in this diff