Commit 49c28c5a authored by Lee Brown's avatar Lee Brown

invite users

parent 56363828
......@@ -49,12 +49,7 @@ func (h *Projects) Index(ctx context.Context, w http.ResponseWriter, r *http.Req
return err
}
var statusValues []interface{}
for _, v := range project.ProjectStatus_Values {
statusValues = append(statusValues, string(v))
}
statusOpts := web.NewEnumResponse(ctx, nil, statusValues...)
statusOpts := web.NewEnumResponse(ctx, nil, project.ProjectStatus_ValuesInterface()...)
statusFilterItems := []datatable.FilterOptionItem{}
for _, opt := range statusOpts.Options {
......
......@@ -68,6 +68,10 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
app.Handle("GET", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("POST", "/users/invite/:hash", us.InviteAccept)
app.Handle("GET", "/users/invite/:hash", us.InviteAccept)
app.Handle("POST", "/users/invite", us.Invite, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/invite", us.Invite, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/users/create", us.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/create", us.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users", us.Index, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
......
......@@ -230,6 +230,9 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.
return err
}
// Append the query param value to the request.
req.ResetHash = params["hash"]
u, err := user.ResetConfirm(ctx, h.MasterDB, *req, h.SecretKey, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
......@@ -267,8 +270,6 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.
// Redirect the user to the dashboard.
http.Redirect(w, r, "/", http.StatusFound)
} else {
req.ResetHash = params["hash"]
}
return nil
......
......@@ -3,9 +3,14 @@ package handlers
import (
"context"
"fmt"
"geeks-accelerator/oss/saas-starter-kit/internal/account"
"geeks-accelerator/oss/saas-starter-kit/internal/user_auth"
"net/http"
"strings"
"time"
"geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite"
"github.com/dustin/go-humanize/english"
"geeks-accelerator/oss/saas-starter-kit/internal/geonames"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/datatable"
......@@ -63,12 +68,7 @@ func (h *Users) Index(ctx context.Context, w http.ResponseWriter, r *http.Reques
return err
}
var statusValues []interface{}
for _, v := range user_account.UserAccountStatus_Values {
statusValues = append(statusValues, string(v))
}
statusOpts := web.NewEnumResponse(ctx, nil, statusValues...)
statusOpts := web.NewEnumResponse(ctx, nil, user_account.UserAccountStatus_ValuesInterface())
statusFilterItems := []datatable.FilterOptionItem{}
for _, opt := range statusOpts.Options {
......@@ -497,3 +497,220 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque
return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "users-update.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
}
// Invite handles sending invites for users to the account.
func (h *Users) Invite(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
ctxValues, err := webcontext.ContextValues(ctx)
if err != nil {
return err
}
claims, err := auth.ClaimsFromContext(ctx)
if err != nil {
return err
}
//
req := new(invite.SendUserInvitesRequest)
data := make(map[string]interface{})
f := func() (bool, error) {
if r.Method == http.MethodPost {
err := r.ParseForm()
if err != nil {
return false, err
}
decoder := schema.NewDecoder()
if err := decoder.Decode(req, r.PostForm); err != nil {
return false, err
}
req.UserID = claims.Subject
req.AccountID = claims.Audience
res, err := invite.SendUserInvites(ctx, claims, h.MasterDB, h.ProjectRoutes.UserInviteAccept, h.NotifyEmail, *req, h.SecretKey, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
default:
if verr, ok := weberror.NewValidationError(ctx, err); ok {
data["validationErrors"] = verr.(*weberror.Error)
return false, nil
} else {
return false, err
}
}
}
// Display a success message to the user.
inviteCnt := len(res)
if inviteCnt > 0 {
webcontext.SessionFlashSuccess(ctx,
fmt.Sprintf("%s Invited", english.PluralWord(inviteCnt, "User", "")),
fmt.Sprintf("%s successfully invited. %s been sent to them to join your account.",
english.Plural(inviteCnt, "user", ""),
english.PluralWord(inviteCnt, "An email has", "Emails have")))
} else {
webcontext.SessionFlashWarning(ctx,
"Users not Invited",
"No users were invited.")
}
err = webcontext.ContextSession(ctx).Save(r, w)
if err != nil {
return false, err
}
http.Redirect(w, r, urlUsersIndex(), http.StatusFound)
return true, nil
}
return false, nil
}
end, err := f()
if err != nil {
return web.RenderError(ctx, w, r, err, h.Renderer, TmplLayoutBase, TmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
} else if end {
return nil
}
var selectedRoles []interface{}
for _, r := range req.Roles {
selectedRoles = append(selectedRoles, r.String())
}
data["roles"] = web.NewEnumMultiResponse(ctx, selectedRoles, user_account.UserAccountRole_ValuesInterface()...)
data["form"] = req
if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(invite.SendUserInvitesRequest{})); ok {
data["validationDefaults"] = verr.(*weberror.Error)
}
return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "users-invite.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
}
// Invite handles sending invites for users to the account.
func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
inviteHash := params["hash"]
ctxValues, err := webcontext.ContextValues(ctx)
if err != nil {
return err
}
//
req := new(invite.AcceptInviteRequest)
data := make(map[string]interface{})
f := func() (bool, error) {
if r.Method == http.MethodPost {
err := r.ParseForm()
if err != nil {
return false, err
}
decoder := schema.NewDecoder()
if err := decoder.Decode(req, r.PostForm); err != nil {
return false, err
}
// Append the query param value to the request.
req.InviteHash = inviteHash
err = invite.AcceptInvite(ctx, h.MasterDB, *req, h.SecretKey, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
default:
if verr, ok := weberror.NewValidationError(ctx, err); ok {
data["validationErrors"] = verr.(*weberror.Error)
return false, nil
} else {
return false, err
}
}
}
// Authenticated the user. Probably should use the default session TTL from UserLogin.
token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, u.Email, req.Password, time.Hour, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
case account.ErrForbidden:
return false, web.RespondError(ctx, w, weberror.NewError(ctx, err, http.StatusForbidden))
default:
if verr, ok := weberror.NewValidationError(ctx, err); ok {
data["validationErrors"] = verr.(*weberror.Error)
return false, nil
} else {
return false, err
}
}
}
// Add the token to the users session.
err = handleSessionToken(ctx, h.MasterDB, w, r, token)
if err != nil {
return false, err
}
// Redirect the user to the dashboard.
http.Redirect(w, r, "/", http.StatusFound)
return true, nil
}
hash, err := invite.ParseInviteHash(ctx, h.SecretKey, inviteHash, ctxValues.Now)
if err != nil {
switch errors.Cause(err) {
case invite.ErrInviteExpired:
webcontext.SessionFlashError(ctx,
"Invite Expired",
"The invite has expired.")
return false, nil
case invite.ErrInviteUserPasswordSet:
webcontext.SessionFlashError(ctx,
"Invite already Accepted",
"The invite has already been accepted. Try to login or use forgot password.")
http.Redirect(w, r, "/user/login", http.StatusFound)
return true, nil
default:
return false, err
}
}
// Read user by ID with no claims.
usr, err := user.ReadByID(ctx, auth.Claims{}, h.MasterDB, hash.UserID)
if err != nil {
return false, err
}
data["user"] = usr.Response(ctx)
if req.Email == "" {
req.FirstName = usr.FirstName
req.LastName = usr.LastName
req.Email = usr.Email
if usr.Timezone != "" {
req.Timezone = &usr.Timezone
}
}
return false, nil
}
end, err := f()
if err != nil {
return web.RenderError(ctx, w, r, err, h.Renderer, TmplLayoutBase, TmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
} else if end {
return nil
}
data["form"] = req
if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(invite.AcceptInviteRequest{})); ok {
data["validationDefaults"] = verr.(*weberror.Error)
}
return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "user-invite-accept.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
}
......@@ -16,7 +16,7 @@
<label for="selectStatus">Status</label>
<select class="form-control {{ ValidationFieldClass $.validationErrors "Status" }}"
id="selectStatus" name="Status">
{{ range $idx, $t := .project.Status.Options }}
{{ range $t := .project.Status.Options }}
<option value="{{ $t.Value }}" {{ if $t.Selected }}selected="selected"{{ end }}>{{ $t.Title }}</option>
{{ end }}
</select>
......
{{define "title"}}Invite Accept{{end}}
{{define "description"}}{{end}}
{{define "style"}}
{{end}}
{{ define "partials/app-wrapper" }}
<div class="container" id="page-content">
<!-- Outer Row -->
<div class="row justify-content-center">
<div class="col-xl-10 col-lg-12 col-md-9">
<div class="card o-hidden border-0 shadow-lg my-5">
<div class="card-body p-0">
<!-- Nested Row within Card Body -->
<div class="row">
<div class="col-lg-6 d-none d-lg-block bg-login-image"></div>
<div class="col-lg-6">
<div class="p-5">
{{ template "app-flashes" . }}
<div class="text-center">
<h1 class="h4 text-gray-900 mb-2">Invite Accept</h1>
<p class="mb-4">.....</p>
</div>
{{ template "validation-error" . }}
<form class="user" method="post" novalidate>
<input type="hidden" name="ResetHash" value="{{ $.form.ResetHash }}" />
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="password" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Password" }}" name="Password" value="{{ $.form.Password }}" placeholder="Password" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Password" }}
</div>
<div class="col-sm-6">
<input type="password" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "PasswordConfirm" }}" name="PasswordConfirm" value="{{ $.form.PasswordConfirm }}" placeholder="Repeat Password" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "PasswordConfirm" }}
</div>
</div>
<button class="btn btn-primary btn-user btn-block">
Join
</button>
<hr>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "js"}}
<script>
$(document).ready(function() {
$(document).find('body').addClass('bg-gradient-primary');
});
</script>
{{end}}
\ No newline at end of file
{{define "title"}}Invite Users{{end}}
{{define "content"}}
<form method="POST">
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<div class="form-group">
<label for="inputEmail">Email for Invite 1</label>
<input type="text" class="form-control invite-user-email" placeholder="enter email" name="Emails" value="">
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<div class="form-group">
<label for="selectRoles">Roles</label>
<select class="form-control {{ ValidationFieldClass $.validationErrors "Roles" }}"
id="selectRoles" name="Roles" multiple="multiple">
{{ range $t := .roles }}
<option value="{{ $t.Value }}" {{ if $t.Selected }}selected="selected"{{ end }}>{{ $t.Title }}</option>
{{ end }}
</select>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Roles" }}
</div>
</div>
</div>
</div>
</div>
<div class="spacer-30"></div>
<div class="row">
<div class="col">
<input id="btnSubmit" type="submit" value="Invite Users" class="btn btn-primary"/>
</div>
</div>
</form>
{{end}}
{{ define "js" }}
<script>
function addAnotherEmail(el) {
if ($(el).val() == '') {
return;
}
cnt = 0;
$( "input.invite-user-email" ).each(function( index ) {
cnt = cnt + 1;
});
cnt = cnt + 1;
newId = 'inviteUser'+cnt;
newHtml = '';
newHtml = newHtml + '<div class="form-group">';
newHtml = newHtml + '<label for="inputEmail">Email for Invite '+cnt+'</label>';
newHtml = newHtml + '<input type="text" class="form-control invite-user-email" placeholder="enter email" name="Emails" value="">';
newHtml = newHtml + '</div>';
$(el).closest('div.card-body').append(newHtml);
$(el).unbind( "blur" );
$('#'+newId).on("blur", function() {
addAnotherEmail($(this));
});
}
$(document).ready(function(){
$("#inviteUser1").on("blur", function() {
addAnotherEmail($(this));
});
$("#inputRole").on("change", function() {
if ($(this).val() == 'admin') {
$('#userProcedures').hide();
} else {
$('#userProcedures').show();
}
}).change();
});
</script>
{{ end }}
\ No newline at end of file
......@@ -116,6 +116,34 @@ func NewEnumResponse(ctx context.Context, value interface{}, options ...interfac
return er
}
// EnumResponse is a response friendly format for displaying a multi select enum.
type EnumMultiResponse []EnumOption
// NewEnumMultiResponse returns a display friendly format for a multi enum field.
func NewEnumMultiResponse(ctx context.Context, selected []interface{}, options ...interface{}) EnumMultiResponse {
var er EnumMultiResponse
for _, opt := range options {
optStr := fmt.Sprintf("%s", opt)
opt := EnumOption{
Value: optStr,
Title: EnumValueTitle(optStr),
}
for _, s := range selected {
selStr := fmt.Sprintf("%s", s)
if optStr == selStr {
opt.Selected = true
}
}
er = append(er, opt)
}
return er
}
// EnumValueTitle formats a string value for display.
func EnumValueTitle(v string) string {
v = strings.Replace(v, "_", " ", -1)
......
......@@ -40,8 +40,14 @@ func (r ProjectRoutes) WebApiUrl(urlPath string) string {
return u.String()
}
func (r ProjectRoutes) UserResetPassword(resetId string) string {
func (r ProjectRoutes) UserResetPassword(resetHash string) string {
u := r.webAppUrl
u.Path = "/user/reset-password/" + resetId
u.Path = "/user/reset-password/" + resetHash
return u.String()
}
func (r ProjectRoutes) UserInviteAccept(inviteHash string) string {
u := r.webAppUrl
u.Path = "/users/invite/" + inviteHash
return u.String()
}
\ No newline at end of file
......@@ -3,7 +3,6 @@ package invite
import (
"context"
"fmt"
"strconv"
"strings"
"time"
......@@ -15,7 +14,6 @@ import (
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"github.com/sudo-suhas/symcrypto"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)
......@@ -149,30 +147,15 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r
var inviteHashes []string
for email, userID := range emailUserIDs {
// Generate a string that embeds additional information.
hashPts := []string{
userID,
strconv.Itoa(int(now.UTC().Unix())),
strconv.Itoa(int(now.UTC().Add(req.TTL).Unix())),
requestIp,
}
hashStr := strings.Join(hashPts, "|")
// This returns the nonce appended with the encrypted string.
crypto, err := symcrypto.New(secretKey)
if err != nil {
return nil, errors.WithStack(err)
}
encrypted, err := crypto.Encrypt(hashStr)
hash, err := NewInviteHash(ctx, secretKey, userID, requestIp, req.TTL, now)
if err != nil {
return nil, errors.WithStack(err)
return nil, err
}
data := map[string]interface{}{
"FromUser": fromUser.Response(ctx),
"Account": account.Response(ctx),
"Url": resetUrl(encrypted),
"Url": resetUrl(hash),
"Minutes": req.TTL.Minutes(),
}
......@@ -184,7 +167,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r
return nil, err
}
inviteHashes = append(inviteHashes, encrypted)
inviteHashes = append(inviteHashes, hash)
}
return inviteHashes, nil
......@@ -203,32 +186,8 @@ func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest,
return err
}
crypto, err := symcrypto.New(secretKey)
if err != nil {
return errors.WithStack(err)
}
hashStr, err := crypto.Decrypt(req.InviteHash)
hash, err := ParseInviteHash(ctx, secretKey, req.InviteHash, now)
if err != nil {
return errors.WithStack(err)
}
hashPts := strings.Split(hashStr, "|")
var hash InviteHash
if len(hashPts) == 4 {
hash.UserID = hashPts[0]
hash.CreatedAt, _ = strconv.Atoi(hashPts[1])
hash.ExpiresAt, _ = strconv.Atoi(hashPts[2])
hash.RequestIP = hashPts[3]
}
// Validate the hash.
err = v.StructCtx(ctx, hash)
if err != nil {
return err
}
if int64(hash.ExpiresAt) < now.UTC().Unix() {
err = errors.WithMessage(ErrInviteExpired, "Invite has expired.")
return err
}
......@@ -251,6 +210,7 @@ func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest,
err = user.Update(ctx, auth.Claims{}, dbConn, user.UserUpdateRequest{
ID: hash.UserID,
Email: &req.Email,
FirstName: &req.FirstName,
LastName: &req.LastName,
Timezone: req.Timezone,
......
package invite
import (
"context"
"strconv"
"strings"
"time"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"github.com/pkg/errors"
"github.com/sudo-suhas/symcrypto"
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
)
......@@ -26,9 +32,69 @@ type InviteHash struct {
// AcceptInviteRequest defines the fields need to complete an invite request.
type AcceptInviteRequest struct {
InviteHash string `json:"invite_hash" validate:"required" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
Email string `json:"email" validate:"required,email" example:"[email protected]"`
FirstName string `json:"first_name" validate:"required" example:"Gabi"`
LastName string `json:"last_name" validate:"required" example:"May"`
Password string `json:"password" validate:"required" example:"SecretString"`
PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"SecretString"`
Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"`
}
// NewInviteHash generates a new encrypt invite hash that is web safe for use in URLs.
func NewInviteHash(ctx context.Context, secretKey string, userID, requestIp string, ttl time.Duration, now time.Time) (string, error) {
// Generate a string that embeds additional information.
hashPts := []string{
userID,
strconv.Itoa(int(now.UTC().Unix())),
strconv.Itoa(int(now.UTC().Add(ttl).Unix())),
requestIp,
}
hashStr := strings.Join(hashPts, "|")
// This returns the nonce appended with the encrypted string.
crypto, err := symcrypto.New(secretKey)
if err != nil {
return "", errors.WithStack(err)
}
encrypted, err := crypto.Encrypt(hashStr)
if err != nil {
return "", errors.WithStack(err)
}
return encrypted, nil
}
// ParseInviteHash extracts the details encrypted in the hash string.
func ParseInviteHash(ctx context.Context, secretKey string, str string, now time.Time) (*InviteHash, error) {
crypto, err := symcrypto.New(secretKey)
if err != nil {
return nil, errors.WithStack(err)
}
hashStr, err := crypto.Decrypt(str)
if err != nil {
return nil, errors.WithStack(err)
}
hashPts := strings.Split(hashStr, "|")
var hash InviteHash
if len(hashPts) == 4 {
hash.UserID = hashPts[0]
hash.CreatedAt, _ = strconv.Atoi(hashPts[1])
hash.ExpiresAt, _ = strconv.Atoi(hashPts[2])
hash.RequestIP = hashPts[3]
}
// Validate the hash.
err = webcontext.Validator().StructCtx(ctx, hash)
if err != nil {
return nil, err
}
if int64(hash.ExpiresAt) < now.UTC().Unix() {
err = errors.WithMessage(ErrInviteExpired, "Invite has expired.")
return nil, err
}
return &hash, nil
}
......@@ -328,6 +328,10 @@ func (m *User) Response(ctx context.Context) *UserResponse {
Gravatar: web.NewGravatarResponse(ctx, m.Email),
}