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" }} +
.....
+