You've already forked golang-saas-starter-kit
mirror of
https://github.com/raseels-repos/golang-saas-starter-kit.git
synced 2025-08-08 22:36:41 +02:00
Completed user invite
This commit is contained in:
@ -49,7 +49,7 @@ func (h *Projects) Index(ctx context.Context, w http.ResponseWriter, r *http.Req
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
statusOpts := web.NewEnumResponse(ctx, nil, project.ProjectStatus_ValuesInterface()...)
|
statusOpts := web.NewEnumResponse(ctx, nil, project.ProjectStatus_ValuesInterface()...)
|
||||||
|
|
||||||
statusFilterItems := []datatable.FilterOptionItem{}
|
statusFilterItems := []datatable.FilterOptionItem{}
|
||||||
for _, opt := range statusOpts.Options {
|
for _, opt := range statusOpts.Options {
|
||||||
|
@ -58,12 +58,12 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request
|
|||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false,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 false,err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionTTL := time.Hour
|
sessionTTL := time.Hour
|
||||||
@ -76,16 +76,16 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
switch errors.Cause(err) {
|
switch errors.Cause(err) {
|
||||||
case user.ErrForbidden:
|
case user.ErrForbidden:
|
||||||
return false,web.RespondError(ctx, w, weberror.NewError(ctx, err, http.StatusForbidden))
|
return false, web.RespondError(ctx, w, weberror.NewError(ctx, err, http.StatusForbidden))
|
||||||
case user_auth.ErrAuthenticationFailure:
|
case user_auth.ErrAuthenticationFailure:
|
||||||
data["error"] = weberror.NewErrorMessage(ctx, err, http.StatusUnauthorized, "Authentication failure. Try again.")
|
data["error"] = weberror.NewErrorMessage(ctx, err, http.StatusUnauthorized, "Authentication failure. Try again.")
|
||||||
return false, nil
|
return false, nil
|
||||||
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 false,nil
|
return false, nil
|
||||||
} else {
|
} else {
|
||||||
return false,err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,14 +93,14 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request
|
|||||||
// 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 false,err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectUri := "/"
|
redirectUri := "/"
|
||||||
if qv := r.URL.Query().Get("redirect"); qv != "" {
|
if qv := r.URL.Query().Get("redirect"); qv != "" {
|
||||||
redirectUri, err = url.QueryUnescape(qv)
|
redirectUri, err = url.QueryUnescape(qv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false,err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -426,7 +426,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques
|
|||||||
req.FirstName = &usr.FirstName
|
req.FirstName = &usr.FirstName
|
||||||
req.LastName = &usr.LastName
|
req.LastName = &usr.LastName
|
||||||
req.Email = &usr.Email
|
req.Email = &usr.Email
|
||||||
req.Timezone = &usr.Timezone
|
req.Timezone = usr.Timezone
|
||||||
}
|
}
|
||||||
|
|
||||||
data["user"] = usr.Response(ctx)
|
data["user"] = usr.Response(ctx)
|
||||||
|
@ -14,6 +14,7 @@ import (
|
|||||||
"geeks-accelerator/oss/saas-starter-kit/internal/user"
|
"geeks-accelerator/oss/saas-starter-kit/internal/user"
|
||||||
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
|
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
|
||||||
"geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite"
|
"geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite"
|
||||||
|
"geeks-accelerator/oss/saas-starter-kit/internal/user_auth"
|
||||||
"github.com/dustin/go-humanize/english"
|
"github.com/dustin/go-humanize/english"
|
||||||
"github.com/gorilla/schema"
|
"github.com/gorilla/schema"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
@ -21,6 +22,7 @@ import (
|
|||||||
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
|
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Users represents the Users API method handler set.
|
// Users represents the Users API method handler set.
|
||||||
@ -64,7 +66,7 @@ func (h *Users) Index(ctx context.Context, w http.ResponseWriter, r *http.Reques
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
statusOpts := web.NewEnumResponse(ctx, nil, user_account.UserAccountStatus_ValuesInterface())
|
statusOpts := web.NewEnumResponse(ctx, nil, user_account.UserAccountStatus_ValuesInterface()...)
|
||||||
|
|
||||||
statusFilterItems := []datatable.FilterOptionItem{}
|
statusFilterItems := []datatable.FilterOptionItem{}
|
||||||
for _, opt := range statusOpts.Options {
|
for _, opt := range statusOpts.Options {
|
||||||
@ -471,7 +473,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque
|
|||||||
req.FirstName = &usr.FirstName
|
req.FirstName = &usr.FirstName
|
||||||
req.LastName = &usr.LastName
|
req.LastName = &usr.LastName
|
||||||
req.Email = &usr.Email
|
req.Email = &usr.Email
|
||||||
req.Timezone = &usr.Timezone
|
req.Timezone = usr.Timezone
|
||||||
}
|
}
|
||||||
|
|
||||||
data["user"] = usr.Response(ctx)
|
data["user"] = usr.Response(ctx)
|
||||||
@ -552,7 +554,6 @@ func (h *Users) Invite(ctx context.Context, w http.ResponseWriter, r *http.Reque
|
|||||||
"No users were invited.")
|
"No users were invited.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
err = webcontext.ContextSession(ctx).Save(r, w)
|
err = webcontext.ContextSession(ctx).Save(r, w)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@ -616,9 +617,30 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
|
|||||||
// Append the query param value to the request.
|
// Append the query param value to the request.
|
||||||
req.InviteHash = inviteHash
|
req.InviteHash = inviteHash
|
||||||
|
|
||||||
err = invite.AcceptInvite(ctx, h.MasterDB, *req, h.SecretKey, ctxValues.Now)
|
userID, err := invite.AcceptInvite(ctx, h.MasterDB, *req, h.SecretKey, ctxValues.Now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch errors.Cause(err) {
|
switch errors.Cause(err) {
|
||||||
|
case invite.ErrInviteExpired:
|
||||||
|
webcontext.SessionFlashError(ctx,
|
||||||
|
"Invite Expired",
|
||||||
|
"The invite has expired.")
|
||||||
|
return false, nil
|
||||||
|
case invite.ErrUserAccountActive:
|
||||||
|
webcontext.SessionFlashError(ctx,
|
||||||
|
"User already Active",
|
||||||
|
"The user already is already active for the account. Try to login or use forgot password.")
|
||||||
|
http.Redirect(w, r, "/user/login", http.StatusFound)
|
||||||
|
return true, nil
|
||||||
|
case invite.ErrInviteUserPasswordSet:
|
||||||
|
webcontext.SessionFlashError(ctx,
|
||||||
|
"Invite already Accepted",
|
||||||
|
"The invite has already been accepted. Try to login or use forgot password.")
|
||||||
|
http.Redirect(w, r, "/user/login", http.StatusFound)
|
||||||
|
return true, nil
|
||||||
|
case user_account.ErrNotFound:
|
||||||
|
return false, err
|
||||||
|
case invite.ErrNoPendingInvite:
|
||||||
|
return false, err
|
||||||
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)
|
||||||
@ -629,20 +651,20 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
// Load the user without any claims applied.
|
||||||
// Authenticated the user. Probably should use the default session TTL from UserLogin.
|
usr, err := user.ReadByID(ctx, auth.Claims{}, h.MasterDB, userID)
|
||||||
token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, u.Email, req.Password, time.Hour, ctxValues.Now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch errors.Cause(err) {
|
return false, err
|
||||||
case account.ErrForbidden:
|
}
|
||||||
return false, web.RespondError(ctx, w, weberror.NewError(ctx, err, http.StatusForbidden))
|
|
||||||
default:
|
// Authenticated the user. Probably should use the default session TTL from UserLogin.
|
||||||
if verr, ok := weberror.NewValidationError(ctx, err); ok {
|
token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, usr.Email, req.Password, time.Hour, ctxValues.Now)
|
||||||
data["validationErrors"] = verr.(*weberror.Error)
|
if err != nil {
|
||||||
return false, nil
|
if verr, ok := weberror.NewValidationError(ctx, err); ok {
|
||||||
} else {
|
data["validationErrors"] = verr.(*weberror.Error)
|
||||||
return false, err
|
return false, nil
|
||||||
}
|
} else {
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -651,15 +673,13 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, 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 true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := invite.ParseInviteHash(ctx, h.SecretKey, inviteHash, ctxValues.Now)
|
hash, err := invite.ParseInviteHash(ctx, h.SecretKey, inviteHash, ctxValues.Now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch errors.Cause(err) {
|
switch errors.Cause(err) {
|
||||||
case invite.ErrInviteExpired:
|
case invite.ErrInviteExpired:
|
||||||
@ -674,7 +694,12 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
|
|||||||
http.Redirect(w, r, "/user/login", http.StatusFound)
|
http.Redirect(w, r, "/user/login", http.StatusFound)
|
||||||
return true, nil
|
return true, nil
|
||||||
default:
|
default:
|
||||||
return false, err
|
if verr, ok := weberror.NewValidationError(ctx, err); ok {
|
||||||
|
data["validationErrors"] = verr.(*weberror.Error)
|
||||||
|
return false, nil
|
||||||
|
} else {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -689,10 +714,7 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
|
|||||||
req.FirstName = usr.FirstName
|
req.FirstName = usr.FirstName
|
||||||
req.LastName = usr.LastName
|
req.LastName = usr.LastName
|
||||||
req.Email = usr.Email
|
req.Email = usr.Email
|
||||||
|
req.Timezone = usr.Timezone
|
||||||
if usr.Timezone != "" {
|
|
||||||
req.Timezone = &usr.Timezone
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, nil
|
return false, nil
|
||||||
@ -705,11 +727,16 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
data["form"] = req
|
data["form"] = req
|
||||||
|
|
||||||
if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(invite.AcceptInviteRequest{})); ok {
|
if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(invite.AcceptInviteRequest{})); ok {
|
||||||
data["validationDefaults"] = verr.(*weberror.Error)
|
data["validationDefaults"] = verr.(*weberror.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "user-invite-accept.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
|
return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "users-invite-accept.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
|
||||||
}
|
}
|
||||||
|
@ -28,22 +28,67 @@
|
|||||||
{{ template "validation-error" . }}
|
{{ template "validation-error" . }}
|
||||||
|
|
||||||
<form class="user" method="post" novalidate>
|
<form class="user" method="post" novalidate>
|
||||||
<input type="hidden" name="ResetHash" value="{{ $.form.ResetHash }}" />
|
|
||||||
<div class="form-group row">
|
<div class="card shadow">
|
||||||
<div class="col-sm-6 mb-3 mb-sm-0">
|
<div class="card-body">
|
||||||
<input type="password" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Password" }}" name="Password" value="{{ $.form.Password }}" placeholder="Password" required>
|
|
||||||
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Password" }}
|
<div class="row mb-2">
|
||||||
</div>
|
<div class="col-md-6">
|
||||||
<div class="col-sm-6">
|
<div class="form-group">
|
||||||
<input type="password" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "PasswordConfirm" }}" name="PasswordConfirm" value="{{ $.form.PasswordConfirm }}" placeholder="Repeat Password" required>
|
<label for="inputFirstName">First Name</label>
|
||||||
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "PasswordConfirm" }}
|
<input type="text" class="form-control {{ ValidationFieldClass $.validationErrors "FirstName" }}" placeholder="enter first name" name="FirstName" value="{{ .form.FirstName }}" required>
|
||||||
|
{{template "invalid-feedback" dict "validationDefaults" $.userValidationDefaults "validationErrors" $.validationErrors "fieldName" "FirstName" }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inputLastName">Last Name</label>
|
||||||
|
<input type="text" class="form-control {{ ValidationFieldClass $.validationErrors "LastName" }}" placeholder="enter last name" name="LastName" value="{{ .form.LastName }}" required>
|
||||||
|
{{template "invalid-feedback" dict "validationDefaults" $.userValidationDefaults "validationErrors" $.validationErrors "fieldName" "LastName" }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inputEmail">Email</label>
|
||||||
|
<input type="text" class="form-control {{ ValidationFieldClass $.validationErrors "Email" }}" placeholder="enter email" name="Email" value="{{ .form.Email }}" required>
|
||||||
|
{{template "invalid-feedback" dict "validationDefaults" $.userValidationDefaults "validationErrors" $.validationErrors "fieldName" "Email" }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inputTimezone">Timezone</label>
|
||||||
|
<select class="form-control {{ ValidationFieldClass $.validationErrors "Timezone" }}" name="Timezone">
|
||||||
|
<option value="">Not set</option>
|
||||||
|
{{ range $idx, $t := .timezones }}
|
||||||
|
<option value="{{ $t }}" {{ if CmpString $t $.form.Timezone }}selected="selected"{{ end }}>{{ $t }}</option>
|
||||||
|
{{ end }}
|
||||||
|
</select>
|
||||||
|
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Timezone" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inputPassword">Password</label>
|
||||||
|
<input type="password" class="form-control" id="inputPassword" placeholder="" name="Password" value="">
|
||||||
|
<span class="help-block "><small><a a href="javascript:void(0)" id="btnGeneratePassword"><i class="fas fa-random mr-1"></i>Generate random password </a></small></span>
|
||||||
|
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Password" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inputPasswordConfirm">Confirm Password</label>
|
||||||
|
<input type="password" class="form-control" id="inputPasswordConfirm" placeholder="" name="PasswordConfirm" value="">
|
||||||
|
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "PasswordConfirm" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<input type="submit" value="Save" class="btn btn-primary"/>
|
||||||
|
<a href="/users/{{ .user.ID }}" class="ml-2 btn btn-secondary" >Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-user btn-block">
|
|
||||||
Join
|
|
||||||
</button>
|
|
||||||
<hr>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -58,8 +103,25 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{define "js"}}
|
{{define "js"}}
|
||||||
<script>
|
<script>
|
||||||
|
function randomPassword(length) {
|
||||||
|
var chars = "abcdefghijklmnopqrstuvwxyz!@#&*()-+<>ABCDEFGHIJKLMNOP1234567890";
|
||||||
|
var pass = "";
|
||||||
|
for (var x = 0; x < length; x++) {
|
||||||
|
var i = Math.floor(Math.random() * chars.length);
|
||||||
|
pass += chars.charAt(i);
|
||||||
|
}
|
||||||
|
return pass;
|
||||||
|
}
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
$(document).find('body').addClass('bg-gradient-primary');
|
$(document).find('body').addClass('bg-gradient-primary');
|
||||||
|
|
||||||
|
$("#btnGeneratePassword").on("click", function() {
|
||||||
|
pwd = randomPassword(12);
|
||||||
|
$("#inputPassword").attr('type', 'text').val(pwd)
|
||||||
|
$("#inputPasswordConfirm").attr('type', 'text').val(pwd)
|
||||||
|
return false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
@ -121,7 +121,7 @@ type EnumMultiResponse []EnumOption
|
|||||||
|
|
||||||
// NewEnumMultiResponse returns a display friendly format for a multi enum field.
|
// NewEnumMultiResponse returns a display friendly format for a multi enum field.
|
||||||
func NewEnumMultiResponse(ctx context.Context, selected []interface{}, options ...interface{}) EnumMultiResponse {
|
func NewEnumMultiResponse(ctx context.Context, selected []interface{}, options ...interface{}) EnumMultiResponse {
|
||||||
var er EnumMultiResponse
|
var er EnumMultiResponse
|
||||||
|
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
optStr := fmt.Sprintf("%s", opt)
|
optStr := fmt.Sprintf("%s", opt)
|
||||||
@ -137,7 +137,6 @@ func NewEnumMultiResponse(ctx context.Context, selected []interface{}, options .
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
er = append(er, opt)
|
er = append(er, opt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,4 +50,4 @@ func (r ProjectRoutes) UserInviteAccept(inviteHash string) string {
|
|||||||
u := r.webAppUrl
|
u := r.webAppUrl
|
||||||
u.Path = "/users/invite/" + inviteHash
|
u.Path = "/users/invite/" + inviteHash
|
||||||
return u.String()
|
return u.String()
|
||||||
}
|
}
|
||||||
|
@ -585,5 +585,25 @@ func migrationList(db *sqlx.DB, log *log.Logger, isUnittest bool) []*sqlxmigrate
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Remove default value for users.timezone.
|
||||||
|
{
|
||||||
|
ID: "20190805-01",
|
||||||
|
Migrate: func(tx *sql.Tx) error {
|
||||||
|
q1 := `ALTER TABLE users ALTER COLUMN timezone DROP DEFAULT`
|
||||||
|
if _, err := tx.Exec(q1); err != nil {
|
||||||
|
return errors.WithMessagef(err, "Query failed %s", q1)
|
||||||
|
}
|
||||||
|
|
||||||
|
q2 := `ALTER TABLE users ALTER COLUMN timezone DROP NOT NULL`
|
||||||
|
if _, err := tx.Exec(q2); err != nil {
|
||||||
|
return errors.WithMessagef(err, "Query failed %s", q2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Rollback: func(tx *sql.Tx) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,7 +19,7 @@ type User struct {
|
|||||||
PasswordSalt string `json:"-" validate:"required"`
|
PasswordSalt string `json:"-" validate:"required"`
|
||||||
PasswordHash []byte `json:"-" validate:"required"`
|
PasswordHash []byte `json:"-" validate:"required"`
|
||||||
PasswordReset *sql.NullString `json:"-"`
|
PasswordReset *sql.NullString `json:"-"`
|
||||||
Timezone string `json:"timezone" validate:"omitempty" example:"America/Anchorage"`
|
Timezone *string `json:"timezone" validate:"omitempty" example:"America/Anchorage"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
ArchivedAt *pq.NullTime `json:"archived_at,omitempty"`
|
ArchivedAt *pq.NullTime `json:"archived_at,omitempty"`
|
||||||
@ -52,12 +52,15 @@ func (m *User) Response(ctx context.Context) *UserResponse {
|
|||||||
FirstName: m.FirstName,
|
FirstName: m.FirstName,
|
||||||
LastName: m.LastName,
|
LastName: m.LastName,
|
||||||
Email: m.Email,
|
Email: m.Email,
|
||||||
Timezone: m.Timezone,
|
|
||||||
CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt),
|
CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt),
|
||||||
UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt),
|
UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt),
|
||||||
Gravatar: web.NewGravatarResponse(ctx, m.Email),
|
Gravatar: web.NewGravatarResponse(ctx, m.Email),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.Timezone != nil {
|
||||||
|
r.Timezone = *m.Timezone
|
||||||
|
}
|
||||||
|
|
||||||
if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() {
|
if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() {
|
||||||
at := web.NewTimeResponse(ctx, m.ArchivedAt.Time)
|
at := web.NewTimeResponse(ctx, m.ArchivedAt.Time)
|
||||||
r.ArchivedAt = &at
|
r.ArchivedAt = &at
|
||||||
|
@ -280,6 +280,10 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCr
|
|||||||
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Create")
|
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Create")
|
||||||
defer span.Finish()
|
defer span.Finish()
|
||||||
|
|
||||||
|
if req.Timezone != nil && *req.Timezone == "" {
|
||||||
|
req.Timezone = nil
|
||||||
|
}
|
||||||
|
|
||||||
v := webcontext.Validator()
|
v := webcontext.Validator()
|
||||||
|
|
||||||
// Validation email address is unique in the database.
|
// Validation email address is unique in the database.
|
||||||
@ -330,17 +334,13 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCr
|
|||||||
FirstName: req.FirstName,
|
FirstName: req.FirstName,
|
||||||
LastName: req.LastName,
|
LastName: req.LastName,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
|
Timezone: req.Timezone,
|
||||||
PasswordHash: passwordHash,
|
PasswordHash: passwordHash,
|
||||||
PasswordSalt: passwordSalt,
|
PasswordSalt: passwordSalt,
|
||||||
Timezone: "America/Anchorage",
|
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Timezone != nil {
|
|
||||||
u.Timezone = *req.Timezone
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the insert SQL statement.
|
// Build the insert SQL statement.
|
||||||
query := sqlbuilder.NewInsertBuilder()
|
query := sqlbuilder.NewInsertBuilder()
|
||||||
query.InsertInto(userTableName)
|
query.InsertInto(userTableName)
|
||||||
@ -542,8 +542,8 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUp
|
|||||||
if req.Email != nil {
|
if req.Email != nil {
|
||||||
fields = append(fields, query.Assign("email", req.Email))
|
fields = append(fields, query.Assign("email", req.Email))
|
||||||
}
|
}
|
||||||
if req.Timezone != nil {
|
if req.Timezone != nil && *req.Timezone != "" {
|
||||||
fields = append(fields, query.Assign("timezone", req.Timezone))
|
fields = append(fields, query.Assign("timezone", *req.Timezone))
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there's nothing to update we can quit early.
|
// If there's nothing to update we can quit early.
|
||||||
|
@ -21,6 +21,12 @@ var (
|
|||||||
// ErrInviteExpired occurs when the the reset hash exceeds the expiration.
|
// ErrInviteExpired occurs when the the reset hash exceeds the expiration.
|
||||||
ErrInviteExpired = errors.New("Invite expired")
|
ErrInviteExpired = errors.New("Invite expired")
|
||||||
|
|
||||||
|
// ErrNoPendingInvite occurs when the user does not have an entry in user_accounts with status pending.
|
||||||
|
ErrNoPendingInvite = errors.New("No pending invite.")
|
||||||
|
|
||||||
|
// ErrUserAccountActive occurs when the user already has an active user_account entry.
|
||||||
|
ErrUserAccountActive = errors.New("User already active.")
|
||||||
|
|
||||||
// ErrInviteUserPasswordSet occurs when the the reset hash exceeds the expiration.
|
// ErrInviteUserPasswordSet occurs when the the reset hash exceeds the expiration.
|
||||||
ErrInviteUserPasswordSet = errors.New("User password set")
|
ErrInviteUserPasswordSet = errors.New("User password set")
|
||||||
)
|
)
|
||||||
@ -147,7 +153,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r
|
|||||||
|
|
||||||
var inviteHashes []string
|
var inviteHashes []string
|
||||||
for email, userID := range emailUserIDs {
|
for email, userID := range emailUserIDs {
|
||||||
hash, err := NewInviteHash(ctx, secretKey, userID, requestIp, req.TTL, now)
|
hash, err := NewInviteHash(ctx, secretKey, userID, req.AccountID, requestIp, req.TTL, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -174,7 +180,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AcceptInvite updates the user using the provided invite hash.
|
// AcceptInvite updates the user using the provided invite hash.
|
||||||
func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest, secretKey string, now time.Time) error {
|
func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest, secretKey string, now time.Time) (string, error) {
|
||||||
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.AcceptInvite")
|
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.AcceptInvite")
|
||||||
defer span.Finish()
|
defer span.Finish()
|
||||||
|
|
||||||
@ -183,40 +189,59 @@ func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest,
|
|||||||
// Validate the request.
|
// Validate the request.
|
||||||
err := v.StructCtx(ctx, req)
|
err := v.StructCtx(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := ParseInviteHash(ctx, secretKey, req.InviteHash, now)
|
hash, err := ParseInviteHash(ctx, secretKey, req.InviteHash, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := user.Read(ctx, auth.Claims{}, dbConn,
|
u, err := user.Read(ctx, auth.Claims{}, dbConn,
|
||||||
user.UserReadRequest{ID: hash.UserID, IncludeArchived: true})
|
user.UserReadRequest{ID: hash.UserID, IncludeArchived: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.ArchivedAt != nil && !u.ArchivedAt.Time.IsZero() {
|
if u.ArchivedAt != nil && !u.ArchivedAt.Time.IsZero() {
|
||||||
err = user.Restore(ctx, auth.Claims{}, dbConn, user.UserRestoreRequest{ID: hash.UserID}, now)
|
err = user.Restore(ctx, auth.Claims{}, dbConn, user.UserRestoreRequest{ID: hash.UserID}, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
} else if len(u.PasswordHash) > 0 {
|
|
||||||
// Do not update the password for a user that already has a password set.
|
|
||||||
err = errors.WithMessage(ErrInviteUserPasswordSet, "Invite user already has a password set.")
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usrAcc, err := user_account.Read(ctx, auth.Claims{}, dbConn, user_account.UserAccountReadRequest{
|
||||||
|
UserID: hash.UserID,
|
||||||
|
AccountID: hash.AccountID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the entry has the status of invited.
|
||||||
|
if usrAcc.Status != user_account.UserAccountStatus_Invited {
|
||||||
|
// If the entry is already active
|
||||||
|
if usrAcc.Status == user_account.UserAccountStatus_Active {
|
||||||
|
return u.ID, errors.WithStack(ErrUserAccountActive)
|
||||||
|
}
|
||||||
|
return "", errors.WithStack(ErrNoPendingInvite)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(u.PasswordHash) > 0 {
|
||||||
|
// Do not update the password for a user that already has a password set.
|
||||||
|
return "", errors.WithStack(ErrInviteUserPasswordSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// These two calls, user.Update and user.UpdatePassword should probably be in a transaction!
|
||||||
err = user.Update(ctx, auth.Claims{}, dbConn, user.UserUpdateRequest{
|
err = user.Update(ctx, auth.Claims{}, dbConn, user.UserUpdateRequest{
|
||||||
ID: hash.UserID,
|
ID: hash.UserID,
|
||||||
Email: &req.Email,
|
Email: &req.Email,
|
||||||
FirstName: &req.FirstName,
|
FirstName: &req.FirstName,
|
||||||
LastName: &req.LastName,
|
LastName: &req.LastName,
|
||||||
Timezone: req.Timezone,
|
Timezone: req.Timezone,
|
||||||
}, now)
|
}, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = user.UpdatePassword(ctx, auth.Claims{}, dbConn, user.UserUpdatePasswordRequest{
|
err = user.UpdatePassword(ctx, auth.Claims{}, dbConn, user.UserUpdatePasswordRequest{
|
||||||
@ -225,8 +250,18 @@ func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest,
|
|||||||
PasswordConfirm: req.PasswordConfirm,
|
PasswordConfirm: req.PasswordConfirm,
|
||||||
}, now)
|
}, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
activeStatus := user_account.UserAccountStatus_Active
|
||||||
|
err = user_account.Update(ctx, auth.Claims{}, dbConn, user_account.UserAccountUpdateRequest{
|
||||||
|
UserID: usrAcc.UserID,
|
||||||
|
AccountID: usrAcc.AccountID,
|
||||||
|
Status: &activeStatus,
|
||||||
|
}, now)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash.UserID, nil
|
||||||
}
|
}
|
||||||
|
@ -2,14 +2,15 @@ package invite
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
|
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
|
||||||
|
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/sudo-suhas/symcrypto"
|
"github.com/sudo-suhas/symcrypto"
|
||||||
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SendUserInvitesRequest defines the data needed to make an invite request.
|
// SendUserInvitesRequest defines the data needed to make an invite request.
|
||||||
@ -24,6 +25,7 @@ type SendUserInvitesRequest struct {
|
|||||||
// InviteHash
|
// InviteHash
|
||||||
type InviteHash struct {
|
type InviteHash struct {
|
||||||
UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
|
UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
|
||||||
|
AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
|
||||||
CreatedAt int `json:"created_at" validate:"required"`
|
CreatedAt int `json:"created_at" validate:"required"`
|
||||||
ExpiresAt int `json:"expires_at" validate:"required"`
|
ExpiresAt int `json:"expires_at" validate:"required"`
|
||||||
RequestIP string `json:"request_ip" validate:"required,ip" example:"69.56.104.36"`
|
RequestIP string `json:"request_ip" validate:"required,ip" example:"69.56.104.36"`
|
||||||
@ -32,7 +34,7 @@ type InviteHash struct {
|
|||||||
// AcceptInviteRequest defines the fields need to complete an invite request.
|
// AcceptInviteRequest defines the fields need to complete an invite request.
|
||||||
type AcceptInviteRequest struct {
|
type AcceptInviteRequest struct {
|
||||||
InviteHash string `json:"invite_hash" validate:"required" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
|
InviteHash string `json:"invite_hash" validate:"required" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
|
||||||
Email string `json:"email" validate:"required,email" example:"gabi@geeksinthewoods.com"`
|
Email string `json:"email" validate:"required,email" example:"gabi@geeksinthewoods.com"`
|
||||||
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"`
|
||||||
Password string `json:"password" validate:"required" example:"SecretString"`
|
Password string `json:"password" validate:"required" example:"SecretString"`
|
||||||
@ -41,17 +43,17 @@ type AcceptInviteRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewInviteHash generates a new encrypt invite hash that is web safe for use in URLs.
|
// NewInviteHash generates a new encrypt invite hash that is web safe for use in URLs.
|
||||||
func NewInviteHash(ctx context.Context, secretKey string, userID, requestIp string, ttl time.Duration, now time.Time) (string, error) {
|
func NewInviteHash(ctx context.Context, secretKey, userID, accountID, requestIp string, ttl time.Duration, now time.Time) (string, error) {
|
||||||
// Generate a string that embeds additional information.
|
// Generate a string that embeds additional information.
|
||||||
hashPts := []string{
|
hashPts := []string{
|
||||||
userID,
|
userID,
|
||||||
|
accountID,
|
||||||
strconv.Itoa(int(now.UTC().Unix())),
|
strconv.Itoa(int(now.UTC().Unix())),
|
||||||
strconv.Itoa(int(now.UTC().Add(ttl).Unix())),
|
strconv.Itoa(int(now.UTC().Add(ttl).Unix())),
|
||||||
requestIp,
|
requestIp,
|
||||||
}
|
}
|
||||||
hashStr := strings.Join(hashPts, "|")
|
hashStr := strings.Join(hashPts, "|")
|
||||||
|
|
||||||
|
|
||||||
// This returns the nonce appended with the encrypted string.
|
// This returns the nonce appended with the encrypted string.
|
||||||
crypto, err := symcrypto.New(secretKey)
|
crypto, err := symcrypto.New(secretKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -77,12 +79,15 @@ func ParseInviteHash(ctx context.Context, secretKey string, str string, now time
|
|||||||
}
|
}
|
||||||
hashPts := strings.Split(hashStr, "|")
|
hashPts := strings.Split(hashStr, "|")
|
||||||
|
|
||||||
|
fmt.Println(hashPts)
|
||||||
|
|
||||||
var hash InviteHash
|
var hash InviteHash
|
||||||
if len(hashPts) == 4 {
|
if len(hashPts) == 5 {
|
||||||
hash.UserID = hashPts[0]
|
hash.UserID = hashPts[0]
|
||||||
hash.CreatedAt, _ = strconv.Atoi(hashPts[1])
|
hash.AccountID = hashPts[1]
|
||||||
hash.ExpiresAt, _ = strconv.Atoi(hashPts[2])
|
hash.CreatedAt, _ = strconv.Atoi(hashPts[2])
|
||||||
hash.RequestIP = hashPts[3]
|
hash.ExpiresAt, _ = strconv.Atoi(hashPts[3])
|
||||||
|
hash.RequestIP = hashPts[4]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the hash.
|
// Validate the hash.
|
||||||
|
@ -280,7 +280,7 @@ type User 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:"gabi@geeksinthewoods.com"`
|
Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"`
|
||||||
Timezone string `json:"timezone" validate:"omitempty" example:"America/Anchorage"`
|
Timezone *string `json:"timezone" validate:"omitempty" example:"America/Anchorage"`
|
||||||
AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
|
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"`
|
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"`
|
Status UserAccountStatus `json:"status" validate:"omitempty,oneof=active invited disabled" enums:"active,invited,disabled" swaggertype:"string" example:"active"`
|
||||||
@ -319,7 +319,6 @@ func (m *User) Response(ctx context.Context) *UserResponse {
|
|||||||
FirstName: m.FirstName,
|
FirstName: m.FirstName,
|
||||||
LastName: m.LastName,
|
LastName: m.LastName,
|
||||||
Email: m.Email,
|
Email: m.Email,
|
||||||
Timezone: m.Timezone,
|
|
||||||
AccountID: m.AccountID,
|
AccountID: m.AccountID,
|
||||||
Roles: m.Roles,
|
Roles: m.Roles,
|
||||||
Status: web.NewEnumResponse(ctx, m.Status, UserAccountStatus_Values),
|
Status: web.NewEnumResponse(ctx, m.Status, UserAccountStatus_Values),
|
||||||
@ -328,6 +327,10 @@ func (m *User) Response(ctx context.Context) *UserResponse {
|
|||||||
Gravatar: web.NewGravatarResponse(ctx, m.Email),
|
Gravatar: web.NewGravatarResponse(ctx, m.Email),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.Timezone != nil {
|
||||||
|
r.Timezone = *m.Timezone
|
||||||
|
}
|
||||||
|
|
||||||
if r.Name == "" {
|
if r.Name == "" {
|
||||||
r.Name = r.Email
|
r.Name = r.Email
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user