You've already forked golang-saas-starter-kit
mirror of
https://github.com/raseels-repos/golang-saas-starter-kit.git
synced 2025-08-06 22:32:51 +02:00
Merge branch 'issue16/web-app-signup' of gitlab.com:geeks-accelerator/oss/saas-starter-kit into issue16/web-app-signup
This commit is contained in:
@ -49,7 +49,7 @@ func (h *Projects) Index(ctx context.Context, w http.ResponseWriter, r *http.Req
|
||||
return err
|
||||
}
|
||||
|
||||
statusOpts := web.NewEnumResponse(ctx, nil, project.ProjectStatus_ValuesInterface()...)
|
||||
statusOpts := web.NewEnumResponse(ctx, nil, project.ProjectStatus_ValuesInterface()...)
|
||||
|
||||
statusFilterItems := []datatable.FilterOptionItem{}
|
||||
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 {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
return false,err
|
||||
return false, err
|
||||
}
|
||||
|
||||
decoder := schema.NewDecoder()
|
||||
if err := decoder.Decode(req, r.PostForm); err != nil {
|
||||
return false,err
|
||||
return false, err
|
||||
}
|
||||
|
||||
sessionTTL := time.Hour
|
||||
@ -76,16 +76,16 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request
|
||||
if err != nil {
|
||||
switch errors.Cause(err) {
|
||||
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:
|
||||
data["error"] = weberror.NewErrorMessage(ctx, err, http.StatusUnauthorized, "Authentication failure. Try again.")
|
||||
return false, nil
|
||||
default:
|
||||
if verr, ok := weberror.NewValidationError(ctx, err); ok {
|
||||
data["validationErrors"] = verr.(*weberror.Error)
|
||||
return false,nil
|
||||
return false, nil
|
||||
} 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.
|
||||
err = handleSessionToken(ctx, h.MasterDB, w, r, token)
|
||||
if err != nil {
|
||||
return false,err
|
||||
return false, err
|
||||
}
|
||||
|
||||
redirectUri := "/"
|
||||
if qv := r.URL.Query().Get("redirect"); qv != "" {
|
||||
redirectUri, err = url.QueryUnescape(qv)
|
||||
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.LastName = &usr.LastName
|
||||
req.Email = &usr.Email
|
||||
req.Timezone = &usr.Timezone
|
||||
req.Timezone = usr.Timezone
|
||||
}
|
||||
|
||||
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_account"
|
||||
"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/gorilla/schema"
|
||||
"github.com/jmoiron/sqlx"
|
||||
@ -21,6 +22,7 @@ import (
|
||||
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
statusOpts := web.NewEnumResponse(ctx, nil, user_account.UserAccountStatus_ValuesInterface())
|
||||
statusOpts := web.NewEnumResponse(ctx, nil, user_account.UserAccountStatus_ValuesInterface()...)
|
||||
|
||||
statusFilterItems := []datatable.FilterOptionItem{}
|
||||
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.LastName = &usr.LastName
|
||||
req.Email = &usr.Email
|
||||
req.Timezone = &usr.Timezone
|
||||
req.Timezone = usr.Timezone
|
||||
}
|
||||
|
||||
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.")
|
||||
}
|
||||
|
||||
|
||||
err = webcontext.ContextSession(ctx).Save(r, w)
|
||||
if err != nil {
|
||||
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.
|
||||
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 {
|
||||
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:
|
||||
if verr, ok := weberror.NewValidationError(ctx, err); ok {
|
||||
data["validationErrors"] = verr.(*weberror.Error)
|
||||
@ -629,20 +651,20 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
// Authenticated the user. Probably should use the default session TTL from UserLogin.
|
||||
token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, u.Email, req.Password, time.Hour, ctxValues.Now)
|
||||
// Load the user without any claims applied.
|
||||
usr, err := user.ReadByID(ctx, auth.Claims{}, h.MasterDB, userID)
|
||||
if err != nil {
|
||||
switch errors.Cause(err) {
|
||||
case account.ErrForbidden:
|
||||
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 false, nil
|
||||
} else {
|
||||
return false, err
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Authenticated the user. Probably should use the default session TTL from UserLogin.
|
||||
token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, usr.Email, req.Password, time.Hour, ctxValues.Now)
|
||||
if err != nil {
|
||||
if verr, ok := weberror.NewValidationError(ctx, err); ok {
|
||||
data["validationErrors"] = verr.(*weberror.Error)
|
||||
return false, nil
|
||||
} else {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
@ -652,14 +674,12 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
|
||||
return false, err
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
// Redirect the user to the dashboard.
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
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 {
|
||||
switch errors.Cause(err) {
|
||||
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)
|
||||
return true, nil
|
||||
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.LastName = usr.LastName
|
||||
req.Email = usr.Email
|
||||
|
||||
if usr.Timezone != "" {
|
||||
req.Timezone = &usr.Timezone
|
||||
}
|
||||
req.Timezone = usr.Timezone
|
||||
}
|
||||
|
||||
return false, nil
|
||||
@ -705,11 +727,16 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http
|
||||
return nil
|
||||
}
|
||||
|
||||
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(invite.AcceptInviteRequest{})); ok {
|
||||
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" . }}
|
||||
|
||||
<form class="user" method="post" novalidate>
|
||||
<input type="hidden" name="ResetHash" value="{{ $.form.ResetHash }}" />
|
||||
<div class="form-group row">
|
||||
<div class="col-sm-6 mb-3 mb-sm-0">
|
||||
<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>
|
||||
<div class="col-sm-6">
|
||||
<input type="password" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "PasswordConfirm" }}" name="PasswordConfirm" value="{{ $.form.PasswordConfirm }}" placeholder="Repeat Password" required>
|
||||
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "PasswordConfirm" }}
|
||||
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="inputFirstName">First Name</label>
|
||||
<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>
|
||||
<button class="btn btn-primary btn-user btn-block">
|
||||
Join
|
||||
</button>
|
||||
<hr>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -58,8 +103,25 @@
|
||||
{{end}}
|
||||
{{define "js"}}
|
||||
<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).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>
|
||||
{{end}}
|
@ -121,7 +121,7 @@ type EnumMultiResponse []EnumOption
|
||||
|
||||
// NewEnumMultiResponse returns a display friendly format for a multi enum field.
|
||||
func NewEnumMultiResponse(ctx context.Context, selected []interface{}, options ...interface{}) EnumMultiResponse {
|
||||
var er EnumMultiResponse
|
||||
var er EnumMultiResponse
|
||||
|
||||
for _, opt := range options {
|
||||
optStr := fmt.Sprintf("%s", opt)
|
||||
@ -137,7 +137,6 @@ func NewEnumMultiResponse(ctx context.Context, selected []interface{}, options .
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
er = append(er, opt)
|
||||
}
|
||||
|
||||
|
@ -585,5 +585,25 @@ func migrationList(db *sqlx.DB, log *log.Logger, isUnittest bool) []*sqlxmigrate
|
||||
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"`
|
||||
PasswordHash []byte `json:"-" validate:"required"`
|
||||
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"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ArchivedAt *pq.NullTime `json:"archived_at,omitempty"`
|
||||
@ -52,12 +52,15 @@ func (m *User) Response(ctx context.Context) *UserResponse {
|
||||
FirstName: m.FirstName,
|
||||
LastName: m.LastName,
|
||||
Email: m.Email,
|
||||
Timezone: m.Timezone,
|
||||
CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt),
|
||||
UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt),
|
||||
Gravatar: web.NewGravatarResponse(ctx, m.Email),
|
||||
}
|
||||
|
||||
if m.Timezone != nil {
|
||||
r.Timezone = *m.Timezone
|
||||
}
|
||||
|
||||
if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() {
|
||||
at := web.NewTimeResponse(ctx, m.ArchivedAt.Time)
|
||||
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")
|
||||
defer span.Finish()
|
||||
|
||||
if req.Timezone != nil && *req.Timezone == "" {
|
||||
req.Timezone = nil
|
||||
}
|
||||
|
||||
v := webcontext.Validator()
|
||||
|
||||
// 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,
|
||||
LastName: req.LastName,
|
||||
Email: req.Email,
|
||||
Timezone: req.Timezone,
|
||||
PasswordHash: passwordHash,
|
||||
PasswordSalt: passwordSalt,
|
||||
Timezone: "America/Anchorage",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if req.Timezone != nil {
|
||||
u.Timezone = *req.Timezone
|
||||
}
|
||||
|
||||
// Build the insert SQL statement.
|
||||
query := sqlbuilder.NewInsertBuilder()
|
||||
query.InsertInto(userTableName)
|
||||
@ -542,8 +542,8 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUp
|
||||
if req.Email != nil {
|
||||
fields = append(fields, query.Assign("email", req.Email))
|
||||
}
|
||||
if req.Timezone != nil {
|
||||
fields = append(fields, query.Assign("timezone", req.Timezone))
|
||||
if req.Timezone != nil && *req.Timezone != "" {
|
||||
fields = append(fields, query.Assign("timezone", *req.Timezone))
|
||||
}
|
||||
|
||||
// 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 = 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 = errors.New("User password set")
|
||||
)
|
||||
@ -147,7 +153,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r
|
||||
|
||||
var inviteHashes []string
|
||||
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 {
|
||||
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.
|
||||
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")
|
||||
defer span.Finish()
|
||||
|
||||
@ -183,40 +189,59 @@ func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest,
|
||||
// Validate the request.
|
||||
err := v.StructCtx(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
hash, err := ParseInviteHash(ctx, secretKey, req.InviteHash, now)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
u, err := user.Read(ctx, auth.Claims{}, dbConn,
|
||||
user.UserReadRequest{ID: hash.UserID, IncludeArchived: true})
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
if u.ArchivedAt != nil && !u.ArchivedAt.Time.IsZero() {
|
||||
err = user.Restore(ctx, auth.Claims{}, dbConn, user.UserRestoreRequest{ID: hash.UserID}, now)
|
||||
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{
|
||||
ID: hash.UserID,
|
||||
Email: &req.Email,
|
||||
Email: &req.Email,
|
||||
FirstName: &req.FirstName,
|
||||
LastName: &req.LastName,
|
||||
Timezone: req.Timezone,
|
||||
}, now)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
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,
|
||||
}, now)
|
||||
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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/sudo-suhas/symcrypto"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
|
||||
)
|
||||
|
||||
// SendUserInvitesRequest defines the data needed to make an invite request.
|
||||
@ -24,6 +25,7 @@ type SendUserInvitesRequest struct {
|
||||
// InviteHash
|
||||
type InviteHash struct {
|
||||
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"`
|
||||
ExpiresAt int `json:"expires_at" validate:"required"`
|
||||
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.
|
||||
type AcceptInviteRequest struct {
|
||||
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"`
|
||||
LastName string `json:"last_name" validate:"required" example:"May"`
|
||||
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.
|
||||
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.
|
||||
hashPts := []string{
|
||||
userID,
|
||||
accountID,
|
||||
strconv.Itoa(int(now.UTC().Unix())),
|
||||
strconv.Itoa(int(now.UTC().Add(ttl).Unix())),
|
||||
requestIp,
|
||||
}
|
||||
hashStr := strings.Join(hashPts, "|")
|
||||
|
||||
|
||||
// This returns the nonce appended with the encrypted string.
|
||||
crypto, err := symcrypto.New(secretKey)
|
||||
if err != nil {
|
||||
@ -77,12 +79,15 @@ func ParseInviteHash(ctx context.Context, secretKey string, str string, now time
|
||||
}
|
||||
hashPts := strings.Split(hashStr, "|")
|
||||
|
||||
fmt.Println(hashPts)
|
||||
|
||||
var hash InviteHash
|
||||
if len(hashPts) == 4 {
|
||||
if len(hashPts) == 5 {
|
||||
hash.UserID = hashPts[0]
|
||||
hash.CreatedAt, _ = strconv.Atoi(hashPts[1])
|
||||
hash.ExpiresAt, _ = strconv.Atoi(hashPts[2])
|
||||
hash.RequestIP = hashPts[3]
|
||||
hash.AccountID = hashPts[1]
|
||||
hash.CreatedAt, _ = strconv.Atoi(hashPts[2])
|
||||
hash.ExpiresAt, _ = strconv.Atoi(hashPts[3])
|
||||
hash.RequestIP = hashPts[4]
|
||||
}
|
||||
|
||||
// Validate the hash.
|
||||
|
@ -280,7 +280,7 @@ type User 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:"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"`
|
||||
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"`
|
||||
@ -319,7 +319,6 @@ func (m *User) Response(ctx context.Context) *UserResponse {
|
||||
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),
|
||||
@ -328,6 +327,10 @@ func (m *User) Response(ctx context.Context) *UserResponse {
|
||||
Gravatar: web.NewGravatarResponse(ctx, m.Email),
|
||||
}
|
||||
|
||||
if m.Timezone != nil {
|
||||
r.Timezone = *m.Timezone
|
||||
}
|
||||
|
||||
if r.Name == "" {
|
||||
r.Name = r.Email
|
||||
}
|
||||
|
Reference in New Issue
Block a user