feat(invite): worked on pending invitations list, revoke invite now works, #2353

This commit is contained in:
Torkel Ödegaard 2015-07-20 10:57:39 +02:00
parent 4ac652b127
commit 3242354a4b
11 changed files with 116 additions and 65 deletions

View File

@ -94,6 +94,7 @@ func Register(r *macaron.Macaron) {
// invites // invites
r.Get("/invites", wrap(GetPendingOrgInvites)) r.Get("/invites", wrap(GetPendingOrgInvites))
r.Post("/invites", bind(dtos.AddInviteForm{}), wrap(AddOrgInvite)) r.Post("/invites", bind(dtos.AddInviteForm{}), wrap(AddOrgInvite))
r.Patch("/invites/:id/revoke", wrap(RevokeInvite))
}, regOrgAdmin) }, regOrgAdmin)
// create new org // create new org

View File

@ -10,7 +10,7 @@ import (
) )
func GetPendingOrgInvites(c *middleware.Context) Response { func GetPendingOrgInvites(c *middleware.Context) Response {
query := m.GetTempUsersForOrgQuery{OrgId: c.OrgId} query := m.GetTempUsersForOrgQuery{OrgId: c.OrgId, Status: m.TmpUserInvitePending}
if err := bus.Dispatch(&query); err != nil { if err := bus.Dispatch(&query); err != nil {
return ApiError(500, "Failed to get invites from db", err) return ApiError(500, "Failed to get invites from db", err)
@ -47,10 +47,11 @@ func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response
cmd.OrgId = c.OrgId cmd.OrgId = c.OrgId
cmd.Email = inviteDto.Email cmd.Email = inviteDto.Email
cmd.Name = inviteDto.Name cmd.Name = inviteDto.Name
cmd.IsInvite = true cmd.Status = m.TmpUserInvitePending
cmd.InvitedByUserId = c.UserId cmd.InvitedByUserId = c.UserId
cmd.Code = util.GetRandomString(30) cmd.Code = util.GetRandomString(30)
cmd.Role = inviteDto.Role cmd.Role = inviteDto.Role
cmd.RemoteAddr = c.Req.RemoteAddr
if err := bus.Dispatch(&cmd); err != nil { if err := bus.Dispatch(&cmd); err != nil {
return ApiError(500, "Failed to save invite to database", err) return ApiError(500, "Failed to save invite to database", err)
@ -77,3 +78,17 @@ func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response
return ApiSuccess("ok, done!") return ApiSuccess("ok, done!")
} }
func RevokeInvite(c *middleware.Context) Response {
cmd := m.UpdateTempUserStatusCommand{
Id: c.ParamsInt64(":id"),
OrgId: c.OrgId,
Status: m.TmpUserRevoked,
}
if err := bus.Dispatch(&cmd); err != nil {
return ApiError(500, "Failed to update invite status", err)
}
return ApiSuccess("Invite revoked")
}

View File

@ -10,6 +10,15 @@ var (
ErrTempUserNotFound = errors.New("User not found") ErrTempUserNotFound = errors.New("User not found")
) )
type TempUserStatus string
const (
TmpUserInvitePending TempUserStatus = "InvitePending"
TmpUserCompleted TempUserStatus = "Completed"
TmpUserEmailPending TempUserStatus = "EmailPending"
TmpUserRevoked TempUserStatus = "Revoked"
)
// TempUser holds data for org invites and unconfirmed sign ups // TempUser holds data for org invites and unconfirmed sign ups
type TempUser struct { type TempUser struct {
Id int64 Id int64
@ -18,12 +27,13 @@ type TempUser struct {
Email string Email string
Name string Name string
Role RoleType Role RoleType
IsInvite bool
InvitedByUserId int64 InvitedByUserId int64
Status TempUserStatus
EmailSent bool EmailSent bool
EmailSentOn time.Time EmailSentOn time.Time
Code string Code string
RemoteAddr string
Created time.Time Created time.Time
Updated time.Time Updated time.Time
@ -36,16 +46,24 @@ type CreateTempUserCommand struct {
Email string Email string
Name string Name string
OrgId int64 OrgId int64
IsInvite bool
InvitedByUserId int64 InvitedByUserId int64
Status TempUserStatus
Code string Code string
Role RoleType Role RoleType
RemoteAddr string
Result *TempUser Result *TempUser
} }
type UpdateTempUserStatusCommand struct {
Id int64
OrgId int64
Status TempUserStatus
}
type GetTempUsersForOrgQuery struct { type GetTempUsersForOrgQuery struct {
OrgId int64 OrgId int64
Status TempUserStatus
Result []*TempUserDTO Result []*TempUserDTO
} }
@ -56,6 +74,7 @@ type TempUserDTO struct {
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"` Role string `json:"role"`
InvitedBy string `json:"invitedBy"` InvitedBy string `json:"invitedBy"`
Code string `json:"code"`
EmailSent bool `json:"emailSent"` EmailSent bool `json:"emailSent"`
EmailSentOn time.Time `json:"emailSentOn"` EmailSentOn time.Time `json:"emailSentOn"`
Created time.Time `json:"createdOn"` Created time.Time `json:"createdOn"`

View File

@ -13,10 +13,11 @@ func addTempUserMigrations(mg *Migrator) {
{Name: "name", Type: DB_NVarchar, Length: 255, Nullable: true}, {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: true},
{Name: "role", Type: DB_NVarchar, Length: 20, Nullable: true}, {Name: "role", Type: DB_NVarchar, Length: 20, Nullable: true},
{Name: "code", Type: DB_NVarchar, Length: 255}, {Name: "code", Type: DB_NVarchar, Length: 255},
{Name: "is_invite", Type: DB_Bool}, {Name: "status", Type: DB_Varchar, Length: 20},
{Name: "invited_by_user_id", Type: DB_BigInt, Nullable: true}, {Name: "invited_by_user_id", Type: DB_BigInt, Nullable: true},
{Name: "email_sent", Type: DB_Bool}, {Name: "email_sent", Type: DB_Bool},
{Name: "email_sent_on", Type: DB_DateTime, Nullable: true}, {Name: "email_sent_on", Type: DB_DateTime, Nullable: true},
{Name: "remote_addr", Type: DB_Varchar, Nullable: true},
{Name: "created", Type: DB_DateTime}, {Name: "created", Type: DB_DateTime},
{Name: "updated", Type: DB_DateTime}, {Name: "updated", Type: DB_DateTime},
}, },
@ -24,11 +25,14 @@ func addTempUserMigrations(mg *Migrator) {
{Cols: []string{"email"}, Type: IndexType}, {Cols: []string{"email"}, Type: IndexType},
{Cols: []string{"org_id"}, Type: IndexType}, {Cols: []string{"org_id"}, Type: IndexType},
{Cols: []string{"code"}, Type: IndexType}, {Cols: []string{"code"}, Type: IndexType},
{Cols: []string{"status"}, Type: IndexType},
}, },
} }
// create table // addDropAllIndicesMigrations(mg, "v7", tempUserV1)
mg.AddMigration("create temp user table v1-3", NewAddTableMigration(tempUserV1)) // mg.AddMigration("Drop old table tempUser v7", NewDropTableMigration("temp_user"))
addTableIndicesMigrations(mg, "v1-3", tempUserV1) // create table
mg.AddMigration("create temp user table v1-7", NewAddTableMigration(tempUserV1))
addTableIndicesMigrations(mg, "v1-7", tempUserV1)
} }

View File

@ -3,6 +3,7 @@ package sqlstore
import ( import (
"time" "time"
"github.com/go-xorm/xorm"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
) )
@ -10,6 +11,15 @@ import (
func init() { func init() {
bus.AddHandler("sql", CreateTempUser) bus.AddHandler("sql", CreateTempUser)
bus.AddHandler("sql", GetTempUsersForOrg) bus.AddHandler("sql", GetTempUsersForOrg)
bus.AddHandler("sql", UpdateTempUserStatus)
}
func UpdateTempUserStatus(cmd *m.UpdateTempUserStatusCommand) error {
return inTransaction(func(sess *xorm.Session) error {
var rawSql = "UPDATE temp_user SET status=? WHERE id=? and org_id=?"
_, err := sess.Exec(rawSql, string(cmd.Status), cmd.Id, cmd.OrgId)
return err
})
} }
func CreateTempUser(cmd *m.CreateTempUserCommand) error { func CreateTempUser(cmd *m.CreateTempUserCommand) error {
@ -22,14 +32,13 @@ func CreateTempUser(cmd *m.CreateTempUserCommand) error {
OrgId: cmd.OrgId, OrgId: cmd.OrgId,
Code: cmd.Code, Code: cmd.Code,
Role: cmd.Role, Role: cmd.Role,
IsInvite: cmd.IsInvite, Status: cmd.Status,
RemoteAddr: cmd.RemoteAddr,
InvitedByUserId: cmd.InvitedByUserId, InvitedByUserId: cmd.InvitedByUserId,
Created: time.Now(), Created: time.Now(),
Updated: time.Now(), Updated: time.Now(),
} }
sess.UseBool("is_invite")
if _, err := sess.Insert(user); err != nil { if _, err := sess.Insert(user); err != nil {
return err return err
} }
@ -51,10 +60,10 @@ func GetTempUsersForOrg(query *m.GetTempUsersForOrgQuery) error {
u.login as invited_by u.login as invited_by
FROM ` + dialect.Quote("temp_user") + ` as tu FROM ` + dialect.Quote("temp_user") + ` as tu
LEFT OUTER JOIN ` + dialect.Quote("user") + ` as u on u.id = tu.invited_by_user_id LEFT OUTER JOIN ` + dialect.Quote("user") + ` as u on u.id = tu.invited_by_user_id
WHERE tu.org_id=? ORDER BY tu.created desc` WHERE tu.org_id=? AND tu.status =? ORDER BY tu.created desc`
query.Result = make([]*m.TempUserDTO, 0) query.Result = make([]*m.TempUserDTO, 0)
sess := x.Sql(rawSql, query.OrgId) sess := x.Sql(rawSql, query.OrgId, string(query.Status))
err := sess.Find(&query.Result) err := sess.Find(&query.Result)
return err return err
} }

View File

@ -15,22 +15,28 @@ func TestTempUserCommandsAndQueries(t *testing.T) {
Convey("Given saved api key", func() { Convey("Given saved api key", func() {
cmd := m.CreateTempUserCommand{ cmd := m.CreateTempUserCommand{
OrgId: 2256, OrgId: 2256,
Name: "hello", Name: "hello",
Email: "e@as.co", Email: "e@as.co",
IsInvite: true, Status: m.TmpUserInvitePending,
} }
err := CreateTempUser(&cmd) err := CreateTempUser(&cmd)
So(err, ShouldBeNil) So(err, ShouldBeNil)
Convey("Should be able to get temp users by org id", func() { Convey("Should be able to get temp users by org id", func() {
query := m.GetTempUsersForOrgQuery{OrgId: 2256} query := m.GetTempUsersForOrgQuery{OrgId: 2256, Status: m.TmpUserInvitePending}
err = GetTempUsersForOrg(&query) err = GetTempUsersForOrg(&query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1) So(len(query.Result), ShouldEqual, 1)
}) })
Convey("Should be able update status", func() {
cmd2 := m.UpdateTempUserStatusCommand{OrgId: 2256, Status: m.TmpUserRevoked, Id: cmd.Result.Id}
err := UpdateTempUserStatus(&cmd2)
So(err, ShouldBeNil)
})
}) })
}) })
} }

View File

@ -38,9 +38,8 @@ function (angular) {
backendSrv.delete('/api/org/users/' + user.userId).then($scope.get); backendSrv.delete('/api/org/users/' + user.userId).then($scope.get);
}; };
$scope.addUser = function() { $scope.revokeInvite = function(invite) {
if (!$scope.form.$valid) { return; } backendSrv.patch('/api/org/invites/' + invite.id + '/revoke').then($scope.get);
backendSrv.post('/api/org/users', $scope.user).then($scope.get);
}; };
$scope.openInviteModal = function() { $scope.openInviteModal = function() {

View File

@ -52,14 +52,17 @@
</div> </div>
</div> </div>
<br> <div style="text-align: left; margin-top: 6px;">
<div style="text-align: left">
<a ng-click="addInvite()">+ Invite another</a> <a ng-click="addInvite()">+ Invite another</a>
<div class="form-inline" style="margin-top: 20px">
<editor-checkbox text="Skip sending emails" model="options.skipEmails" change="targetBlur()"></editor-checkbox>
</div>
</div> </div>
<div class="" style="margin-top: 30px; margin-bottom: 20px;"> <div class="" style="margin-top: 30px; margin-bottom: 20px;">
<button type="button" class="btn btn-inverse" ng-click="dismiss()">Cancel</button> <button type="button" class="btn btn-inverse" ng-click="dismiss()">Cancel</button>
<button type="submit" class="btn btn-success" ng-click="sendInvites();">Invite Users</button> <button type="submit" class="btn btn-success" ng-click="sendInvites();">Invite Users</button>
</div> </div>
</div> </div>
</form> </form>

View File

@ -40,26 +40,32 @@
</table> </table>
</tab> </tab>
<tab heading="Pending Invitations ({{pendingInvites.length}})"> <tab heading="Pending Invitations ({{pendingInvites.length}})">
<table class="grafana-options-table form-inline"> <div class="grafana-list-item" ng-repeat="invite in pendingInvites" ng-click="invite.expanded = !invite.expanded">
<tr> {{invite.email}}
<th>Email</th> <span ng-show="invite.name" style="padding-left: 20px"> {{invite.name}}</span>
<th>Name</th> <span class="pull-right">
<th></th> <button class="btn btn-inverse btn-mini " data-clipboard-text="{{invite.url}}" clipboard-button>
</tr> <i class="fa fa-clipboard"></i> Copy Invite
<tr ng-repeat="invite in pendingInvites"> </button>
<td>{{invite.email}}</td> &nbsp;
<td>{{invite.name}}</td> <a class="pointer">
<td style="width: 1%"> <i ng-show="!invite.expanded" class="fa fa-caret-right"></i>
<button class="btn btn-inverse btn-mini" data-clipboard-text="{{snapshotUrl}}" clipboard-button> <i ng-show="invite.expanded" class="fa fa-caret-down"></i>
<i class="fa fa-clipboard"></i> Copy Invite </a>
</button> </span>
&nbsp;&nbsp; <div ng-show="invite.expanded">
<a class="pointer"> <button class="btn btn-inverse btn-mini">
<i class="fa fa-caret-right"></i> <i class="fa fa-envelope-o"></i> Resend invite
</a> </button>
</td> &nbsp;
</tr> <button class="btn btn-inverse btn-mini" ng-click="revokeInvite(invite, $event)">
</table> <i class="fa fa-remove" style="color: red"></i> Revoke invite
</button>
<span style="padding-left: 15px">
Invited: <em> {{invite.createdOn | date: 'shortDate'}} by {{invite.invitedBy}} </em>
</span>
<div>
</div>
</tab> </tab>
</tabset> </tabset>

View File

@ -13,8 +13,8 @@ function (angular, _) {
{name: '', email: '', role: 'Editor'}, {name: '', email: '', role: 'Editor'},
]; ];
$scope.init = function() { $scope.options = {skipEmails: false};
}; $scope.init = function() { };
$scope.addInvite = function() { $scope.addInvite = function() {
$scope.invites.push({name: '', email: '', role: 'Editor'}); $scope.invites.push({name: '', email: '', role: 'Editor'});
@ -28,6 +28,7 @@ function (angular, _) {
if (!$scope.inviteForm.$valid) { return; } if (!$scope.inviteForm.$valid) { return; }
var promises = _.map($scope.invites, function(invite) { var promises = _.map($scope.invites, function(invite) {
invite.skipEmails = $scope.options.skipEmails;
return backendSrv.post('/api/org/invites', invite); return backendSrv.post('/api/org/invites', invite);
}); });

View File

@ -33,23 +33,11 @@
white-space: nowrap; white-space: nowrap;
} }
.grafana-options-list { .grafana-list-item {
list-style: none; display: block;
margin: 0; padding: 1px 10px;
max-width: 450px; line-height: 34px;
background-color: @grafanaTargetBackground;
li:nth-child(odd) { margin-bottom: 4px;
background-color: @grafanaListAccent; cursor: pointer;
}
li {
float: left;
margin: 2px;
padding: 5px 10px;
border: 1px solid @grafanaListBorderBottom;
border: 1px solid @grafanaListBorderBottom;
}
li:first-child {
border: 1px solid @grafanaListBorderBottom;
}
} }