1
0
mirror of https://github.com/raseels-repos/golang-saas-starter-kit.git synced 2025-06-27 00:51:13 +02:00

fixed signup

This commit is contained in:
Lee Brown
2019-08-04 23:24:30 -08:00
parent f7dfa5b089
commit ed6147260a
11 changed files with 1294 additions and 65 deletions

View File

@ -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()) 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. // Register user management and authentication endpoints.
u := User{ u := User{
MasterDB: masterDB, MasterDB: masterDB,

View File

@ -36,19 +36,19 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque
// //
req := new(signup.SignupRequest) req := new(signup.SignupRequest)
data := make(map[string]interface{}) data := make(map[string]interface{})
f := func() error { f := func() (bool, error) {
claims, _ := auth.ClaimsFromContext(ctx) claims, _ := auth.ClaimsFromContext(ctx)
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
return err return false, err
} }
decoder := schema.NewDecoder() decoder := schema.NewDecoder()
if err := decoder.Decode(req, r.PostForm); err != nil { if err := decoder.Decode(req, r.PostForm); err != nil {
return err return false, err
} }
// Execute the account / user signup. // 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 { if err != nil {
switch errors.Cause(err) { switch errors.Cause(err) {
case account.ErrForbidden: 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: default:
if verr, ok := weberror.NewValidationError(ctx, err); ok { if verr, ok := weberror.NewValidationError(ctx, err); ok {
data["validationErrors"] = verr.(*weberror.Error) data["validationErrors"] = verr.(*weberror.Error)
return nil return false, nil
} else { } 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. // Authenticated the new user.
token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, req.User.Email, req.User.Password, time.Hour, ctxValues.Now) token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, req.User.Email, req.User.Password, time.Hour, ctxValues.Now)
if err != nil { if err != nil {
return err return false, err
} }
// Add the token to the users session. // Add the token to the users session.
err = handleSessionToken(ctx, h.MasterDB, w, r, token) err = handleSessionToken(ctx, h.MasterDB, w, r, token)
if err != nil { if err != nil {
return err return false, err
} }
// Display a welcome message to the user. // 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.") "You workflow will be a breeze starting today.")
err = webcontext.ContextSession(ctx).Save(r, w) err = webcontext.ContextSession(ctx).Save(r, w)
if err != nil { if err != nil {
return err return false, err
} }
// Redirect the user to the dashboard. // Redirect the user to the dashboard.
http.Redirect(w, r, "/", http.StatusFound) 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) return web.RenderError(ctx, w, r, err, h.Renderer, TmplLayoutBase, TmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
} else if end {
return nil
} }
data["geonameCountries"] = geonames.ValidGeonameCountries data["geonameCountries"] = geonames.ValidGeonameCountries

View File

@ -36,6 +36,7 @@ type User struct {
SecretKey string SecretKey string
} }
// UserLoginRequest extends the AuthenicateRequest with the RememberMe flag.
type UserLoginRequest struct { type UserLoginRequest struct {
user_auth.AuthenticateRequest user_auth.AuthenticateRequest
RememberMe bool 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) 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. // 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 { 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) 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. // updateContextClaims updates the claims in the context.
func updateContextClaims(ctx context.Context, authenticator *auth.Authenticator, claims auth.Claims) (context.Context, error) { func updateContextClaims(ctx context.Context, authenticator *auth.Authenticator, claims auth.Claims) (context.Context, error) {
tkn, err := authenticator.GenerateToken(claims) tkn, err := authenticator.GenerateToken(claims)

View File

@ -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("<a href='%s'>%s</a>", UrlUsersView(q.ID), v.Value)
case "created_at":
dt := web.NewTimeResponse(ctx, q.CreatedAt)
v.Value = dt.Local
v.Formatted = fmt.Sprintf("<span class='cell-font-date'>%s</span>", v.Value)
case "updated_at":
dt := web.NewTimeResponse(ctx, q.UpdatedAt)
v.Value = dt.Local
v.Formatted = fmt.Sprintf("<span class='cell-font-date'>%s</span>", 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)
}

View File

@ -0,0 +1,27 @@
{{define "title"}}Users{{end}}
{{define "content"}}
<div class="row">
<div class="col">
<form method="post">
<div class="card">
<div class="table-responsive dataTable_card">
{{ template "partials/datatable/html" . }}
</div>
</div>
</form>
</div>
</div>
{{end}}
{{define "style"}}
{{ template "partials/datatable/style" . }}
{{ end }}
{{define "js"}}
{{ template "partials/datatable/js" . }}
<script>
$(document).ready(function(){
//$("#dataTable_filter").hide();
});
</script>
{{end}}

View File

@ -0,0 +1,136 @@
{{ define "partials/datatable/html" }}
<table id="dataTable" class="display nowrap table table-hover table-striped table-bordered" cellspacing="0" width="100%">
<thead>
<tr>
{{ range $idx, $c := .datatable.DisplayFields }}
<th>{{ $c.Title }}</th>
{{ end }}
</tr>
</thead>
<tfoot>
<tr>
{{ range $idx, $c := .datatable.DisplayFields }}
<th>{{ $c.Title }}</th>
{{ end }}
</tr>
</tfoot>
</table>
{{ end }}
{{ define "partials/datatable/style" }}
<link href="{{ SiteAssetUrl "/assets/vendor/datatables/dataTables.bootstrap4.min.css" }}" rel="stylesheet">
{{ end }}
{{ define "partials/datatable/js" }}
<!-- This is data table -->
<script src="{{ SiteAssetUrl "/assets/vendor/datatables/jquery.dataTables.min.js" }}"></script>
<script>
$(document).ready(function() {
var dtbl = $('#dataTable').DataTable( {
serverSide: true,
ordering: true,
searching: true,
ajax: "{{ .datatable.AjaxUrl }}",
scrollY: 300,
scroller: {
loadingIndicator: true
},
scrollX: true,
stateSave: false,
"columnDefs": [
{{ range $idx, $c := .datatable.DisplayFields }}
{ "title": "{{ $c.Title }}", "name": "{{ $c.Field }}", "visible": {{ $c.Visible }}, "searchable": {{ $c.Searchable }}, "orderable": {{ $c.Orderable }}, "targets": {{ $idx }} },
{{ end }}
],
initComplete: function () {
{{ range $idx, $c := .datatable.DisplayFields }}
{{ if or $c.Filterable $c.AutocompletePath }}
this.api().columns({{ $idx }}).every( function (colIdx) {
var column = this;
{{ if or ($c.AutocompletePath) ($c.FilterItems) }}
var select = $('<select><option value="">{{ $c.FilterPlaceholder }}</option></select>')
.appendTo( $(column.footer()).empty() )
.on( 'change', function () {
var val = $.fn.dataTable.util.escapeRegex(
$(this).val()
);
column
.search(val ? '^' + val + '$' : '', true, false)
.draw();
} );
{{ if $c.AutocompletePath }}
$.ajax({
type: "GET",
url: '{{ $c.AutocompletePath }}',
dataType: "json",
success: function (data) {
for (var k in data.suggestions) {
kv = data.suggestions[k]
select.append( '<option value="'+kv.value+'">'+kv.data+'</option>' )
}
}
});
{{ else }}
{{ range $idx, $item := $c.FilterItems }}
select.append( '<option value="{{ $item.Value }}">{{ $item.Display }}</option>' )
{{ end }}
{{ end }}
{{ else }}
var input = $('<input type="text" placeholder="{{ $c.FilterPlaceholder }}" />')
.appendTo( $(column.footer()).empty() )
.on( 'change', function () {
if ( column.search() !== this.value ) {
column
.search( this.value )
.draw();
}
} );
{{ end }}
} );
{{ end }}
{{ end }}
}
} );
dtbl.on( 'draw', function () {
if ( typeof customPageDatatableDraw === "function" ) {
customPageDatatableDraw();
}
} );
var vars = [], hash,filter_column,filter_value,filer_column_num;
var hashes = window.location.href.slice(window.location.href.indexOf('?') + 1).split('&');
if (hashes.length > 0 ) {
for(var i = 0; i < hashes.length; i++)
{
hash = hashes[i].split('=');
if (hash[0] == "filter_column") {
filter_column = hash[1].toLowerCase();
}
if (hash[0] == "filter_value") {
filter_value = hash[1].toLowerCase();
}
}
if (filter_column && filter_value ) {
$( "#dataTable_wrapper thead th" ).each(function( index ) {
//console.log( index + ": " + $( this ).text() );
column_text = $( this ).text().toLowerCase();
column_text = column_text.replace(" ", "_");
if (column_text ==filter_column) {
filer_column_num = index;
}
});
if (filer_column_num ) {
//console.log(filer_column_num);
dtbl.column(filer_column_num).search(filter_value).draw();
//filer_column_num = filer_column_num +1 ;
//console.log($(".dataTables_scrollFootInner tfoot th:nth-child("+filer_column_num+") ").text());
//$(".dataTables_scrollFootInner tfoot th:nth-child("+filer_column_num+") select ").val(filter_value);
}
}
}
});
</script>
{{ end }}

View File

@ -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)
}

View File

@ -14,7 +14,7 @@ type SignupRequest struct {
// SignupAccount defined the details needed for account. // SignupAccount defined the details needed for account.
type SignupAccount struct { 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"` Address1 string `json:"address1" validate:"required" example:"221 Tatitlek Ave"`
Address2 string `json:"address2" validate:"omitempty" example:"Box #1832"` Address2 string `json:"address2" validate:"omitempty" example:"Box #1832"`
City string `json:"city" validate:"required" example:"Valdez"` City string `json:"city" validate:"required" example:"Valdez"`
@ -28,7 +28,7 @@ type SignupAccount struct {
type SignupUser struct { type SignupUser struct {
FirstName string `json:"first_name" validate:"required" example:"Gabi"` FirstName string `json:"first_name" validate:"required" example:"Gabi"`
LastName string `json:"last_name" validate:"required" example:"May"` 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"` Password string `json:"password" validate:"required" example:"SecretString"`
PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"SecretString"` PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"SecretString"`
} }

View File

@ -2,7 +2,6 @@ package signup
import ( import (
"context" "context"
"strings"
"time" "time"
"geeks-accelerator/oss/saas-starter-kit/internal/account" "geeks-accelerator/oss/saas-starter-kit/internal/account"
@ -15,6 +14,63 @@ import (
"gopkg.in/go-playground/validator.v9" "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 // Signup performs the steps needed to create a new account, new user and then associate
// both records with a new user_account entry. // 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) { 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 { if err != nil {
return nil, err return nil, err
} }
ctx = context.WithValue(ctx, KeyTagUniqueEmail, uniqEmail)
// Validate the account name is unique in the database. // Validate the account name is unique in the database.
uniqName, err := account.UniqueName(ctx, dbConn, req.Account.Name, "") uniqName, err := account.UniqueName(ctx, dbConn, req.Account.Name, "")
if err != nil { if err != nil {
return nil, err return nil, err
} }
ctx = context.WithValue(ctx, KeyTagUniqueName, uniqName)
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)
// Validate the request. // Validate the request.
err = v.Struct(req) err = Validator().StructCtx(ctx, req)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -254,3 +254,93 @@ func (s UserAccountRoles) Value() (driver.Value, error) {
return arr.Value() 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"`
}

View File

@ -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
}