diff --git a/cmd/web-app/handlers/projects.go b/cmd/web-app/handlers/projects.go index c05202c..a387615 100644 --- a/cmd/web-app/handlers/projects.go +++ b/cmd/web-app/handlers/projects.go @@ -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 { diff --git a/cmd/web-app/handlers/routes.go b/cmd/web-app/handlers/routes.go index ed82119..f14b91e 100644 --- a/cmd/web-app/handlers/routes.go +++ b/cmd/web-app/handlers/routes.go @@ -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()) diff --git a/cmd/web-app/handlers/user.go b/cmd/web-app/handlers/user.go index 71fd087..b301337 100644 --- a/cmd/web-app/handlers/user.go +++ b/cmd/web-app/handlers/user.go @@ -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 diff --git a/cmd/web-app/handlers/users.go b/cmd/web-app/handlers/users.go index 21035ea..e117244 100644 --- a/cmd/web-app/handlers/users.go +++ b/cmd/web-app/handlers/users.go @@ -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) +} diff --git a/cmd/web-app/templates/content/projects-update.gohtml b/cmd/web-app/templates/content/projects-update.gohtml index 4649bc5..0a2325f 100644 --- a/cmd/web-app/templates/content/projects-update.gohtml +++ b/cmd/web-app/templates/content/projects-update.gohtml @@ -16,7 +16,7 @@ diff --git a/cmd/web-app/templates/content/users-invite-accept.gohtml b/cmd/web-app/templates/content/users-invite-accept.gohtml new file mode 100644 index 0000000..87d794a --- /dev/null +++ b/cmd/web-app/templates/content/users-invite-accept.gohtml @@ -0,0 +1,65 @@ +{{define "title"}}Invite Accept{{end}} +{{define "description"}}{{end}} +{{define "style"}} + +{{end}} +{{ define "partials/app-wrapper" }} +
+ + +
+ +
+ +
+
+ +
+ +
+
+ {{ template "app-flashes" . }} + +
+

Invite Accept

+

.....

+
+ + {{ template "validation-error" . }} + +
+ +
+
+ + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Password" }} +
+
+ + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "PasswordConfirm" }} +
+
+ +
+
+
+
+
+
+
+ +
+ +
+ +
+{{end}} +{{define "js"}} + +{{end}} \ No newline at end of file diff --git a/cmd/web-app/templates/content/users-invite.gohtml b/cmd/web-app/templates/content/users-invite.gohtml new file mode 100644 index 0000000..7b18d55 --- /dev/null +++ b/cmd/web-app/templates/content/users-invite.gohtml @@ -0,0 +1,86 @@ +{{define "title"}}Invite Users{{end}} +{{define "content"}} +
+ +
+
+
+
+
+ + +
+ +
+
+
+
+
+
+
+ + + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Roles" }} +
+
+
+
+
+ +
+ +
+
+ +
+
+ +
+{{end}} +{{ define "js" }} + +{{ end }} \ No newline at end of file diff --git a/internal/platform/web/models.go b/internal/platform/web/models.go index 5d4d7ab..9fe7881 100644 --- a/internal/platform/web/models.go +++ b/internal/platform/web/models.go @@ -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) diff --git a/internal/project-routes/project_routes.go b/internal/project-routes/project_routes.go index fb406d7..f670139 100644 --- a/internal/project-routes/project_routes.go +++ b/internal/project-routes/project_routes.go @@ -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 diff --git a/internal/user_account/invite/invite.go b/internal/user_account/invite/invite.go index 3e920bf..986e3b1 100644 --- a/internal/user_account/invite/invite.go +++ b/internal/user_account/invite/invite.go @@ -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) + hash, err := NewInviteHash(ctx, secretKey, userID, requestIp, req.TTL, now) if err != nil { - return nil, errors.WithStack(err) - } - encrypted, err := crypto.Encrypt(hashStr) - 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) + hash, err := ParseInviteHash(ctx, secretKey, req.InviteHash, now) if err != nil { - return errors.WithStack(err) - } - hashStr, err := crypto.Decrypt(req.InviteHash) - 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, diff --git a/internal/user_account/invite/models.go b/internal/user_account/invite/models.go index c72dd40..696edfd 100644 --- a/internal/user_account/invite/models.go +++ b/internal/user_account/invite/models.go @@ -1,8 +1,14 @@ 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:"gabi@geeksinthewoods.com"` 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 +} diff --git a/internal/user_account/models.go b/internal/user_account/models.go index 91028df..a9571ed 100644 --- a/internal/user_account/models.go +++ b/internal/user_account/models.go @@ -328,6 +328,10 @@ func (m *User) Response(ctx context.Context) *UserResponse { Gravatar: web.NewGravatarResponse(ctx, m.Email), } + if r.Name == "" { + r.Name = r.Email + } + if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() { at := web.NewTimeResponse(ctx, m.ArchivedAt.Time) r.ArchivedAt = &at