From a616249be31ed87eae0389cede15440a83d5660a Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Mon, 5 Aug 2019 01:13:03 -0800 Subject: [PATCH] completed user management --- cmd/web-app/handlers/routes.go | 20 +- cmd/web-app/handlers/users.go | 274 +++++++++++++++--- .../templates/content/users-create.gohtml | 91 ++++++ .../templates/content/users-index.gohtml | 1 + .../templates/content/users-view.gohtml | 8 +- cmd/web-app/templates/partials/datatable.tmpl | 6 +- internal/platform/datatable/datatable.go | 22 +- internal/signup/signup.go | 4 - internal/user/user.go | 4 + internal/user_account/models.go | 28 +- internal/user_account/user.go | 124 +++++++- internal/user_account/user_test.go | 10 + 12 files changed, 507 insertions(+), 85 deletions(-) create mode 100644 cmd/web-app/templates/content/users-create.gohtml create mode 100644 internal/user_account/user_test.go diff --git a/cmd/web-app/handlers/routes.go b/cmd/web-app/handlers/routes.go index b627d95..8b9f70c 100644 --- a/cmd/web-app/handlers/routes.go +++ b/cmd/web-app/handlers/routes.go @@ -40,7 +40,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir // Construct the web.App which holds all routes as well as common Middleware. app := web.NewApp(shutdown, log, env, middlewares...) - // Register project management pages. p := Projects{ MasterDB: masterDB, @@ -48,22 +47,23 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir } app.Handle("GET", "/projects", p.Index, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) - // Register user management pages. us := Users{ - MasterDB: masterDB, - Redis: redis, + MasterDB: masterDB, + Redis: redis, Renderer: renderer, Authenticator: authenticator, ProjectRoutes: projectRoutes, NotifyEmail: notifyEmail, SecretKey: secretKey, } - app.Handle("GET", "/users", us.Index, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("POST", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("GET", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/users/:user_id", us.View, 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/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.HasRole(auth.RoleAdmin)) // Register user management and authentication endpoints. u := User{ @@ -94,7 +94,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir app.Handle("POST", "/user", u.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) app.Handle("GET", "/user", u.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) - // Register account management endpoints. acc := Account{ MasterDB: masterDB, @@ -106,7 +105,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir app.Handle("POST", "/account", acc.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("GET", "/account", acc.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - // Register user management and authentication endpoints. s := Signup{ MasterDB: masterDB, @@ -125,7 +123,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir app.Handle("GET", "/examples/flash-messages", ex.FlashMessages) app.Handle("GET", "/examples/images", ex.Images) - // Register geo g := Geo{ MasterDB: masterDB, @@ -136,7 +133,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir app.Handle("GET", "/geo/geonames/postal_code/:postalCode", g.GeonameByPostalCode) app.Handle("GET", "/geo/country/:countryCode/timezones", g.CountryTimezones) - // Register root r := Root{ MasterDB: masterDB, @@ -152,7 +148,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir app.Handle("GET", "/index.html", r.IndexHtml) app.Handle("GET", "/robots.txt", r.RobotTxt) - // Register health check endpoint. This route is not authenticated. check := Check{ MasterDB: masterDB, @@ -161,7 +156,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir } app.Handle("GET", "/v1/health", check.Health) - // Handle static files/pages. Render a custom 404 page when file not found. static := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { err := web.StaticHandler(ctx, w, r, params, staticDir, "") diff --git a/cmd/web-app/handlers/users.go b/cmd/web-app/handlers/users.go index 4d98d73..21035ea 100644 --- a/cmd/web-app/handlers/users.go +++ b/cmd/web-app/handlers/users.go @@ -6,9 +6,9 @@ import ( "net/http" "strings" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/datatable" "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" "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" @@ -25,7 +25,7 @@ import ( // Users represents the Users API method handler set. type Users struct { MasterDB *sqlx.DB - Redis *redis.Client + Redis *redis.Client Renderer web.Renderer Authenticator *auth.Authenticator ProjectRoutes project_routes.ProjectRoutes @@ -33,10 +33,28 @@ type Users struct { SecretKey string } -func UrlUsersView(userID string) string { +func urlUsersIndex() string { + return fmt.Sprintf("/users") +} + +func urlUsersCreate() string { + return fmt.Sprintf("/users/create") +} + +func urlUsersView(userID string) string { return fmt.Sprintf("/users/%s", userID) } +func urlUsersUpdate(userID string) string { + return fmt.Sprintf("/users/%s/update", userID) +} + +// UserLoginRequest extends the AuthenicateRequest with the RememberMe flag. +type UserCreateRequest struct { + user.UserCreateRequest + Roles user_account.UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"admin"` +} + // Index handles listing all the users for the current account. func (h *Users) Index(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { @@ -77,7 +95,25 @@ func (h *Users) Index(ctx context.Context, w http.ResponseWriter, r *http.Reques v.Value = fmt.Sprintf("%d", q.ID) case "name": v.Value = q.Name - v.Formatted = fmt.Sprintf("%s", UrlUsersView(q.ID), v.Value) + v.Formatted = fmt.Sprintf("%s", urlUsersView(q.ID), v.Value) + case "status": + v.Value = q.Status.String() + + var subStatusClass string + var subStatusIcon string + switch q.Status { + case user_account.UserAccountStatus_Active: + subStatusClass = "text-green" + subStatusIcon = "far fa-dot-circle" + case user_account.UserAccountStatus_Invited: + subStatusClass = "text-blue" + subStatusIcon = "far fa-unicorn" + case user_account.UserAccountStatus_Disabled: + subStatusClass = "text-orange" + subStatusIcon = "far fa-circle" + } + + v.Formatted = fmt.Sprintf("%s", subStatusClass, subStatusIcon, web.EnumValueTitle(v.Value)) case "created_at": dt := web.NewTimeResponse(ctx, q.CreatedAt) v.Value = dt.Local @@ -97,7 +133,8 @@ func (h *Users) Index(ctx context.Context, w http.ResponseWriter, r *http.Reques loadFunc := func(ctx context.Context, sorting string, fields []datatable.DisplayField) (resp [][]datatable.ColumnValue, err error) { res, err := user_account.UserFindByAccount(ctx, claims, h.MasterDB, user_account.UserFindByAccountRequest{ - Order: strings.Split(sorting, ","), + AccountID: claims.Audience, + Order: strings.Split(sorting, ","), }) if err != nil { return resp, err @@ -132,55 +169,217 @@ func (h *Users) Index(ctx context.Context, w http.ResponseWriter, r *http.Reques } data := map[string]interface{}{ - "datatable": dt.Response(), + "datatable": dt.Response(), + "urlUsersCreate": urlUsersCreate(), } return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "users-index.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data) } -// View handles displaying a user. -func (h *Users) View(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +// Create handles creating a new user for the account. +func (h *Users) Create(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(UserCreateRequest) data := make(map[string]interface{}) - f := func() error { - - claims, err := auth.ClaimsFromContext(ctx) - if err != nil { - return err - } - - usr, err := user.ReadByID(ctx, claims, h.MasterDB, claims.Subject) - if err != nil { - return err - } - - data["user"] = usr.Response(ctx) - - usrAccs, err := user_account.FindByUserID(ctx, claims, h.MasterDB, claims.Subject, false) - if err != nil { - return err - } - - for _, usrAcc := range usrAccs { - if usrAcc.AccountID == claims.Audience { - data["userAccount"] = usrAcc.Response(ctx) - break + f := func() (bool, error) { + if r.Method == http.MethodPost { + err := r.ParseForm() + if err != nil { + return false, err } + + decoder := schema.NewDecoder() + decoder.IgnoreUnknownKeys(true) + + if err := decoder.Decode(req, r.PostForm); err != nil { + return false, err + } + + // Bypass the uniq check on email here for the moment, it will be caught before the user_account is + // created by user.Create. + ctx = context.WithValue(ctx, webcontext.KeyTagUnique, true) + + // Validate the request. + err = webcontext.Validator().StructCtx(ctx, req) + if err != nil { + if verr, ok := weberror.NewValidationError(ctx, err); ok { + data["validationErrors"] = verr.(*weberror.Error) + return false, nil + } else { + return false, err + } + } + + usr, err := user.Create(ctx, claims, h.MasterDB, req.UserCreateRequest, 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 + } + } + } + + uaStatus := user_account.UserAccountStatus_Active + _, err = user_account.Create(ctx, claims, h.MasterDB, user_account.UserAccountCreateRequest{ + UserID: usr.ID, + AccountID: claims.Audience, + Roles: req.Roles, + Status: &uaStatus, + }, 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. + webcontext.SessionFlashSuccess(ctx, + "User Created", + "User successfully created.") + err = webcontext.ContextSession(ctx).Save(r, w) + if err != nil { + return false, err + } + + http.Redirect(w, r, urlUsersView(usr.ID), 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 } - if err := f(); err != nil { - return web.RenderError(ctx, w, r, err, h.Renderer, TmplLayoutBase, TmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8) + data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB) + if err != nil { + return err } + var roleValues []interface{} + for _, v := range user_account.UserAccountRole_Values { + roleValues = append(roleValues, string(v)) + } + data["roles"] = web.NewEnumResponse(ctx, nil, roleValues...) + + data["form"] = req + + if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(UserCreateRequest{})); ok { + data["validationDefaults"] = verr.(*weberror.Error) + } + + return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "users-create.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data) +} + +// View handles displaying a user. +func (h *Users) View(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { + + userID := params["user_id"] + + ctxValues, err := webcontext.ContextValues(ctx) + if err != nil { + return err + } + + claims, err := auth.ClaimsFromContext(ctx) + if err != nil { + return err + } + + data := make(map[string]interface{}) + f := func() (bool, error) { + if r.Method == http.MethodPost { + err := r.ParseForm() + if err != nil { + return false, err + } + + switch r.PostForm.Get("action") { + case "archive": + err = user.Archive(ctx, claims, h.MasterDB, user.UserArchiveRequest{ + ID: userID, + }, ctxValues.Now) + if err != nil { + return false, err + } + + webcontext.SessionFlashSuccess(ctx, + "User Archive", + "User successfully archive.") + 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 + } + + usr, err := user.ReadByID(ctx, claims, h.MasterDB, userID) + if err != nil { + return err + } + + data["user"] = usr.Response(ctx) + + usrAccs, err := user_account.FindByUserID(ctx, claims, h.MasterDB, userID, false) + if err != nil { + return err + } + + for _, usrAcc := range usrAccs { + if usrAcc.AccountID == claims.Audience { + data["userAccount"] = usrAcc.Response(ctx) + break + } + } + + data["urlUsersUpdate"] = urlUsersUpdate(userID) + return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "users-view.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data) } // Update handles updating a user for the account. func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { + userID := params["user_id"] + ctxValues, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -207,7 +406,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque if err := decoder.Decode(req, r.PostForm); err != nil { return false, err } - req.ID = claims.Subject + req.ID = userID err = user.Update(ctx, claims, h.MasterDB, *req, ctxValues.Now) if err != nil { @@ -228,7 +427,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque if err := decoder.Decode(pwdReq, r.PostForm); err != nil { return false, err } - pwdReq.ID = claims.Subject + pwdReq.ID = userID err = user.UpdatePassword(ctx, claims, h.MasterDB, *pwdReq, ctxValues.Now) if err != nil { @@ -253,7 +452,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque return false, err } - http.Redirect(w, r, "/users/"+req.ID, http.StatusFound) + http.Redirect(w, r, urlUsersView(req.ID), http.StatusFound) return true, nil } @@ -267,7 +466,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque return nil } - usr, err := user.ReadByID(ctx, claims, h.MasterDB, claims.Subject) + usr, err := user.ReadByID(ctx, claims, h.MasterDB, userID) if err != nil { return err } @@ -298,4 +497,3 @@ 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) } - diff --git a/cmd/web-app/templates/content/users-create.gohtml b/cmd/web-app/templates/content/users-create.gohtml new file mode 100644 index 0000000..086da6f --- /dev/null +++ b/cmd/web-app/templates/content/users-create.gohtml @@ -0,0 +1,91 @@ +{{define "title"}}Create User{{end}} +{{define "style"}} + +{{end}} +{{define "content"}} +
+
+
+
+ + + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "UserCreateRequest.FirstName" }} +
+
+ + + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "UserCreateRequest.LastName" }} +
+
+ + + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "UserCreateRequest.Email" }} +
+
+ + + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "UserCreateRequest.Timezone" }} +
+
+ + + Generate random password + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "UserCreateRequest.Password" }} +
+
+ + + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "UserCreateRequest.PasswordConfirm" }} +
+ +
+ + + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Roles" }} +
+ +
+
+
+
+
+ +
+
+
+{{end}} +{{define "js"}} + +{{end}} diff --git a/cmd/web-app/templates/content/users-index.gohtml b/cmd/web-app/templates/content/users-index.gohtml index a4205fe..fb3ad91 100644 --- a/cmd/web-app/templates/content/users-index.gohtml +++ b/cmd/web-app/templates/content/users-index.gohtml @@ -1,5 +1,6 @@ {{define "title"}}Users{{end}} {{define "content"}} + Create User
diff --git a/cmd/web-app/templates/content/users-view.gohtml b/cmd/web-app/templates/content/users-view.gohtml index 373eaf6..c6521e7 100644 --- a/cmd/web-app/templates/content/users-view.gohtml +++ b/cmd/web-app/templates/content/users-view.gohtml @@ -20,7 +20,13 @@

Update Avatar

- Edit Details + Edit Details + {{ $ctxUser := ContextUser $._Ctx }} + {{ if $ctxUser }} + {{ if ne .user.ID $ctxUser.ID }} + + {{ end }} + {{ end }}
diff --git a/cmd/web-app/templates/partials/datatable.tmpl b/cmd/web-app/templates/partials/datatable.tmpl index 952535e..f7fb137 100644 --- a/cmd/web-app/templates/partials/datatable.tmpl +++ b/cmd/web-app/templates/partials/datatable.tmpl @@ -28,7 +28,11 @@ serverSide: true, ordering: true, searching: true, - ajax: "{{ .datatable.AjaxUrl }}", + ajax: { + "url": "{{ .datatable.AjaxUrl }}", + "contentType": "application/json; charset=utf-8", + "dataType": "json" + }, scrollY: 300, scroller: { loadingIndicator: true diff --git a/internal/platform/datatable/datatable.go b/internal/platform/datatable/datatable.go index da88ca2..b3d302e 100644 --- a/internal/platform/datatable/datatable.go +++ b/internal/platform/datatable/datatable.go @@ -30,9 +30,9 @@ var ( type ( Datatable struct { ctx context.Context - w http.ResponseWriter - r *http.Request - redis *redis.Client + w http.ResponseWriter + r *http.Request + redis *redis.Client fields []DisplayField loadFunc func(ctx context.Context, sorting string, fields []DisplayField) (resp [][]ColumnValue, err error) stateId string @@ -122,8 +122,8 @@ func (r Request) CacheKey() string { func ParseQueryValues(vals url.Values) (Request, error) { req := Request{ - Columns:make(map[int]Column), - Order: make(map[int]Order) , + Columns: make(map[int]Column), + Order: make(map[int]Order), } var err error @@ -182,7 +182,7 @@ func ParseQueryValues(vals url.Values) (Request, error) { return req, errors.WithMessagef(ErrInvalidColumn, "Unable to map query Column Search %s for %s", svn, kn) } default: - return req, errors.WithMessagef(ErrInvalidColumn,"Unable to map query Column %s for %s", sn, kn) + return req, errors.WithMessagef(ErrInvalidColumn, "Unable to map query Column %s for %s", sn, kn) } req.Columns[idx] = curCol case "order": @@ -235,7 +235,6 @@ func ParseQueryValues(vals url.Values) (Request, error) { } } - case "search": sn := strings.Split(pts[1], "]")[0] switch sn { @@ -276,11 +275,11 @@ func ParseQueryValues(vals url.Values) (Request, error) { return req, nil } -func New(ctx context.Context, w http.ResponseWriter, r *http.Request, redisClient *redis.Client, fields []DisplayField, loadFunc func(ctx context.Context, sorting string, fields []DisplayField) (resp [][]ColumnValue, err error)) (dt *Datatable, err error) { +func New(ctx context.Context, w http.ResponseWriter, r *http.Request, redisClient *redis.Client, fields []DisplayField, loadFunc func(ctx context.Context, sorting string, fields []DisplayField) (resp [][]ColumnValue, err error)) (dt *Datatable, err error) { dt = &Datatable{ ctx: ctx, w: w, - r: r, + r: r, redis: redisClient, fields: fields, loadFunc: loadFunc, @@ -296,7 +295,7 @@ func New(ctx context.Context, w http.ResponseWriter, r *http.Request, redisClien } dt.SetAjaxUrl(r.URL) - if web.RequestIsJson(r) { + if web.RequestIsJson(r) { dt.handleRequest = true dt.req, err = ParseQueryValues(r.URL.Query()) @@ -354,7 +353,7 @@ func New(ctx context.Context, w http.ResponseWriter, r *http.Request, redisClien } } - dt.cacheKey = fmt.Sprintf("%x", md5.Sum([]byte(dt.resp.AjaxUrl + dt.req.CacheKey() + dt.stateId))) + dt.cacheKey = fmt.Sprintf("%x", md5.Sum([]byte(dt.resp.AjaxUrl+dt.req.CacheKey()+dt.stateId))) } else { //for idx, f := range fields { @@ -513,7 +512,6 @@ func (dt *Datatable) Render() (rendered bool, err error) { match = strings.Contains(l[i].Value, cn.Search.Value) } - if !match { //fmt.Println("-> no match") skip = true diff --git a/internal/signup/signup.go b/internal/signup/signup.go index 2250864..e3d43c4 100644 --- a/internal/signup/signup.go +++ b/internal/signup/signup.go @@ -18,13 +18,10 @@ type ctxKeyTagUniqueName int const KeyTagUniqueName ctxKeyTagUniqueName = 1 - type ctxKeyTagUniqueEmail int const KeyTagUniqueEmail ctxKeyTagUniqueEmail = 1 - - // validate holds the settings and caches for validating request struct values. var validate *validator.Validate @@ -70,7 +67,6 @@ func Validator() *validator.Validate { return validate } - // Signup performs the steps needed to create a new account, new user and then associate // both records with a new user_account entry. func Signup(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req SignupRequest, now time.Time) (*SignupResult, error) { diff --git a/internal/user/user.go b/internal/user/user.go index effddef..8af4742 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -647,6 +647,8 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA err = CanModifyUser(ctx, claims, dbConn, req.ID) if err != nil { return err + } else if claims.Subject != "" && claims.Subject == req.ID { + return errors.WithStack(ErrForbidden) } // If now empty set it to the current time. @@ -770,6 +772,8 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserDe err = CanModifyUser(ctx, claims, dbConn, req.ID) if err != nil { return err + } else if claims.Subject != "" && claims.Subject == req.ID { + return errors.WithStack(ErrForbidden) } // Start a new transaction to handle rollbacks on error. diff --git a/internal/user_account/models.go b/internal/user_account/models.go index 3484ba8..4a05036 100644 --- a/internal/user_account/models.go +++ b/internal/user_account/models.go @@ -257,18 +257,18 @@ func (s UserAccountRoles) Value() (driver.Value, error) { // User represents someone with access to our system. type User struct { - ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` - Name string `json:"name" validate:"required" example:"Gabi May"` - 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"` + ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + Name string `json:"name" validate:"required" example:"Gabi May"` + 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"` 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"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ArchivedAt *pq.NullTime `json:"archived_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ArchivedAt *pq.NullTime `json:"archived_at,omitempty"` } // UserResponse represents someone with access to our system that is returned for display. @@ -279,9 +279,9 @@ type UserResponse struct { LastName string `json:"last_name" example:"May"` Email string `json:"email" example:"gabi@geeksinthewoods.com"` Timezone string `json:"timezone" example:"America/Anchorage"` - AccountID string `json:"account_id" 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 web.EnumResponse `json:"status"` // Status is enum with values [active, invited, disabled]. + AccountID string `json:"account_id" 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 web.EnumResponse `json:"status"` // Status is enum with values [active, invited, disabled]. CreatedAt web.TimeResponse `json:"created_at"` // CreatedAt contains multiple format options for display. UpdatedAt web.TimeResponse `json:"updated_at"` // UpdatedAt contains multiple format options for display. ArchivedAt *web.TimeResponse `json:"archived_at,omitempty"` // ArchivedAt contains multiple format options for display. @@ -336,8 +336,8 @@ func (m *Users) Response(ctx context.Context) []*UserResponse { // UserFindByAccountRequest defines the possible options to search for users by account ID. // By default archived users will be excluded from response. type UserFindByAccountRequest struct { - AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` - Where string `json:"where" example:"name = ? and email = ?"` + AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` + Where string `json:"where" example:"name = ? and email = ?"` Args []interface{} `json:"args" swaggertype:"array,string" example:"Company Name,gabi.may@geeksinthewoods.com"` Order []string `json:"order" example:"created_at desc"` Limit *uint `json:"limit" example:"10"` diff --git a/internal/user_account/user.go b/internal/user_account/user.go index 7738007..9c63821 100644 --- a/internal/user_account/user.go +++ b/internal/user_account/user.go @@ -3,9 +3,11 @@ package user_account import ( "context" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" + "github.com/pkg/errors" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) @@ -22,6 +24,124 @@ func UserFindByAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, return nil, err } + /* + SELECT + id, + first_name, + last_name, + name, + email, + timezone, + account_id, + status, + roles, + created_at, + updated_at, + archived_at FROM ( + SELECT + u.id, + u.first_name, + u.last_name, + concat(u.first_name, ' ',u.last_name) as name, + u.email, + u.timezone, + ua.account_id, + ua.status, + ua.roles, + CASE WHEN ua.created_at > u.created_at THEN ua.created_at ELSE u.created_at END AS created_at, + CASE WHEN ua.updated_at > u.updated_at THEN ua.updated_at ELSE u.updated_at END AS updated_at, + CASE WHEN ua.archived_at > u.archived_at THEN ua.archived_at ELSE u.archived_at END AS archived_at + FROM users u + JOIN users_accounts ua + ON u.id = ua.user_id AND ua.account_id = 'df1a8a65-b00b-4640-9a64-66c1a355b17c' + WHERE + (u.archived_at IS NULL AND ua.archived_at IS NULL) AND + account_id IN (SELECT account_id FROM users_accounts WHERE (account_id = ? OR user_id = ?)) + ) res ORDER BY id asc - return nil , nil + */ + + subQuery := sqlbuilder.NewSelectBuilder(). + Select("u.id,u.first_name,u.last_name,concat(u.first_name, ' ',u.last_name) as name,u.email,u.timezone,ua.account_id,ua.status,ua.roles,"+ + "CASE WHEN ua.created_at > u.created_at THEN ua.created_at ELSE u.created_at END AS created_at,"+ + "CASE WHEN ua.updated_at > u.updated_at THEN ua.updated_at ELSE u.updated_at END AS updated_at,"+ + "CASE WHEN ua.archived_at > u.archived_at THEN ua.archived_at ELSE u.archived_at END AS archived_at"). + From(userTableName+" u"). + Join(userAccountTableName+" ua", "u.id = ua.user_id", "ua.account_id = '"+req.AccountID+"'") + + if !req.IncludeArchived { + subQuery.Where(subQuery.And( + subQuery.IsNull("u.archived_at"), + subQuery.IsNull("ua.archived_at"))) + } + + if claims.Audience != "" || claims.Subject != "" { + // Build select statement for users_accounts table + authQuery := sqlbuilder.NewSelectBuilder().Select("account_id").From(userAccountTableName) + + var or []string + if claims.Audience != "" { + or = append(or, authQuery.Equal("account_id", claims.Audience)) + } + if claims.Subject != "" { + or = append(or, authQuery.Equal("user_id", claims.Subject)) + } + + // Append sub query + if len(or) > 0 { + authQuery.Where(authQuery.Or(or...)) + subQuery.Where(subQuery.In("account_id", authQuery)) + } + } + + subQueryStr, queryArgs := subQuery.Build() + + query := sqlbuilder.NewSelectBuilder(). + Select("id,first_name,last_name,name,email,timezone,account_id,status,roles,created_at,updated_at,archived_at"). + From("(" + subQueryStr + ") res") + if req.Where != "" { + query.Where(query.And(req.Where)) + } + if len(req.Order) > 0 { + query.OrderBy(req.Order...) + } + if req.Limit != nil { + query.Limit(int(*req.Limit)) + } + if req.Offset != nil { + query.Offset(int(*req.Offset)) + } + + queryStr, moreQueryArgs := query.Build() + queryStr = dbConn.Rebind(queryStr) + + queryArgs = append(queryArgs, moreQueryArgs...) + + // fetch all places from the db + rows, err := dbConn.QueryContext(ctx, queryStr, queryArgs...) + if err != nil { + err = errors.Wrapf(err, "query - %s", query.String()) + err = errors.WithMessage(err, "find users failed") + return nil, err + } + + // iterate over each row + resp := []*User{} + for rows.Next() { + + var ( + u User + err error + ) + err = rows.Scan(&u.ID, &u.FirstName, &u.LastName, &u.Name, &u.Email, &u.Timezone, &u.AccountID, &u.Status, + &u.Roles, &u.CreatedAt, &u.UpdatedAt, &u.ArchivedAt) + if err != nil { + err = errors.Wrapf(err, "query - %s", query.String()) + return nil, err + } + + resp = append(resp, &u) + } + + return resp, nil } diff --git a/internal/user_account/user_test.go b/internal/user_account/user_test.go new file mode 100644 index 0000000..846adbb --- /dev/null +++ b/internal/user_account/user_test.go @@ -0,0 +1,10 @@ +package user_account + +import ( + "testing" +) + +// TestUserFindByAccount validates that find users by account works. +func TestUserFindByAccount(t *testing.T) { + +}