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"}}
+
+{{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 }}
+ {{ $c.Title }} |
+ {{ end }}
+
+
+
+
+ {{ range $idx, $c := .datatable.DisplayFields }}
+ {{ $c.Title }} |
+ {{ end }}
+
+
+
+{{ 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
+}