Commit ed614726 authored by Lee Brown's avatar Lee Brown

fixed signup

parent f7dfa5b0
......@@ -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,
......
......@@ -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
......
......@@ -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)
......
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)
}
{{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}}
{{ 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 }}
This diff is collapsed.
......@@ -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"`
}
......
......@@ -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
}