diff --git a/cmd/web-app/handlers/routes.go b/cmd/web-app/handlers/routes.go index fd4c6df..1a7b65e 100644 --- a/cmd/web-app/handlers/routes.go +++ b/cmd/web-app/handlers/routes.go @@ -47,6 +47,22 @@ 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, + 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)) + // Register user management and authentication endpoints. u := User{ MasterDB: masterDB, diff --git a/cmd/web-app/handlers/signup.go b/cmd/web-app/handlers/signup.go index 9ebeabf..0a10d8d 100644 --- a/cmd/web-app/handlers/signup.go +++ b/cmd/web-app/handlers/signup.go @@ -36,19 +36,19 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque // req := new(signup.SignupRequest) data := make(map[string]interface{}) - f := func() error { + f := func() (bool, error) { claims, _ := auth.ClaimsFromContext(ctx) if r.Method == http.MethodPost { err := r.ParseForm() if err != nil { - return err + return false, err } decoder := schema.NewDecoder() if err := decoder.Decode(req, r.PostForm); err != nil { - return err + return false, err } // Execute the account / user signup. @@ -56,13 +56,13 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque if err != nil { switch errors.Cause(err) { case account.ErrForbidden: - return web.RespondError(ctx, w, weberror.NewError(ctx, err, http.StatusForbidden)) + 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 nil + return false, nil } else { - return err + return false, err } } } @@ -70,13 +70,13 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque // Authenticated the new user. token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, req.User.Email, req.User.Password, time.Hour, ctxValues.Now) if err != nil { - return err + return false, err } // Add the token to the users session. err = handleSessionToken(ctx, h.MasterDB, w, r, token) if err != nil { - return err + return false, err } // Display a welcome message to the user. @@ -85,19 +85,22 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque "You workflow will be a breeze starting today.") err = webcontext.ContextSession(ctx).Save(r, w) if err != nil { - return err + return false, err } // Redirect the user to the dashboard. http.Redirect(w, r, "/", http.StatusFound) - return nil + return true, nil } - return nil + return false, nil } - if err := f(); err != 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["geonameCountries"] = geonames.ValidGeonameCountries diff --git a/cmd/web-app/handlers/user.go b/cmd/web-app/handlers/user.go index a139866..116a22a 100644 --- a/cmd/web-app/handlers/user.go +++ b/cmd/web-app/handlers/user.go @@ -36,6 +36,7 @@ type User struct { SecretKey string } +// UserLoginRequest extends the AuthenicateRequest with the RememberMe flag. type UserLoginRequest struct { user_auth.AuthenticateRequest RememberMe bool @@ -120,33 +121,6 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "user-login.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data) } -// handleSessionToken persists the access token to the session for request authentication. -func handleSessionToken(ctx context.Context, db *sqlx.DB, w http.ResponseWriter, r *http.Request, token user_auth.Token) error { - if token.AccessToken == "" { - return errors.New("accessToken is required.") - } - - sess := webcontext.ContextSession(ctx) - - if sess.IsNew { - sess.ID = uuid.NewRandom().String() - } - - sess.Options = &sessions.Options{ - Path: "/", - MaxAge: int(token.TTL.Seconds()), - HttpOnly: false, - } - - sess = webcontext.SessionInit(sess, - token.AccessToken) - if err := sess.Save(r, w); err != nil { - return err - } - - return nil -} - // Logout handles removing authentication for the user. func (h *User) Logout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { @@ -828,6 +802,33 @@ func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "user-switch-account.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data) } +// handleSessionToken persists the access token to the session for request authentication. +func handleSessionToken(ctx context.Context, db *sqlx.DB, w http.ResponseWriter, r *http.Request, token user_auth.Token) error { + if token.AccessToken == "" { + return errors.New("accessToken is required.") + } + + sess := webcontext.ContextSession(ctx) + + if sess.IsNew { + sess.ID = uuid.NewRandom().String() + } + + sess.Options = &sessions.Options{ + Path: "/", + MaxAge: int(token.TTL.Seconds()), + HttpOnly: false, + } + + sess = webcontext.SessionInit(sess, + token.AccessToken) + if err := sess.Save(r, w); err != nil { + return err + } + + return nil +} + // updateContextClaims updates the claims in the context. func updateContextClaims(ctx context.Context, authenticator *auth.Authenticator, claims auth.Claims) (context.Context, error) { tkn, err := authenticator.GenerateToken(claims) diff --git a/cmd/web-app/handlers/users.go b/cmd/web-app/handlers/users.go new file mode 100644 index 0000000..4d98d73 --- /dev/null +++ b/cmd/web-app/handlers/users.go @@ -0,0 +1,301 @@ +package handlers + +import ( + "context" + "fmt" + "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/notify" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" + project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes" + "geeks-accelerator/oss/saas-starter-kit/internal/user" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "github.com/gorilla/schema" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" +) + +// Users represents the Users API method handler set. +type Users struct { + MasterDB *sqlx.DB + Redis *redis.Client + Renderer web.Renderer + Authenticator *auth.Authenticator + ProjectRoutes project_routes.ProjectRoutes + NotifyEmail notify.Email + SecretKey string +} + +func UrlUsersView(userID string) string { + return fmt.Sprintf("/users/%s", userID) +} + +// 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 { + + claims, err := auth.ClaimsFromContext(ctx) + if err != nil { + return err + } + + var statusValues []interface{} + for _, v := range user_account.UserAccountStatus_Values { + statusValues = append(statusValues, string(v)) + } + + statusOpts := web.NewEnumResponse(ctx, nil, statusValues...) + + statusFilterItems := []datatable.FilterOptionItem{} + for _, opt := range statusOpts.Options { + statusFilterItems = append(statusFilterItems, datatable.FilterOptionItem{ + Display: opt.Title, + Value: opt.Value, + }) + } + + fields := []datatable.DisplayField{ + datatable.DisplayField{Field: "id", Title: "ID", Visible: false, Searchable: true, Orderable: true, Filterable: false}, + datatable.DisplayField{Field: "name", Title: "User", Visible: true, Searchable: true, Orderable: true, Filterable: true, FilterPlaceholder: "filter Name"}, + datatable.DisplayField{Field: "status", Title: "Status", Visible: true, Searchable: true, Orderable: true, Filterable: true, FilterPlaceholder: "All Statuses", FilterItems: statusFilterItems}, + datatable.DisplayField{Field: "updated_at", Title: "Last Updated", Visible: true, Searchable: true, Orderable: true, Filterable: false}, + datatable.DisplayField{Field: "created_at", Title: "Created", Visible: true, Searchable: true, Orderable: true, Filterable: false}, + } + + mapFunc := func(q *user_account.User, cols []datatable.DisplayField) (resp []datatable.ColumnValue, err error) { + for i := 0; i < len(cols); i++ { + col := cols[i] + var v datatable.ColumnValue + switch col.Field { + case "id": + v.Value = fmt.Sprintf("%d", q.ID) + case "name": + v.Value = q.Name + v.Formatted = fmt.Sprintf("%s", UrlUsersView(q.ID), v.Value) + case "created_at": + dt := web.NewTimeResponse(ctx, q.CreatedAt) + v.Value = dt.Local + v.Formatted = fmt.Sprintf("%s", v.Value) + case "updated_at": + dt := web.NewTimeResponse(ctx, q.UpdatedAt) + v.Value = dt.Local + v.Formatted = fmt.Sprintf("%s", v.Value) + default: + return resp, errors.Errorf("Failed to map value for %s.", col.Field) + } + resp = append(resp, v) + } + + return resp, nil + } + + 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, ","), + }) + if err != nil { + return resp, err + } + + for _, a := range res { + l, err := mapFunc(a, fields) + if err != nil { + return resp, errors.Wrapf(err, "Failed to map user for display.") + } + + resp = append(resp, l) + } + + return resp, nil + } + + dt, err := datatable.New(ctx, w, r, h.Redis, fields, loadFunc) + if err != nil { + return err + } + + if dt.HasCache() { + return nil + } + + if ok, err := dt.Render(); ok { + if err != nil { + return err + } + return nil + } + + data := map[string]interface{}{ + "datatable": dt.Response(), + } + + 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 { + + 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 + } + } + + return nil + } + + if err := f(); err != nil { + return web.RenderError(ctx, w, r, err, h.Renderer, TmplLayoutBase, TmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8) + } + + 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 { + + ctxValues, err := webcontext.ContextValues(ctx) + if err != nil { + return err + } + + claims, err := auth.ClaimsFromContext(ctx) + if err != nil { + return err + } + + // + req := new(user.UserUpdateRequest) + 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() + decoder.IgnoreUnknownKeys(true) + + if err := decoder.Decode(req, r.PostForm); err != nil { + return false, err + } + req.ID = claims.Subject + + err = user.Update(ctx, claims, h.MasterDB, *req, 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 + } + } + } + + if r.PostForm.Get("Password") != "" { + pwdReq := new(user.UserUpdatePasswordRequest) + + if err := decoder.Decode(pwdReq, r.PostForm); err != nil { + return false, err + } + pwdReq.ID = claims.Subject + + err = user.UpdatePassword(ctx, claims, h.MasterDB, *pwdReq, 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 Updated", + "User successfully updated.") + err = webcontext.ContextSession(ctx).Save(r, w) + if err != nil { + return false, err + } + + http.Redirect(w, r, "/users/"+req.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 + } + + usr, err := user.ReadByID(ctx, claims, h.MasterDB, claims.Subject) + if err != nil { + return err + } + + if req.ID == "" { + req.FirstName = &usr.FirstName + req.LastName = &usr.LastName + req.Email = &usr.Email + req.Timezone = &usr.Timezone + } + + data["user"] = usr.Response(ctx) + + 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(user.UserUpdateRequest{})); ok { + data["userValidationDefaults"] = verr.(*weberror.Error) + } + + if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(user.UserUpdatePasswordRequest{})); ok { + data["passwordValidationDefaults"] = verr.(*weberror.Error) + } + + 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-index.gohtml b/cmd/web-app/templates/content/users-index.gohtml new file mode 100644 index 0000000..a4205fe --- /dev/null +++ b/cmd/web-app/templates/content/users-index.gohtml @@ -0,0 +1,27 @@ +{{define "title"}}Users{{end}} +{{define "content"}} +
+
+
+
+
+ {{ template "partials/datatable/html" . }} +
+
+
+
+
+{{end}} +{{define "style"}} + {{ template "partials/datatable/style" . }} +{{ end }} +{{define "js"}} + {{ template "partials/datatable/js" . }} + + + +{{end}} diff --git a/cmd/web-app/templates/partials/datatable.tmpl b/cmd/web-app/templates/partials/datatable.tmpl new file mode 100644 index 0000000..952535e --- /dev/null +++ b/cmd/web-app/templates/partials/datatable.tmpl @@ -0,0 +1,136 @@ +{{ define "partials/datatable/html" }} + + + + {{ range $idx, $c := .datatable.DisplayFields }} + + {{ end }} + + + + + {{ range $idx, $c := .datatable.DisplayFields }} + + {{ end }} + + +
{{ $c.Title }}
{{ $c.Title }}
+{{ end }} +{{ define "partials/datatable/style" }} + +{{ end }} +{{ define "partials/datatable/js" }} + + + +{{ end }} diff --git a/internal/platform/datatable/datatable.go b/internal/platform/datatable/datatable.go new file mode 100644 index 0000000..da88ca2 --- /dev/null +++ b/internal/platform/datatable/datatable.go @@ -0,0 +1,592 @@ +package datatable + +import ( + "context" + "crypto/md5" + "encoding/json" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "github.com/pborman/uuid" + "github.com/pkg/errors" + "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" +) + +const ( + DatatableStateCacheTtl = 120 +) + +var ( + // ErrInvalidColumn occurs when a column can not be mapped correctly. + ErrInvalidColumn = errors.New("Invalid column") +) + +type ( + Datatable struct { + ctx context.Context + 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 + req Request + resp *Response + handleRequest bool + sorting []string + cacheKey string + all [][]ColumnValue + loaded bool + storeFilteredFieldName string + filteredFieldValues []string + disableCache bool + caseSensitive bool + } + Request struct { + Data string + Columns map[int]Column + Order map[int]Order + Length int + Start int + Draw int + Search Search + } + Column struct { + Name string + Data string + Orderable bool + Searchable bool + Search Search + } + ColumnValue struct { + Value string + Formatted string + } + Search struct { + Value string + IsRegex bool + Regexp *regexp.Regexp + } + Order struct { + Column int + Dir string + } + Response struct { + AjaxUrl string `json:"ajaxUrl"` + Draw int `json:"draw"` + RecordsTotal int `json:"recordsTotal"` + RecordsFiltered int `json:"recordsFiltered"` + Data [][]string `json:"data"` + Error string `json:"error"` + DisplayFields []DisplayField `json:"displayFields"` + } + DisplayField struct { + Field string `json:"field"` + Title string `json:"title"` + Visible bool `json:"visible"` + Searchable bool `json:"searchable"` + Orderable bool `json:"orderable"` + Filterable bool `json:"filterable"` + AutocompletePath string `json:"autoComplete_path"` + FilterItems []FilterOptionItem `json:"filter_items"` + FilterPlaceholder string `json:"filter_placeholder"` + OrderFields []string `json:"order_fields"` + //Type string `json:"type"` + } + FilterOptionItem struct { + Value string `json:"value"` + Display string `json:"display"` + } +) + +func (r Request) CacheKey() string { + c := Request{ + Order: r.Order, + // Search : r.Search, + } + for _, cn := range r.Columns { + // these will be applied as a filter + cn.Search = Search{} + } + dat, _ := json.Marshal(c) + return fmt.Sprintf("%x", md5.Sum(dat)) +} + +// &columns[0][data]=&columns[0][name]=&columns[0][searchable]=true&columns[0][orderable]=false&columns[0][search][value]=&columns[0][search][regex]=false&columns[1][data]=ts&columns[1][name]=Time&columns[1][searchable]=true&columns[1][orderable]=true&columns[1][search][value]=&columns[1][search][regex]=false&columns[2][data]=level&columns[2][name]=Time&columns[2][searchable]=true&columns[2][orderable]=true&columns[2][search][value]=&columns[2][search][regex]=false&columns[3][data]=msg&columns[3][name]=Time&columns[3][searchable]=true&columns[3][orderable]=true&columns[3][search][value]=&columns[3][search][regex]=false&order[0][column]=0&order[0][dir]=asc&start=0&length=10&search[value]=&search[regex]=false&_=1537426209765 +func ParseQueryValues(vals url.Values) (Request, error) { + + req := Request{ + Columns:make(map[int]Column), + Order: make(map[int]Order) , + } + + var err error + for kn, kvs := range vals { + + pts := strings.Split(kn, "[") + switch pts[0] { + case "columns": + idxStr := strings.Split(pts[1], "]")[0] + + idx, err := strconv.Atoi(idxStr) + if err != nil { + return req, err + } + + if _, ok := req.Columns[idx]; !ok { + req.Columns[idx] = Column{} + } + curCol := req.Columns[idx] + + sn := strings.Split(pts[2], "]")[0] + switch sn { + case "name": + curCol.Name = kvs[0] + case "data": + curCol.Data = kvs[0] + case "orderable": + if kvs[0] != "" { + curCol.Orderable, err = strconv.ParseBool(kvs[0]) + if err != nil { + return req, err + } + } + case "searchable": + if kvs[0] != "" { + curCol.Searchable, err = strconv.ParseBool(kvs[0]) + if err != nil { + return req, err + } + } + case "search": + svn := strings.Split(pts[3], "]")[0] + switch svn { + case "regex": + if kvs[0] != "" { + curCol.Search.IsRegex, err = strconv.ParseBool(kvs[0]) + if err != nil { + return req, err + } + } + case "value": + if strings.ToLower(kvs[0]) != "false" { + curCol.Search.Value = kvs[0] + } + default: + 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) + } + req.Columns[idx] = curCol + case "order": + idxStr := strings.Split(pts[1], "]")[0] + + idx, err := strconv.Atoi(idxStr) + if err != nil { + return req, err + } + + if _, ok := req.Order[idx]; !ok { + req.Order[idx] = Order{} + } + curOrder := req.Order[idx] + + sn := strings.Split(pts[2], "]")[0] + switch sn { + case "dir": + curOrder.Dir = kvs[0] + case "column": + if kvs[0] != "" { + curOrder.Column, err = strconv.Atoi(kvs[0]) + if err != nil { + return req, err + } + } + default: + return req, errors.WithMessagef(ErrInvalidColumn, "Unable to map query Order %s for %s", sn, kn) + } + req.Order[idx] = curOrder + case "length": + if kvs[0] != "" { + req.Length, err = strconv.Atoi(kvs[0]) + if err != nil { + return req, err + } + } + case "draw": + if kvs[0] != "" { + req.Draw, err = strconv.Atoi(kvs[0]) + if err != nil { + return req, err + } + } + case "start": + if kvs[0] != "" { + req.Start, err = strconv.Atoi(kvs[0]) + if err != nil { + return req, err + } + } + + + case "search": + sn := strings.Split(pts[1], "]")[0] + switch sn { + case "value": + if strings.ToLower(kvs[0]) != "false" { + req.Search.Value = kvs[0] + } + case "regex": + if kvs[0] != "" { + req.Search.IsRegex, err = strconv.ParseBool(kvs[0]) + if err != nil { + return req, err + } + } + default: + return req, errors.WithMessagef(ErrInvalidColumn, "Unable to map query Order %s for %s", sn, kn) + } + } + } + + if req.Search.IsRegex && req.Search.Value != "" { + req.Search.Regexp, err = regexp.Compile(req.Search.Value) + if err != nil { + return req, err + } + } + + for idx, col := range req.Columns { + if col.Search.IsRegex && col.Search.Value != "" { + col.Search.Regexp, err = regexp.Compile(col.Search.Value) + if err != nil { + return req, err + } + req.Columns[idx] = col + } + } + + 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) { + dt = &Datatable{ + ctx: ctx, + w: w, + r: r, + redis: redisClient, + fields: fields, + loadFunc: loadFunc, + } + + dt.stateId = r.URL.Query().Get("dtid") + if dt.stateId == "" { + dt.stateId = uuid.NewRandom().String() + } + + dt.resp = &Response{ + Data: [][]string{}, + } + dt.SetAjaxUrl(r.URL) + + if web.RequestIsJson(r) { + dt.handleRequest = true + + dt.req, err = ParseQueryValues(r.URL.Query()) + if err != nil { + return dt, errors.Wrapf(err, "Failed to parse query values") + } + dt.resp.Draw = dt.req.Draw + + dt.sorting = []string{} + for i := 0; i < len(dt.req.Order); i++ { + co := dt.req.Order[i] + + cn := dt.req.Columns[co.Column] + + var df DisplayField + for _, dc := range dt.fields { + if dc.Field == cn.Name { + df = dc + break + } + } + if df.Field == "" { + err = errors.Errorf("Failed to find field for column %s", cn.Name) + return dt, err + } + + if len(df.OrderFields) > 0 { + for _, of := range df.OrderFields { + dt.sorting = append(dt.sorting, fmt.Sprintf("%s %s", of, co.Dir)) + } + } else { + dt.sorting = append(dt.sorting, fmt.Sprintf("%s %s", df.Field, co.Dir)) + } + } + + for i := 0; i < len(dt.req.Columns); i++ { + cn := dt.req.Columns[i] + + var cf string + for _, dc := range dt.fields { + if dc.Field == cn.Name { + if dc.Filterable { + cn.Searchable = true + dt.req.Columns[i] = cn + } + + cf = dc.Field + dt.resp.DisplayFields = append(dt.resp.DisplayFields, dc) + break + } + } + if cf == "" { + err = errors.Errorf("Failed to find field for column %s", cn.Name) + return dt, err + } + } + + dt.cacheKey = fmt.Sprintf("%x", md5.Sum([]byte(dt.resp.AjaxUrl + dt.req.CacheKey() + dt.stateId))) + + } else { + //for idx, f := range fields { + // if f.Filterable && !f.Searchable { + // f.Searchable = true + // fields[idx] = f + // } + //} + + dt.resp.DisplayFields = fields + } + + return dt, nil +} + +func (dt *Datatable) CaseSensitive() { + dt.caseSensitive = true +} + +func (dt *Datatable) SetAjaxUrl(u *url.URL) { + un, _ := url.Parse(u.String()) + + qStr := un.Query() + qStr.Set("dtid", dt.stateId) + // add query to url + un.RawQuery = qStr.Encode() + + if u.IsAbs() { + dt.resp.AjaxUrl = un.String() + } else { + dt.resp.AjaxUrl = un.RequestURI() + } +} + +func (dt *Datatable) HasCache() bool { + if !dt.handleRequest || dt.disableCache { + return false + } + + // @TODO: Need to handle is error better. But when cache is down, the page should still respond, + // maybe just need to add logging. + cv, _ := dt.redis.WithContext(dt.ctx).Get(dt.cacheKey).Bytes() + + if len(cv) > 0 { + err := json.Unmarshal(cv, &dt.all) + if err != nil { + // @TODO: Log the error here. + } else { + dt.loaded = true + return true + } + } + + return false +} + +func (dt *Datatable) Handled() bool { + return dt.handleRequest +} + +func (dt *Datatable) Response() Response { + return *dt.resp +} + +func (dt *Datatable) StoreFilteredField(cn string) { + dt.storeFilteredFieldName = cn +} + +func (dt *Datatable) GetFilteredFieldValues() []string { + return dt.filteredFieldValues +} + +func (dt *Datatable) DisableCache() { + dt.disableCache = true +} + +func (dt *Datatable) Render() (rendered bool, err error) { + rendered = dt.handleRequest + if !rendered { + return rendered, nil + } + + if !dt.loaded { + sorting := strings.Join(dt.sorting, ",") + + dt.all, err = dt.loadFunc(dt.ctx, sorting, dt.fields) + if err != nil { + return rendered, errors.Wrap(err, "Failed to load data") + } + + if !dt.disableCache { + dat, err := json.Marshal(dt.all) + if err != nil { + return rendered, errors.Wrap(err, "Failed to json encode cache response") + } + + err = dt.redis.WithContext(dt.ctx).Set(dt.cacheKey, dat, DatatableStateCacheTtl*time.Second).Err() + if err != nil { + // @TODO: Log the error here. + } + } + } + + dt.resp.RecordsTotal = len(dt.all) + + //fmt.Println("dt.req.Search.Value ", dt.req.Search.Value ) + var hasColFilter bool + for i := 0; i < len(dt.req.Columns); i++ { + cn := dt.req.Columns[i] + if !cn.Searchable { + continue + } + + if cn.Search.Value != "" { + // fmt.Println("col filter on", cn.Name) + hasColFilter = true + break + } + } + + filtered := [][]ColumnValue{} + for _, l := range dt.all { + var skip bool + var oneColAtleastMatches bool + for i := 0; i < len(dt.req.Columns); i++ { + cn := dt.req.Columns[i] + + if cn.Name == dt.storeFilteredFieldName { + dt.filteredFieldValues = append(dt.filteredFieldValues, l[i].Value) + } + + if !cn.Searchable { + // fmt.Println("col ", cn.Name, "is not searchable skipping") + continue + } + + if cn.Search.Value != "" { + if cn.Search.Regexp != nil { + //fmt.Println("col regex", cn.Search.Value, "->>>>", l[i].Value) + + if !cn.Search.Regexp.MatchString(l[i].Value) { + //fmt.Println("-> no match") + skip = true + if dt.req.Search.Value == "" { + // only skip if not full search + break + } + } + } else { + var match bool + if !dt.caseSensitive { + match = strings.Contains( + strings.ToLower(l[i].Value), + strings.ToLower(cn.Search.Value)) + } else { + match = strings.Contains(l[i].Value, cn.Search.Value) + } + + + if !match { + //fmt.Println("-> no match") + skip = true + if dt.req.Search.Value == "" { + // only skip if not full search + break + } + } + } + } + if dt.req.Search.Value != "" { + if dt.req.Search.Regexp != nil { + //fmt.Println("req regex", cn.Search.Value, "->>>>", l[i].Value) + + if dt.req.Search.Regexp.MatchString(l[i].Value) { + // fmt.Println("-> match") + oneColAtleastMatches = true + if !hasColFilter { + // only skip if no column filter + break + } + } + } else { + if strings.Contains(l[i].Value, dt.req.Search.Value) { + // fmt.Println("-> match") + oneColAtleastMatches = true + if !hasColFilter { + // only skip if no column filter + break + } + } + } + } + } + + if hasColFilter && dt.req.Search.Value != "" { + if !skip && oneColAtleastMatches { + filtered = append(filtered, l) + } + } else if hasColFilter { + if !skip { + filtered = append(filtered, l) + } + + } else if dt.req.Search.Value != "" { + if oneColAtleastMatches { + filtered = append(filtered, l) + } + } else { + filtered = append(filtered, l) + } + } + dt.resp.RecordsFiltered = len(filtered) + + for idx, l := range filtered { + if dt.req.Start > 0 && idx < dt.req.Start { + continue + } + + fl := []string{} + for _, lv := range l { + if lv.Formatted != "" { + fl = append(fl, lv.Formatted) + } else { + fl = append(fl, lv.Value) + } + } + + dt.resp.Data = append(dt.resp.Data, fl) + if dt.req.Length > 0 && len(dt.resp.Data) >= dt.req.Length { + break + } + } + + return rendered, web.RespondJson(dt.ctx, dt.w, dt.resp, http.StatusOK) +} diff --git a/internal/signup/models.go b/internal/signup/models.go index 8db28ca..ccf4187 100644 --- a/internal/signup/models.go +++ b/internal/signup/models.go @@ -14,7 +14,7 @@ type SignupRequest struct { // SignupAccount defined the details needed for account. type SignupAccount struct { - Name string `json:"name" validate:"required,unique" example:"Company {RANDOM_UUID}"` + Name string `json:"name" validate:"required,unique-name" example:"Company {RANDOM_UUID}"` Address1 string `json:"address1" validate:"required" example:"221 Tatitlek Ave"` Address2 string `json:"address2" validate:"omitempty" example:"Box #1832"` City string `json:"city" validate:"required" example:"Valdez"` @@ -28,7 +28,7 @@ type SignupAccount struct { type SignupUser 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:"{RANDOM_EMAIL}"` + Email string `json:"email" validate:"required,email,unique-email" example:"{RANDOM_EMAIL}"` Password string `json:"password" validate:"required" example:"SecretString"` PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"SecretString"` } diff --git a/internal/signup/signup.go b/internal/signup/signup.go index 5e3acdc..2250864 100644 --- a/internal/signup/signup.go +++ b/internal/signup/signup.go @@ -2,7 +2,6 @@ package signup import ( "context" - "strings" "time" "geeks-accelerator/oss/saas-starter-kit/internal/account" @@ -15,6 +14,63 @@ import ( "gopkg.in/go-playground/validator.v9" ) +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 + +// Validator returns the current init validator. +func Validator() *validator.Validate { + if validate == nil { + validate = webcontext.Validator() + + validate.RegisterValidationCtx("unique-name", func(ctx context.Context, fl validator.FieldLevel) bool { + if fl.Field().String() == "invalid" { + return false + } + + cv := ctx.Value(KeyTagUniqueName) + if cv == nil { + return false + } + + if v, ok := cv.(bool); ok { + return v + } + + return false + }) + + validate.RegisterValidationCtx("unique-email", func(ctx context.Context, fl validator.FieldLevel) bool { + if fl.Field().String() == "invalid" { + return false + } + + cv := ctx.Value(KeyTagUniqueEmail) + if cv == nil { + return false + } + + if v, ok := cv.(bool); ok { + return v + } + + return false + }) + } + 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) { @@ -26,37 +82,17 @@ func Signup(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Signup if err != nil { return nil, err } + ctx = context.WithValue(ctx, KeyTagUniqueEmail, uniqEmail) // Validate the account name is unique in the database. uniqName, err := account.UniqueName(ctx, dbConn, req.Account.Name, "") if err != nil { return nil, err } - - f := func(fl validator.FieldLevel) bool { - if fl.Field().String() == "invalid" { - return false - } - - fieldName := strings.Trim(fl.FieldName(), "{}") - - var uniq bool - switch fieldName { - case "Name", "name": - uniq = uniqName - case "Email", "email": - uniq = uniqEmail - - } - - return uniq - } - - v := webcontext.Validator() - v.RegisterValidation("unique", f) + ctx = context.WithValue(ctx, KeyTagUniqueName, uniqName) // Validate the request. - err = v.Struct(req) + err = Validator().StructCtx(ctx, req) if err != nil { return nil, err } diff --git a/internal/user_account/models.go b/internal/user_account/models.go index 7c63f29..3484ba8 100644 --- a/internal/user_account/models.go +++ b/internal/user_account/models.go @@ -254,3 +254,93 @@ func (s UserAccountRoles) Value() (driver.Value, error) { return arr.Value() } + +// 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"` + 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"` +} + +// UserResponse represents someone with access to our system that is returned for display. +type UserResponse struct { + ID string `json:"id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + Name string `json:"name" example:"Gabi"` + FirstName string `json:"first_name" example:"Gabi"` + 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]. + 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. + Gravatar web.GravatarResponse `json:"gravatar"` +} + +// Response transforms User and UserResponse that is used for display. +// Additional filtering by context values or translations could be applied. +func (m *User) Response(ctx context.Context) *UserResponse { + if m == nil { + return nil + } + + r := &UserResponse{ + ID: m.ID, + Name: m.Name, + 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), + CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt), + UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt), + Gravatar: web.NewGravatarResponse(ctx, m.Email), + } + + if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() { + at := web.NewTimeResponse(ctx, m.ArchivedAt.Time) + r.ArchivedAt = &at + } + + return r +} + +// Users a list of Users. +type Users []*User + +// Response transforms a list of Users to a list of UserResponses. +func (m *Users) Response(ctx context.Context) []*UserResponse { + var l []*UserResponse + if m != nil && len(*m) > 0 { + for _, n := range *m { + l = append(l, n.Response(ctx)) + } + } + + return l +} + +// 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 = ?"` + 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"` + Offset *uint `json:"offset" example:"20"` + IncludeArchived bool `json:"include-archived" example:"false"` +} diff --git a/internal/user_account/user.go b/internal/user_account/user.go new file mode 100644 index 0000000..a36efa9 --- /dev/null +++ b/internal/user_account/user.go @@ -0,0 +1,27 @@ +package user_account + +import ( + "context" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + + "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" + "github.com/jmoiron/sqlx" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" +) + +// UserFindByAccount lists all the users for a given account ID. +func UserFindByAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserFindByAccountRequest) (Users, error) { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.UserFind") + defer span.Finish() + + v := webcontext.Validator() + + // Validate the request. + err := v.StructCtx(ctx, req) + if err != nil { + return nil, err + } + + + return nil , nil +}