diff --git a/cmd/web-app/handlers/projects.go b/cmd/web-app/handlers/projects.go index a387615..630465d 100644 --- a/cmd/web-app/handlers/projects.go +++ b/cmd/web-app/handlers/projects.go @@ -49,7 +49,7 @@ func (h *Projects) Index(ctx context.Context, w http.ResponseWriter, r *http.Req return err } - statusOpts := web.NewEnumResponse(ctx, nil, project.ProjectStatus_ValuesInterface()...) + statusOpts := web.NewEnumResponse(ctx, nil, project.ProjectStatus_ValuesInterface()...) statusFilterItems := []datatable.FilterOptionItem{} for _, opt := range statusOpts.Options { diff --git a/cmd/web-app/handlers/user.go b/cmd/web-app/handlers/user.go index b301337..b8ff5e4 100644 --- a/cmd/web-app/handlers/user.go +++ b/cmd/web-app/handlers/user.go @@ -58,12 +58,12 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request if r.Method == http.MethodPost { err := r.ParseForm() if err != nil { - return false,err + return false, err } decoder := schema.NewDecoder() if err := decoder.Decode(req, r.PostForm); err != nil { - return false,err + return false, err } sessionTTL := time.Hour @@ -76,16 +76,16 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request if err != nil { switch errors.Cause(err) { case user.ErrForbidden: - return false,web.RespondError(ctx, w, weberror.NewError(ctx, err, http.StatusForbidden)) + return false, web.RespondError(ctx, w, weberror.NewError(ctx, err, http.StatusForbidden)) case user_auth.ErrAuthenticationFailure: data["error"] = weberror.NewErrorMessage(ctx, err, http.StatusUnauthorized, "Authentication failure. Try again.") return false, nil default: if verr, ok := weberror.NewValidationError(ctx, err); ok { data["validationErrors"] = verr.(*weberror.Error) - return false,nil + return false, nil } else { - return false,err + return false, err } } } @@ -93,14 +93,14 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request // Add the token to the users session. err = handleSessionToken(ctx, h.MasterDB, w, r, token) if err != nil { - return false,err + return false, err } redirectUri := "/" if qv := r.URL.Query().Get("redirect"); qv != "" { redirectUri, err = url.QueryUnescape(qv) if err != nil { - return false,err + return false, err } } @@ -426,7 +426,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques req.FirstName = &usr.FirstName req.LastName = &usr.LastName req.Email = &usr.Email - req.Timezone = &usr.Timezone + req.Timezone = usr.Timezone } data["user"] = usr.Response(ctx) diff --git a/cmd/web-app/handlers/users.go b/cmd/web-app/handlers/users.go index fffa20e..9be188b 100644 --- a/cmd/web-app/handlers/users.go +++ b/cmd/web-app/handlers/users.go @@ -14,6 +14,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" + "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" "github.com/dustin/go-humanize/english" "github.com/gorilla/schema" "github.com/jmoiron/sqlx" @@ -21,6 +22,7 @@ import ( "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" "net/http" "strings" + "time" ) // Users represents the Users API method handler set. @@ -64,7 +66,7 @@ func (h *Users) Index(ctx context.Context, w http.ResponseWriter, r *http.Reques return err } - statusOpts := web.NewEnumResponse(ctx, nil, user_account.UserAccountStatus_ValuesInterface()) + statusOpts := web.NewEnumResponse(ctx, nil, user_account.UserAccountStatus_ValuesInterface()...) statusFilterItems := []datatable.FilterOptionItem{} for _, opt := range statusOpts.Options { @@ -471,7 +473,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque req.FirstName = &usr.FirstName req.LastName = &usr.LastName req.Email = &usr.Email - req.Timezone = &usr.Timezone + req.Timezone = usr.Timezone } data["user"] = usr.Response(ctx) @@ -552,7 +554,6 @@ func (h *Users) Invite(ctx context.Context, w http.ResponseWriter, r *http.Reque "No users were invited.") } - err = webcontext.ContextSession(ctx).Save(r, w) if err != nil { return false, err @@ -616,9 +617,30 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http // Append the query param value to the request. req.InviteHash = inviteHash - err = invite.AcceptInvite(ctx, h.MasterDB, *req, h.SecretKey, ctxValues.Now) + userID, err := invite.AcceptInvite(ctx, h.MasterDB, *req, h.SecretKey, 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.ErrUserAccountActive: + webcontext.SessionFlashError(ctx, + "User already Active", + "The user already is already active for the account. Try to login or use forgot password.") + http.Redirect(w, r, "/user/login", http.StatusFound) + return true, 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 + case user_account.ErrNotFound: + return false, err + case invite.ErrNoPendingInvite: + return false, err default: if verr, ok := weberror.NewValidationError(ctx, err); ok { data["validationErrors"] = verr.(*weberror.Error) @@ -629,20 +651,20 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http } } - /* - // 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) + // Load the user without any claims applied. + usr, err := user.ReadByID(ctx, auth.Claims{}, h.MasterDB, userID) 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 - } + 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, usr.Email, req.Password, time.Hour, ctxValues.Now) + if err != nil { + if verr, ok := weberror.NewValidationError(ctx, err); ok { + data["validationErrors"] = verr.(*weberror.Error) + return false, nil + } else { + return false, err } } @@ -651,15 +673,13 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http 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) + hash, err := invite.ParseInviteHash(ctx, h.SecretKey, inviteHash, ctxValues.Now) if err != nil { switch errors.Cause(err) { case invite.ErrInviteExpired: @@ -674,7 +694,12 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http http.Redirect(w, r, "/user/login", http.StatusFound) return true, nil default: - return false, err + if verr, ok := weberror.NewValidationError(ctx, err); ok { + data["validationErrors"] = verr.(*weberror.Error) + return false, nil + } else { + return false, err + } } } @@ -689,10 +714,7 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http req.FirstName = usr.FirstName req.LastName = usr.LastName req.Email = usr.Email - - if usr.Timezone != "" { - req.Timezone = &usr.Timezone - } + req.Timezone = usr.Timezone } return false, nil @@ -705,11 +727,16 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http return nil } + data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB) + if err != nil { + return err + } + 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) + return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "users-invite-accept.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data) } diff --git a/cmd/web-app/templates/content/users-invite-accept.gohtml b/cmd/web-app/templates/content/users-invite-accept.gohtml index 87d794a..de5f1e0 100644 --- a/cmd/web-app/templates/content/users-invite-accept.gohtml +++ b/cmd/web-app/templates/content/users-invite-accept.gohtml @@ -28,22 +28,67 @@ {{ template "validation-error" . }}
- -
-
- - {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Password" }} -
-
- - {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "PasswordConfirm" }} + +
+
+ +
+
+
+ + + {{template "invalid-feedback" dict "validationDefaults" $.userValidationDefaults "validationErrors" $.validationErrors "fieldName" "FirstName" }} +
+
+ + + {{template "invalid-feedback" dict "validationDefaults" $.userValidationDefaults "validationErrors" $.validationErrors "fieldName" "LastName" }} +
+
+ + + {{template "invalid-feedback" dict "validationDefaults" $.userValidationDefaults "validationErrors" $.validationErrors "fieldName" "Email" }} +
+
+ + + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Timezone" }} +
+ +
+ + + Generate random password + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Password" }} +
+ +
+ + + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "PasswordConfirm" }} +
+ +
+
+ +
+
+ + Cancel +
+
+ +
- -
+ +
@@ -58,8 +103,25 @@ {{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 9fe7881..b2fd123 100644 --- a/internal/platform/web/models.go +++ b/internal/platform/web/models.go @@ -121,7 +121,7 @@ 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 + var er EnumMultiResponse for _, opt := range options { optStr := fmt.Sprintf("%s", opt) @@ -137,7 +137,6 @@ func NewEnumMultiResponse(ctx context.Context, selected []interface{}, options . } } - er = append(er, opt) } diff --git a/internal/project-routes/project_routes.go b/internal/project-routes/project_routes.go index f670139..18c7021 100644 --- a/internal/project-routes/project_routes.go +++ b/internal/project-routes/project_routes.go @@ -50,4 +50,4 @@ 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/schema/migrations.go b/internal/schema/migrations.go index f0eac2b..730c403 100644 --- a/internal/schema/migrations.go +++ b/internal/schema/migrations.go @@ -585,5 +585,25 @@ func migrationList(db *sqlx.DB, log *log.Logger, isUnittest bool) []*sqlxmigrate return nil }, }, + // Remove default value for users.timezone. + { + ID: "20190805-01", + Migrate: func(tx *sql.Tx) error { + q1 := `ALTER TABLE users ALTER COLUMN timezone DROP DEFAULT` + if _, err := tx.Exec(q1); err != nil { + return errors.WithMessagef(err, "Query failed %s", q1) + } + + q2 := `ALTER TABLE users ALTER COLUMN timezone DROP NOT NULL` + if _, err := tx.Exec(q2); err != nil { + return errors.WithMessagef(err, "Query failed %s", q2) + } + + return nil + }, + Rollback: func(tx *sql.Tx) error { + return nil + }, + }, } } diff --git a/internal/user/models.go b/internal/user/models.go index 7c00b2d..fe64b74 100644 --- a/internal/user/models.go +++ b/internal/user/models.go @@ -19,7 +19,7 @@ type User struct { PasswordSalt string `json:"-" validate:"required"` PasswordHash []byte `json:"-" validate:"required"` PasswordReset *sql.NullString `json:"-"` - Timezone string `json:"timezone" validate:"omitempty" example:"America/Anchorage"` + Timezone *string `json:"timezone" validate:"omitempty" example:"America/Anchorage"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ArchivedAt *pq.NullTime `json:"archived_at,omitempty"` @@ -52,12 +52,15 @@ func (m *User) Response(ctx context.Context) *UserResponse { FirstName: m.FirstName, LastName: m.LastName, Email: m.Email, - Timezone: m.Timezone, CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt), UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt), Gravatar: web.NewGravatarResponse(ctx, m.Email), } + if m.Timezone != nil { + r.Timezone = *m.Timezone + } + if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() { at := web.NewTimeResponse(ctx, m.ArchivedAt.Time) r.ArchivedAt = &at diff --git a/internal/user/user.go b/internal/user/user.go index 8af4742..96fc430 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -280,6 +280,10 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCr span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Create") defer span.Finish() + if req.Timezone != nil && *req.Timezone == "" { + req.Timezone = nil + } + v := webcontext.Validator() // Validation email address is unique in the database. @@ -330,17 +334,13 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCr FirstName: req.FirstName, LastName: req.LastName, Email: req.Email, + Timezone: req.Timezone, PasswordHash: passwordHash, PasswordSalt: passwordSalt, - Timezone: "America/Anchorage", CreatedAt: now, UpdatedAt: now, } - if req.Timezone != nil { - u.Timezone = *req.Timezone - } - // Build the insert SQL statement. query := sqlbuilder.NewInsertBuilder() query.InsertInto(userTableName) @@ -542,8 +542,8 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUp if req.Email != nil { fields = append(fields, query.Assign("email", req.Email)) } - if req.Timezone != nil { - fields = append(fields, query.Assign("timezone", req.Timezone)) + if req.Timezone != nil && *req.Timezone != "" { + fields = append(fields, query.Assign("timezone", *req.Timezone)) } // If there's nothing to update we can quit early. diff --git a/internal/user_account/invite/invite.go b/internal/user_account/invite/invite.go index 986e3b1..e457721 100644 --- a/internal/user_account/invite/invite.go +++ b/internal/user_account/invite/invite.go @@ -21,6 +21,12 @@ var ( // ErrInviteExpired occurs when the the reset hash exceeds the expiration. ErrInviteExpired = errors.New("Invite expired") + // ErrNoPendingInvite occurs when the user does not have an entry in user_accounts with status pending. + ErrNoPendingInvite = errors.New("No pending invite.") + + // ErrUserAccountActive occurs when the user already has an active user_account entry. + ErrUserAccountActive = errors.New("User already active.") + // ErrInviteUserPasswordSet occurs when the the reset hash exceeds the expiration. ErrInviteUserPasswordSet = errors.New("User password set") ) @@ -147,7 +153,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r var inviteHashes []string for email, userID := range emailUserIDs { - hash, err := NewInviteHash(ctx, secretKey, userID, requestIp, req.TTL, now) + hash, err := NewInviteHash(ctx, secretKey, userID, req.AccountID, requestIp, req.TTL, now) if err != nil { return nil, err } @@ -174,7 +180,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r } // AcceptInvite updates the user using the provided invite hash. -func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest, secretKey string, now time.Time) error { +func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest, secretKey string, now time.Time) (string, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.AcceptInvite") defer span.Finish() @@ -183,40 +189,59 @@ func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest, // Validate the request. err := v.StructCtx(ctx, req) if err != nil { - return err + return "", err } hash, err := ParseInviteHash(ctx, secretKey, req.InviteHash, now) if err != nil { - return err + return "", err } u, err := user.Read(ctx, auth.Claims{}, dbConn, user.UserReadRequest{ID: hash.UserID, IncludeArchived: true}) if err != nil { - return err + return "", err } if u.ArchivedAt != nil && !u.ArchivedAt.Time.IsZero() { err = user.Restore(ctx, auth.Claims{}, dbConn, user.UserRestoreRequest{ID: hash.UserID}, now) if err != nil { - return err + return "", err } - } else if len(u.PasswordHash) > 0 { - // Do not update the password for a user that already has a password set. - err = errors.WithMessage(ErrInviteUserPasswordSet, "Invite user already has a password set.") - return err } + usrAcc, err := user_account.Read(ctx, auth.Claims{}, dbConn, user_account.UserAccountReadRequest{ + UserID: hash.UserID, + AccountID: hash.AccountID, + }) + if err != nil { + return "", nil + } + + // Ensure the entry has the status of invited. + if usrAcc.Status != user_account.UserAccountStatus_Invited { + // If the entry is already active + if usrAcc.Status == user_account.UserAccountStatus_Active { + return u.ID, errors.WithStack(ErrUserAccountActive) + } + return "", errors.WithStack(ErrNoPendingInvite) + } + + if len(u.PasswordHash) > 0 { + // Do not update the password for a user that already has a password set. + return "", errors.WithStack(ErrInviteUserPasswordSet) + } + + // These two calls, user.Update and user.UpdatePassword should probably be in a transaction! err = user.Update(ctx, auth.Claims{}, dbConn, user.UserUpdateRequest{ ID: hash.UserID, - Email: &req.Email, + Email: &req.Email, FirstName: &req.FirstName, LastName: &req.LastName, Timezone: req.Timezone, }, now) if err != nil { - return err + return "", err } err = user.UpdatePassword(ctx, auth.Claims{}, dbConn, user.UserUpdatePasswordRequest{ @@ -225,8 +250,18 @@ func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest, PasswordConfirm: req.PasswordConfirm, }, now) if err != nil { - return err + return "", err } - return nil + activeStatus := user_account.UserAccountStatus_Active + err = user_account.Update(ctx, auth.Claims{}, dbConn, user_account.UserAccountUpdateRequest{ + UserID: usrAcc.UserID, + AccountID: usrAcc.AccountID, + Status: &activeStatus, + }, now) + if err != nil { + return "", err + } + + return hash.UserID, nil } diff --git a/internal/user_account/invite/models.go b/internal/user_account/invite/models.go index 696edfd..2536aa9 100644 --- a/internal/user_account/invite/models.go +++ b/internal/user_account/invite/models.go @@ -2,14 +2,15 @@ package invite import ( "context" + "fmt" "strconv" "strings" "time" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" "github.com/pkg/errors" "github.com/sudo-suhas/symcrypto" - "geeks-accelerator/oss/saas-starter-kit/internal/user_account" ) // SendUserInvitesRequest defines the data needed to make an invite request. @@ -24,6 +25,7 @@ type SendUserInvitesRequest struct { // InviteHash type InviteHash struct { UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` CreatedAt int `json:"created_at" validate:"required"` ExpiresAt int `json:"expires_at" validate:"required"` RequestIP string `json:"request_ip" validate:"required,ip" example:"69.56.104.36"` @@ -32,7 +34,7 @@ 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"` + 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"` @@ -41,17 +43,17 @@ type AcceptInviteRequest struct { } // 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) { +func NewInviteHash(ctx context.Context, secretKey, userID, accountID, requestIp string, ttl time.Duration, now time.Time) (string, error) { // Generate a string that embeds additional information. hashPts := []string{ userID, + accountID, 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 { @@ -77,12 +79,15 @@ func ParseInviteHash(ctx context.Context, secretKey string, str string, now time } hashPts := strings.Split(hashStr, "|") + fmt.Println(hashPts) + var hash InviteHash - if len(hashPts) == 4 { + if len(hashPts) == 5 { hash.UserID = hashPts[0] - hash.CreatedAt, _ = strconv.Atoi(hashPts[1]) - hash.ExpiresAt, _ = strconv.Atoi(hashPts[2]) - hash.RequestIP = hashPts[3] + hash.AccountID = hashPts[1] + hash.CreatedAt, _ = strconv.Atoi(hashPts[2]) + hash.ExpiresAt, _ = strconv.Atoi(hashPts[3]) + hash.RequestIP = hashPts[4] } // Validate the hash. diff --git a/internal/user_account/models.go b/internal/user_account/models.go index a9571ed..610c17a 100644 --- a/internal/user_account/models.go +++ b/internal/user_account/models.go @@ -280,7 +280,7 @@ type User struct { FirstName string `json:"first_name" validate:"required" example:"Gabi"` LastName string `json:"last_name" validate:"required" example:"May"` Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"` - Timezone string `json:"timezone" validate:"omitempty" example:"America/Anchorage"` + Timezone *string `json:"timezone" validate:"omitempty" example:"America/Anchorage"` AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` Roles UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"admin"` Status UserAccountStatus `json:"status" validate:"omitempty,oneof=active invited disabled" enums:"active,invited,disabled" swaggertype:"string" example:"active"` @@ -319,7 +319,6 @@ func (m *User) Response(ctx context.Context) *UserResponse { FirstName: m.FirstName, LastName: m.LastName, Email: m.Email, - Timezone: m.Timezone, AccountID: m.AccountID, Roles: m.Roles, Status: web.NewEnumResponse(ctx, m.Status, UserAccountStatus_Values), @@ -328,6 +327,10 @@ func (m *User) Response(ctx context.Context) *UserResponse { Gravatar: web.NewGravatarResponse(ctx, m.Email), } + if m.Timezone != nil { + r.Timezone = *m.Timezone + } + if r.Name == "" { r.Name = r.Email }