diff --git a/pkg/api/api.go b/pkg/api/api.go
index fd4160f9993..e1cb22dcae3 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -89,6 +89,10 @@ func Register(r *macaron.Macaron) {
r.Get("/users", wrap(GetOrgUsersForCurrentOrg))
r.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUserForCurrentOrg))
r.Delete("/users/:userId", wrap(RemoveOrgUserForCurrentOrg))
+
+ // invites
+ r.Get("/invites", wrap(GetPendingOrgInvites))
+ r.Post("/invites", bind(dtos.AddInviteForm{}), wrap(AddOrgInvite))
}, regOrgAdmin)
// create new org
diff --git a/pkg/api/dtos/invite.go b/pkg/api/dtos/invite.go
new file mode 100644
index 00000000000..d2d4129fa5b
--- /dev/null
+++ b/pkg/api/dtos/invite.go
@@ -0,0 +1,9 @@
+package dtos
+
+import m "github.com/grafana/grafana/pkg/models"
+
+type AddInviteForm struct {
+ Email string `json:"email" binding:"Required"`
+ Name string `json:"name"`
+ Role m.RoleType `json:"role" binding:"Required"`
+}
diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go
new file mode 100644
index 00000000000..25740d00f1b
--- /dev/null
+++ b/pkg/api/org_invite.go
@@ -0,0 +1,39 @@
+package api
+
+import (
+ "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/util"
+)
+
+func GetPendingOrgInvites(c *middleware.Context) Response {
+ query := m.GetTempUsersForOrgQuery{OrgId: c.OrgId}
+
+ if err := bus.Dispatch(&query); err != nil {
+ return ApiError(500, "Failed to get invites from db", err)
+ }
+
+ return Json(200, query.Result)
+}
+
+func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response {
+ if !inviteDto.Role.IsValid() {
+ return ApiError(400, "Invalid role specified", nil)
+ }
+
+ cmd := m.CreateTempUserCommand{}
+ cmd.OrgId = c.OrgId
+ cmd.Email = inviteDto.Email
+ cmd.Name = inviteDto.Name
+ cmd.IsInvite = true
+ cmd.InvitedByUserId = c.UserId
+ cmd.Code = util.GetRandomString(30)
+
+ if err := bus.Dispatch(&cmd); err != nil {
+ return ApiError(500, "Failed to save invite to database", err)
+ }
+
+ return ApiSuccess("ok, done!")
+}
diff --git a/pkg/models/temp_user.go b/pkg/models/temp_user.go
index 0a59e914836..a1dd69a98e7 100644
--- a/pkg/models/temp_user.go
+++ b/pkg/models/temp_user.go
@@ -10,15 +10,16 @@ var (
ErrTempUserNotFound = errors.New("User not found")
)
-// TempUser holds data for org invites and new sign ups
+// TempUser holds data for org invites and unconfirmed sign ups
type TempUser struct {
- Id int64
- OrgId int64
- Version int
- Email string
- Name string
- Role string
- IsInvite bool
+ Id int64
+ OrgId int64
+ Version int
+ Email string
+ Name string
+ Role string
+ IsInvite bool
+ InvitedByUserId int64
EmailSent bool
EmailSentOn time.Time
@@ -32,11 +33,12 @@ type TempUser struct {
// COMMANDS
type CreateTempUserCommand struct {
- Email string
- Name string
- OrgId int64
- IsInvite bool
- Code string
+ Email string
+ Name string
+ OrgId int64
+ IsInvite bool
+ InvitedByUserId int64
+ Code string
Result *TempUser
}
diff --git a/pkg/services/sqlstore/migrations/temp_user.go b/pkg/services/sqlstore/migrations/temp_user.go
index 139ab569fb7..6453e659676 100644
--- a/pkg/services/sqlstore/migrations/temp_user.go
+++ b/pkg/services/sqlstore/migrations/temp_user.go
@@ -14,7 +14,7 @@ func addTempUserMigrations(mg *Migrator) {
{Name: "role", Type: DB_NVarchar, Length: 20, Nullable: true},
{Name: "code", Type: DB_NVarchar, Length: 255},
{Name: "is_invite", Type: DB_Bool},
- {Name: "invited_by", Type: DB_NVarchar, Length: 255, Nullable: true},
+ {Name: "invited_by_user_id", Type: DB_BigInt, Nullable: true},
{Name: "email_sent", Type: DB_Bool},
{Name: "email_sent_on", Type: DB_DateTime, Nullable: true},
{Name: "created", Type: DB_DateTime},
@@ -28,7 +28,7 @@ func addTempUserMigrations(mg *Migrator) {
}
// create table
- mg.AddMigration("create temp user table v1", NewAddTableMigration(tempUserV1))
+ mg.AddMigration("create temp user table v1-3", NewAddTableMigration(tempUserV1))
- addTableIndicesMigrations(mg, "v1-1", tempUserV1)
+ addTableIndicesMigrations(mg, "v1-3", tempUserV1)
}
diff --git a/pkg/services/sqlstore/temp_user.go b/pkg/services/sqlstore/temp_user.go
index 0c92ad6d7ba..511cc1b1afc 100644
--- a/pkg/services/sqlstore/temp_user.go
+++ b/pkg/services/sqlstore/temp_user.go
@@ -17,13 +17,14 @@ func CreateTempUser(cmd *m.CreateTempUserCommand) error {
// create user
user := &m.TempUser{
- Email: cmd.Email,
- Name: cmd.Name,
- OrgId: cmd.OrgId,
- Code: cmd.Code,
- IsInvite: cmd.IsInvite,
- Created: time.Now(),
- Updated: time.Now(),
+ Email: cmd.Email,
+ Name: cmd.Name,
+ OrgId: cmd.OrgId,
+ Code: cmd.Code,
+ IsInvite: cmd.IsInvite,
+ InvitedByUserId: cmd.InvitedByUserId,
+ Created: time.Now(),
+ Updated: time.Now(),
}
sess.UseBool("is_invite")
diff --git a/public/app/app.js b/public/app/app.js
index 4d1dec4f673..8adc04d1ef7 100644
--- a/public/app/app.js
+++ b/public/app/app.js
@@ -12,6 +12,7 @@ define([
'angular-sanitize',
'angular-strap',
'angular-dragdrop',
+ 'angular-ui',
'extend-jquery',
'bindonce',
],
@@ -64,7 +65,8 @@ function (angular, $, _, appLevelRequire) {
'$strap.directives',
'ang-drag-drop',
'grafana',
- 'pasvaz.bindonce'
+ 'pasvaz.bindonce',
+ 'ui.bootstrap.tabs',
];
var module_types = ['controllers', 'directives', 'factories', 'services', 'filters', 'routes'];
diff --git a/public/app/components/require.config.js b/public/app/components/require.config.js
index 12a72dd42e3..31825804d04 100644
--- a/public/app/components/require.config.js
+++ b/public/app/components/require.config.js
@@ -17,6 +17,7 @@ require.config({
'angular-sanitize': '../vendor/angular-sanitize/angular-sanitize',
'angular-dragdrop': '../vendor/angular-native-dragdrop/draganddrop',
'angular-strap': '../vendor/angular-other/angular-strap',
+ 'angular-ui': '../vendor/angular-ui/angular-bootstrap',
timepicker: '../vendor/angular-other/timepicker',
datepicker: '../vendor/angular-other/datepicker',
bindonce: '../vendor/angular-bindonce/bindonce',
@@ -90,6 +91,7 @@ require.config({
'angular-dragdrop': ['jquery', 'angular'],
'angular-mocks': ['angular'],
'angular-sanitize': ['angular'],
+ 'angular-ui': ['angular'],
'angular-route': ['angular'],
'angular-strap': ['angular', 'bootstrap','timepicker', 'datepicker'],
'bindonce': ['angular'],
diff --git a/public/app/features/org/orgUsersCtrl.js b/public/app/features/org/orgUsersCtrl.js
index bc124856bab..1a743c9713e 100644
--- a/public/app/features/org/orgUsersCtrl.js
+++ b/public/app/features/org/orgUsersCtrl.js
@@ -13,6 +13,9 @@ function (angular) {
role: 'Viewer',
};
+ $scope.users = [];
+ $scope.pendingInvites = [];
+
$scope.init = function() {
$scope.get();
$scope.editor = { index: 0 };
@@ -22,6 +25,9 @@ function (angular) {
backendSrv.get('/api/org/users').then(function(users) {
$scope.users = users;
});
+ backendSrv.get('/api/org/invites').then(function(pendingInvites) {
+ $scope.pendingInvites = pendingInvites;
+ });
};
$scope.updateOrgUser = function(user) {
diff --git a/public/app/features/org/partials/orgUsers.html b/public/app/features/org/partials/orgUsers.html
index dcbb180b160..d8422502407 100644
--- a/public/app/features/org/partials/orgUsers.html
+++ b/public/app/features/org/partials/orgUsers.html
@@ -15,39 +15,56 @@
-
+
+
+
+
+
+
+
+
-
-
-
- Pending invitaitons
-
diff --git a/public/app/features/org/userInviteCtrl.js b/public/app/features/org/userInviteCtrl.js
index b93611d6df4..26ec89ddc94 100644
--- a/public/app/features/org/userInviteCtrl.js
+++ b/public/app/features/org/userInviteCtrl.js
@@ -7,7 +7,7 @@ function (angular, _) {
var module = angular.module('grafana.controllers');
- module.controller('UserInviteCtrl', function($scope) {
+ module.controller('UserInviteCtrl', function($scope, backendSrv) {
$scope.invites = [
{name: '', email: '', role: 'Editor'},
@@ -27,6 +27,10 @@ function (angular, _) {
$scope.sendInvites = function() {
if (!$scope.inviteForm.$valid) { return; }
+ _.each($scope.invites, function(invite) {
+ backendSrv.post('/api/org/invites', invite);
+ });
+
$scope.dismiss();
};
});
diff --git a/public/app/partials/bootstrap/tab.html b/public/app/partials/bootstrap/tab.html
new file mode 100644
index 00000000000..d76dd67caf2
--- /dev/null
+++ b/public/app/partials/bootstrap/tab.html
@@ -0,0 +1,3 @@
+
+ {{heading}}
+
diff --git a/public/app/partials/bootstrap/tabset.html b/public/app/partials/bootstrap/tabset.html
new file mode 100644
index 00000000000..acbad38390b
--- /dev/null
+++ b/public/app/partials/bootstrap/tabset.html
@@ -0,0 +1,10 @@
+
diff --git a/public/css/less/bootswatch.dark.less b/public/css/less/bootswatch.dark.less
index b134a3d495a..4d83d04199c 100644
--- a/public/css/less/bootswatch.dark.less
+++ b/public/css/less/bootswatch.dark.less
@@ -220,6 +220,7 @@ div.subnav {
li > a:hover,
li.active > a,
+ li.active > a:focus,
li.active > a:hover {
border-color: transparent;
background-color: transparent;
diff --git a/public/css/less/bootswatch.light.less b/public/css/less/bootswatch.light.less
index 42672d7f0b4..fd3c00cabd3 100644
--- a/public/css/less/bootswatch.light.less
+++ b/public/css/less/bootswatch.light.less
@@ -159,6 +159,7 @@ div.subnav {
li > a:hover,
li.active > a,
+ li.active > a:focus,
li.active > a:hover {
border-color: transparent;
background-color: transparent;
diff --git a/public/css/less/grafana.less b/public/css/less/grafana.less
index 612cfd26818..b26858057ff 100644
--- a/public/css/less/grafana.less
+++ b/public/css/less/grafana.less
@@ -15,6 +15,7 @@
@import "admin.less";
@import "validation.less";
@import "fonts.less";
+@import "tabs.less";
.row-control-inner {
padding:0px;
diff --git a/public/css/less/tabs.less b/public/css/less/tabs.less
new file mode 100644
index 00000000000..0938c853360
--- /dev/null
+++ b/public/css/less/tabs.less
@@ -0,0 +1,28 @@
+
+.nav-tabs-alt {
+ border-bottom: @grafanaTriggerBorder;
+ padding-left: 10px;
+
+ & > li > a {
+ .border-radius(3px);
+ }
+
+ li > a:hover,
+ li.active > a,
+ li.active > a:focus,
+ li.active > a:hover {
+ border: @grafanaTriggerBorder;
+ background-color: transparent;
+ border-bottom: 1px solid @grafanaPanelBackground;
+ }
+
+ li.disabled > a {
+ color: @textColor;
+ }
+
+ .open .dropdown-toggle {
+ background-color: #060606;
+ border-color: transparent;
+ }
+}
+
diff --git a/public/test/test-main.js b/public/test/test-main.js
index e6e18512216..946cc6aad77 100644
--- a/public/test/test-main.js
+++ b/public/test/test-main.js
@@ -22,6 +22,7 @@ require.config({
'angular-sanitize': '../vendor/angular-sanitize/angular-sanitize',
angularMocks: '../vendor/angular-mocks/angular-mocks',
'angular-dragdrop': '../vendor/angular-native-dragdrop/draganddrop',
+ 'angular-ui': '../vendor/angular-ui/angular-bootstrap',
'angular-strap': '../vendor/angular-other/angular-strap',
timepicker: '../vendor/angular-other/timepicker',
datepicker: '../vendor/angular-other/datepicker',
@@ -83,6 +84,7 @@ require.config({
'angular-route': ['angular'],
'angular-sanitize': ['angular'],
+ 'angular-ui': ['angular'],
'angular-dragdrop': ['jquery', 'angular'],
'angular-mocks': ['angular'],
'angular-strap': ['angular', 'bootstrap','timepicker', 'datepicker'],
diff --git a/public/vendor/angular-ui/angular-bootstrap.js b/public/vendor/angular-ui/angular-bootstrap.js
new file mode 100644
index 00000000000..f90955af4e7
--- /dev/null
+++ b/public/vendor/angular-ui/angular-bootstrap.js
@@ -0,0 +1,6 @@
+define([
+ 'angular',
+ '../vendor/angular-ui/tabs',
+], function() {
+});
+
diff --git a/public/vendor/angular-ui/tabs.js b/public/vendor/angular-ui/tabs.js
new file mode 100644
index 00000000000..4d87ac0bab1
--- /dev/null
+++ b/public/vendor/angular-ui/tabs.js
@@ -0,0 +1,293 @@
+
+/**
+ * @ngdoc overview
+ * @name ui.bootstrap.tabs
+ *
+ * @description
+ * AngularJS version of the tabs directive.
+ */
+
+angular.module('ui.bootstrap.tabs', [])
+
+.controller('TabsetController', ['$scope', function TabsetCtrl($scope) {
+ var ctrl = this,
+ tabs = ctrl.tabs = $scope.tabs = [];
+
+ ctrl.select = function(selectedTab) {
+ angular.forEach(tabs, function(tab) {
+ if (tab.active && tab !== selectedTab) {
+ tab.active = false;
+ tab.onDeselect();
+ }
+ });
+ selectedTab.active = true;
+ selectedTab.onSelect();
+ };
+
+ ctrl.addTab = function addTab(tab) {
+ tabs.push(tab);
+ // we can't run the select function on the first tab
+ // since that would select it twice
+ if (tabs.length === 1 && tab.active !== false) {
+ tab.active = true;
+ } else if (tab.active) {
+ ctrl.select(tab);
+ }
+ else {
+ tab.active = false;
+ }
+ };
+
+ ctrl.removeTab = function removeTab(tab) {
+ var index = tabs.indexOf(tab);
+ //Select a new tab if the tab to be removed is selected and not destroyed
+ if (tab.active && tabs.length > 1 && !destroyed) {
+ //If this is the last tab, select the previous tab. else, the next tab.
+ var newActiveIndex = index == tabs.length - 1 ? index - 1 : index + 1;
+ ctrl.select(tabs[newActiveIndex]);
+ }
+ tabs.splice(index, 1);
+ };
+
+ var destroyed;
+ $scope.$on('$destroy', function() {
+ destroyed = true;
+ });
+}])
+
+/**
+ * @ngdoc directive
+ * @name ui.bootstrap.tabs.directive:tabset
+ * @restrict EA
+ *
+ * @description
+ * Tabset is the outer container for the tabs directive
+ *
+ * @param {boolean=} vertical Whether or not to use vertical styling for the tabs.
+ * @param {boolean=} justified Whether or not to use justified styling for the tabs.
+ *
+ * @example
+
+
+
+ First Content!
+ Second Content!
+
+
+
+ First Vertical Content!
+ Second Vertical Content!
+
+
+ First Justified Content!
+ Second Justified Content!
+
+
+
+ */
+.directive('tabset', function() {
+ return {
+ restrict: 'EA',
+ transclude: true,
+ replace: true,
+ scope: {
+ type: '@'
+ },
+ controller: 'TabsetController',
+ templateUrl: 'app/partials/bootstrap/tabset.html',
+ link: function(scope, element, attrs) {
+ scope.vertical = angular.isDefined(attrs.vertical) ? scope.$parent.$eval(attrs.vertical) : false;
+ scope.justified = angular.isDefined(attrs.justified) ? scope.$parent.$eval(attrs.justified) : false;
+ }
+ };
+})
+
+/**
+ * @ngdoc directive
+ * @name ui.bootstrap.tabs.directive:tab
+ * @restrict EA
+ *
+ * @param {string=} heading The visible heading, or title, of the tab. Set HTML headings with {@link ui.bootstrap.tabs.directive:tabHeading tabHeading}.
+ * @param {string=} select An expression to evaluate when the tab is selected.
+ * @param {boolean=} active A binding, telling whether or not this tab is selected.
+ * @param {boolean=} disabled A binding, telling whether or not this tab is disabled.
+ *
+ * @description
+ * Creates a tab with a heading and content. Must be placed within a {@link ui.bootstrap.tabs.directive:tabset tabset}.
+ *
+ * @example
+
+
+
+
+
+
+
+ First Tab
+
+ Alert me!
+ Second Tab, with alert callback and html heading!
+
+
+ {{item.content}}
+
+
+
+
+
+ function TabsDemoCtrl($scope) {
+ $scope.items = [
+ { title:"Dynamic Title 1", content:"Dynamic Item 0" },
+ { title:"Dynamic Title 2", content:"Dynamic Item 1", disabled: true }
+ ];
+
+ $scope.alertMe = function() {
+ setTimeout(function() {
+ alert("You've selected the alert tab!");
+ });
+ };
+ };
+
+
+ */
+
+/**
+ * @ngdoc directive
+ * @name ui.bootstrap.tabs.directive:tabHeading
+ * @restrict EA
+ *
+ * @description
+ * Creates an HTML heading for a {@link ui.bootstrap.tabs.directive:tab tab}. Must be placed as a child of a tab element.
+ *
+ * @example
+
+
+
+
+ HTML in my titles?!
+ And some content, too!
+
+
+ Icon heading?!?
+ That's right.
+
+
+
+
+ */
+.directive('tab', ['$parse', '$log', function($parse, $log) {
+ return {
+ require: '^tabset',
+ restrict: 'EA',
+ replace: true,
+ templateUrl: 'app/partials/bootstrap/tab.html',
+ transclude: true,
+ scope: {
+ active: '=?',
+ heading: '@',
+ onSelect: '&select', //This callback is called in contentHeadingTransclude
+ //once it inserts the tab's content into the dom
+ onDeselect: '&deselect'
+ },
+ controller: function() {
+ //Empty controller so other directives can require being 'under' a tab
+ },
+ compile: function(elm, attrs, transclude) {
+ return function postLink(scope, elm, attrs, tabsetCtrl) {
+ scope.$watch('active', function(active) {
+ if (active) {
+ tabsetCtrl.select(scope);
+ }
+ });
+
+ scope.disabled = false;
+ if ( attrs.disable ) {
+ scope.$parent.$watch($parse(attrs.disable), function(value) {
+ scope.disabled = !! value;
+ });
+ }
+
+ // Deprecation support of "disabled" parameter
+ // fix(tab): IE9 disabled attr renders grey text on enabled tab #2677
+ // This code is duplicated from the lines above to make it easy to remove once
+ // the feature has been completely deprecated
+ if ( attrs.disabled ) {
+ $log.warn('Use of "disabled" attribute has been deprecated, please use "disable"');
+ scope.$parent.$watch($parse(attrs.disabled), function(value) {
+ scope.disabled = !! value;
+ });
+ }
+
+ scope.select = function() {
+ if ( !scope.disabled ) {
+ scope.active = true;
+ }
+ };
+
+ tabsetCtrl.addTab(scope);
+ scope.$on('$destroy', function() {
+ tabsetCtrl.removeTab(scope);
+ });
+
+ //We need to transclude later, once the content container is ready.
+ //when this link happens, we're inside a tab heading.
+ scope.$transcludeFn = transclude;
+ };
+ }
+ };
+}])
+
+.directive('tabHeadingTransclude', [function() {
+ return {
+ restrict: 'A',
+ require: '^tab',
+ link: function(scope, elm, attrs, tabCtrl) {
+ scope.$watch('headingElement', function updateHeadingElement(heading) {
+ if (heading) {
+ elm.html('');
+ elm.append(heading);
+ }
+ });
+ }
+ };
+}])
+
+.directive('tabContentTransclude', function() {
+ return {
+ restrict: 'A',
+ require: '^tabset',
+ link: function(scope, elm, attrs) {
+ var tab = scope.$eval(attrs.tabContentTransclude);
+
+ //Now our tab is ready to be transcluded: both the tab heading area
+ //and the tab content area are loaded. Transclude 'em both.
+ tab.$transcludeFn(tab.$parent, function(contents) {
+ angular.forEach(contents, function(node) {
+ if (isTabHeading(node)) {
+ //Let tabHeadingTransclude know.
+ tab.headingElement = node;
+ } else {
+ elm.append(node);
+ }
+ });
+ });
+ }
+ };
+ function isTabHeading(node) {
+ return node.tagName && (
+ node.hasAttribute('tab-heading') ||
+ node.hasAttribute('data-tab-heading') ||
+ node.tagName.toLowerCase() === 'tab-heading' ||
+ node.tagName.toLowerCase() === 'data-tab-heading'
+ );
+ }
+})
+
+;