You've already forked golang-saas-starter-kit
mirror of
https://github.com/raseels-repos/golang-saas-starter-kit.git
synced 2025-06-27 00:51:13 +02:00
invite users
This commit is contained in:
@ -49,12 +49,7 @@ func (h *Projects) Index(ctx context.Context, w http.ResponseWriter, r *http.Req
|
||||
return err
|
||||
}
|
||||
|
||||
var statusValues []interface{}
|
||||
for _, v := range project.ProjectStatus_Values {
|
||||
statusValues = append(statusValues, string(v))
|
||||
}
|
||||
|
||||
statusOpts := web.NewEnumResponse(ctx, nil, statusValues...)
|
||||
statusOpts := web.NewEnumResponse(ctx, nil, project.ProjectStatus_ValuesInterface()...)
|
||||
|
||||
statusFilterItems := []datatable.FilterOptionItem{}
|
||||
for _, opt := range statusOpts.Options {
|
||||
|
@ -68,6 +68,10 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
|
||||
app.Handle("GET", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
|
||||
app.Handle("POST", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
|
||||
app.Handle("GET", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
|
||||
app.Handle("POST", "/users/invite/:hash", us.InviteAccept)
|
||||
app.Handle("GET", "/users/invite/:hash", us.InviteAccept)
|
||||
app.Handle("POST", "/users/invite", us.Invite, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
|
||||
app.Handle("GET", "/users/invite", us.Invite, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
|
||||
app.Handle("POST", "/users/create", us.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
|
||||
app.Handle("GET", "/users/create", us.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
|
||||
app.Handle("GET", "/users", us.Index, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
|
||||
|
@ -230,6 +230,9 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.
|
||||
return err
|
||||
}
|
||||
|
||||
// Append the query param value to the request.
|
||||
req.ResetHash = params["hash"]
|
||||
|
||||
u, err := user.ResetConfirm(ctx, h.MasterDB, *req, h.SecretKey, ctxValues.Now)
|
||||
if err != nil {
|
||||
switch errors.Cause(err) {
|
||||
@ -267,8 +270,6 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.
|
||||
|
||||
// Redirect the user to the dashboard.
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
} else {
|
||||
req.ResetHash = params["hash"]
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -3,9 +3,14 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/account"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/user_auth"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite"
|
||||
"github.com/dustin/go-humanize/english"
|
||||
"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/datatable"
|
||||
@ -63,12 +68,7 @@ func (h *Users) Index(ctx context.Context, w http.ResponseWriter, r *http.Reques
|
||||
return err
|
||||
}
|
||||
|
||||
var statusValues []interface{}
|
||||
for _, v := range user_account.UserAccountStatus_Values {
|
||||
statusValues = append(statusValues, string(v))
|
||||
}
|
||||
|
||||
statusOpts := web.NewEnumResponse(ctx, nil, statusValues...)
|
||||
statusOpts := web.NewEnumResponse(ctx, nil, user_account.UserAccountStatus_ValuesInterface())
|
||||
|
||||
statusFilterItems := []datatable.FilterOptionItem{}
|
||||
for _, opt := range statusOpts.Options {
|
||||
@ -497,3 +497,220 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque
|
||||
|
||||
return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "users-update.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
|
||||
}
|
||||
|
||||
// Invite handles sending invites for users to the account.
|
||||
func (h *Users) Invite(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(invite.SendUserInvitesRequest)
|
||||
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()
|
||||
if err := decoder.Decode(req, r.PostForm); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
req.UserID = claims.Subject
|
||||
req.AccountID = claims.Audience
|
||||
|
||||
res, err := invite.SendUserInvites(ctx, claims, h.MasterDB, h.ProjectRoutes.UserInviteAccept, h.NotifyEmail, *req, h.SecretKey, 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.
|
||||
inviteCnt := len(res)
|
||||
if inviteCnt > 0 {
|
||||
webcontext.SessionFlashSuccess(ctx,
|
||||
fmt.Sprintf("%s Invited", english.PluralWord(inviteCnt, "User", "")),
|
||||
fmt.Sprintf("%s successfully invited. %s been sent to them to join your account.",
|
||||
english.Plural(inviteCnt, "user", ""),
|
||||
english.PluralWord(inviteCnt, "An email has", "Emails have")))
|
||||
} else {
|
||||
webcontext.SessionFlashWarning(ctx,
|
||||
"Users not Invited",
|
||||
"No users were invited.")
|
||||
}
|
||||
|
||||
|
||||
err = webcontext.ContextSession(ctx).Save(r, w)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
http.Redirect(w, r, urlUsersIndex(), 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
|
||||
}
|
||||
|
||||
var selectedRoles []interface{}
|
||||
for _, r := range req.Roles {
|
||||
selectedRoles = append(selectedRoles, r.String())
|
||||
}
|
||||
data["roles"] = web.NewEnumMultiResponse(ctx, selectedRoles, user_account.UserAccountRole_ValuesInterface()...)
|
||||
|
||||
data["form"] = req
|
||||
|
||||
if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(invite.SendUserInvitesRequest{})); ok {
|
||||
data["validationDefaults"] = verr.(*weberror.Error)
|
||||
}
|
||||
|
||||
return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "users-invite.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
|
||||
}
|
||||
|
||||
// Invite handles sending invites for users to the account.
|
||||
func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
|
||||
|
||||
inviteHash := params["hash"]
|
||||
|
||||
ctxValues, err := webcontext.ContextValues(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//
|
||||
req := new(invite.AcceptInviteRequest)
|
||||
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()
|
||||
if err := decoder.Decode(req, r.PostForm); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Append the query param value to the request.
|
||||
req.InviteHash = inviteHash
|
||||
|
||||
err = invite.AcceptInvite(ctx, h.MasterDB, *req, h.SecretKey, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the token to the users session.
|
||||
err = handleSessionToken(ctx, h.MasterDB, w, r, token)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
switch errors.Cause(err) {
|
||||
case invite.ErrInviteExpired:
|
||||
webcontext.SessionFlashError(ctx,
|
||||
"Invite Expired",
|
||||
"The invite has expired.")
|
||||
return false, 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
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Read user by ID with no claims.
|
||||
usr, err := user.ReadByID(ctx, auth.Claims{}, h.MasterDB, hash.UserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
data["user"] = usr.Response(ctx)
|
||||
|
||||
if req.Email == "" {
|
||||
req.FirstName = usr.FirstName
|
||||
req.LastName = usr.LastName
|
||||
req.Email = usr.Email
|
||||
|
||||
if usr.Timezone != "" {
|
||||
req.Timezone = &usr.Timezone
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
@ -16,7 +16,7 @@
|
||||
<label for="selectStatus">Status</label>
|
||||
<select class="form-control {{ ValidationFieldClass $.validationErrors "Status" }}"
|
||||
id="selectStatus" name="Status">
|
||||
{{ range $idx, $t := .project.Status.Options }}
|
||||
{{ range $t := .project.Status.Options }}
|
||||
<option value="{{ $t.Value }}" {{ if $t.Selected }}selected="selected"{{ end }}>{{ $t.Title }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
|
65
cmd/web-app/templates/content/users-invite-accept.gohtml
Normal file
65
cmd/web-app/templates/content/users-invite-accept.gohtml
Normal file
@ -0,0 +1,65 @@
|
||||
{{define "title"}}Invite Accept{{end}}
|
||||
{{define "description"}}{{end}}
|
||||
{{define "style"}}
|
||||
|
||||
{{end}}
|
||||
{{ define "partials/app-wrapper" }}
|
||||
<div class="container" id="page-content">
|
||||
|
||||
<!-- Outer Row -->
|
||||
<div class="row justify-content-center">
|
||||
|
||||
<div class="col-xl-10 col-lg-12 col-md-9">
|
||||
|
||||
<div class="card o-hidden border-0 shadow-lg my-5">
|
||||
<div class="card-body p-0">
|
||||
<!-- Nested Row within Card Body -->
|
||||
<div class="row">
|
||||
<div class="col-lg-6 d-none d-lg-block bg-login-image"></div>
|
||||
<div class="col-lg-6">
|
||||
<div class="p-5">
|
||||
{{ template "app-flashes" . }}
|
||||
|
||||
<div class="text-center">
|
||||
<h1 class="h4 text-gray-900 mb-2">Invite Accept</h1>
|
||||
<p class="mb-4">.....</p>
|
||||
</div>
|
||||
|
||||
{{ 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>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-user btn-block">
|
||||
Join
|
||||
</button>
|
||||
<hr>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "js"}}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$(document).find('body').addClass('bg-gradient-primary');
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
86
cmd/web-app/templates/content/users-invite.gohtml
Normal file
86
cmd/web-app/templates/content/users-invite.gohtml
Normal file
@ -0,0 +1,86 @@
|
||||
{{define "title"}}Invite Users{{end}}
|
||||
{{define "content"}}
|
||||
<form method="POST">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label for="inputEmail">Email for Invite 1</label>
|
||||
<input type="text" class="form-control invite-user-email" placeholder="enter email" name="Emails" value="">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label for="selectRoles">Roles</label>
|
||||
<select class="form-control {{ ValidationFieldClass $.validationErrors "Roles" }}"
|
||||
id="selectRoles" name="Roles" multiple="multiple">
|
||||
{{ range $t := .roles }}
|
||||
<option value="{{ $t.Value }}" {{ if $t.Selected }}selected="selected"{{ end }}>{{ $t.Title }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Roles" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spacer-30"></div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<input id="btnSubmit" type="submit" value="Invite Users" class="btn btn-primary"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
{{end}}
|
||||
{{ define "js" }}
|
||||
<script>
|
||||
function addAnotherEmail(el) {
|
||||
if ($(el).val() == '') {
|
||||
return;
|
||||
}
|
||||
|
||||
cnt = 0;
|
||||
$( "input.invite-user-email" ).each(function( index ) {
|
||||
cnt = cnt + 1;
|
||||
});
|
||||
cnt = cnt + 1;
|
||||
|
||||
newId = 'inviteUser'+cnt;
|
||||
newHtml = '';
|
||||
newHtml = newHtml + '<div class="form-group">';
|
||||
newHtml = newHtml + '<label for="inputEmail">Email for Invite '+cnt+'</label>';
|
||||
newHtml = newHtml + '<input type="text" class="form-control invite-user-email" placeholder="enter email" name="Emails" value="">';
|
||||
newHtml = newHtml + '</div>';
|
||||
$(el).closest('div.card-body').append(newHtml);
|
||||
$(el).unbind( "blur" );
|
||||
|
||||
$('#'+newId).on("blur", function() {
|
||||
addAnotherEmail($(this));
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
$("#inviteUser1").on("blur", function() {
|
||||
addAnotherEmail($(this));
|
||||
});
|
||||
|
||||
$("#inputRole").on("change", function() {
|
||||
if ($(this).val() == 'admin') {
|
||||
$('#userProcedures').hide();
|
||||
} else {
|
||||
$('#userProcedures').show();
|
||||
}
|
||||
}).change();
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
@ -116,6 +116,34 @@ func NewEnumResponse(ctx context.Context, value interface{}, options ...interfac
|
||||
return er
|
||||
}
|
||||
|
||||
// EnumResponse is a response friendly format for displaying a multi select enum.
|
||||
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
|
||||
|
||||
for _, opt := range options {
|
||||
optStr := fmt.Sprintf("%s", opt)
|
||||
opt := EnumOption{
|
||||
Value: optStr,
|
||||
Title: EnumValueTitle(optStr),
|
||||
}
|
||||
|
||||
for _, s := range selected {
|
||||
selStr := fmt.Sprintf("%s", s)
|
||||
if optStr == selStr {
|
||||
opt.Selected = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
er = append(er, opt)
|
||||
}
|
||||
|
||||
return er
|
||||
}
|
||||
|
||||
// EnumValueTitle formats a string value for display.
|
||||
func EnumValueTitle(v string) string {
|
||||
v = strings.Replace(v, "_", " ", -1)
|
||||
|
@ -40,8 +40,14 @@ func (r ProjectRoutes) WebApiUrl(urlPath string) string {
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (r ProjectRoutes) UserResetPassword(resetId string) string {
|
||||
func (r ProjectRoutes) UserResetPassword(resetHash string) string {
|
||||
u := r.webAppUrl
|
||||
u.Path = "/user/reset-password/" + resetId
|
||||
u.Path = "/user/reset-password/" + resetHash
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (r ProjectRoutes) UserInviteAccept(inviteHash string) string {
|
||||
u := r.webAppUrl
|
||||
u.Path = "/users/invite/" + inviteHash
|
||||
return u.String()
|
||||
}
|
@ -3,7 +3,6 @@ package invite
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -15,7 +14,6 @@ import (
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sudo-suhas/symcrypto"
|
||||
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
|
||||
)
|
||||
|
||||
@ -149,30 +147,15 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r
|
||||
|
||||
var inviteHashes []string
|
||||
for email, userID := range emailUserIDs {
|
||||
|
||||
// Generate a string that embeds additional information.
|
||||
hashPts := []string{
|
||||
userID,
|
||||
strconv.Itoa(int(now.UTC().Unix())),
|
||||
strconv.Itoa(int(now.UTC().Add(req.TTL).Unix())),
|
||||
requestIp,
|
||||
}
|
||||
hashStr := strings.Join(hashPts, "|")
|
||||
|
||||
// This returns the nonce appended with the encrypted string.
|
||||
crypto, err := symcrypto.New(secretKey)
|
||||
hash, err := NewInviteHash(ctx, secretKey, userID, requestIp, req.TTL, now)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
encrypted, err := crypto.Encrypt(hashStr)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"FromUser": fromUser.Response(ctx),
|
||||
"Account": account.Response(ctx),
|
||||
"Url": resetUrl(encrypted),
|
||||
"Url": resetUrl(hash),
|
||||
"Minutes": req.TTL.Minutes(),
|
||||
}
|
||||
|
||||
@ -184,7 +167,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inviteHashes = append(inviteHashes, encrypted)
|
||||
inviteHashes = append(inviteHashes, hash)
|
||||
}
|
||||
|
||||
return inviteHashes, nil
|
||||
@ -203,32 +186,8 @@ func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest,
|
||||
return err
|
||||
}
|
||||
|
||||
crypto, err := symcrypto.New(secretKey)
|
||||
hash, err := ParseInviteHash(ctx, secretKey, req.InviteHash, now)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
hashStr, err := crypto.Decrypt(req.InviteHash)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
hashPts := strings.Split(hashStr, "|")
|
||||
|
||||
var hash InviteHash
|
||||
if len(hashPts) == 4 {
|
||||
hash.UserID = hashPts[0]
|
||||
hash.CreatedAt, _ = strconv.Atoi(hashPts[1])
|
||||
hash.ExpiresAt, _ = strconv.Atoi(hashPts[2])
|
||||
hash.RequestIP = hashPts[3]
|
||||
}
|
||||
|
||||
// Validate the hash.
|
||||
err = v.StructCtx(ctx, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if int64(hash.ExpiresAt) < now.UTC().Unix() {
|
||||
err = errors.WithMessage(ErrInviteExpired, "Invite has expired.")
|
||||
return err
|
||||
}
|
||||
|
||||
@ -251,6 +210,7 @@ func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest,
|
||||
|
||||
err = user.Update(ctx, auth.Claims{}, dbConn, user.UserUpdateRequest{
|
||||
ID: hash.UserID,
|
||||
Email: &req.Email,
|
||||
FirstName: &req.FirstName,
|
||||
LastName: &req.LastName,
|
||||
Timezone: req.Timezone,
|
||||
|
@ -1,8 +1,14 @@
|
||||
package invite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sudo-suhas/symcrypto"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
|
||||
)
|
||||
|
||||
@ -26,9 +32,69 @@ 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"`
|
||||
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"`
|
||||
PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"SecretString"`
|
||||
Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"`
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Generate a string that embeds additional information.
|
||||
hashPts := []string{
|
||||
userID,
|
||||
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 {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
encrypted, err := crypto.Encrypt(hashStr)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// ParseInviteHash extracts the details encrypted in the hash string.
|
||||
func ParseInviteHash(ctx context.Context, secretKey string, str string, now time.Time) (*InviteHash, error) {
|
||||
crypto, err := symcrypto.New(secretKey)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
hashStr, err := crypto.Decrypt(str)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
hashPts := strings.Split(hashStr, "|")
|
||||
|
||||
var hash InviteHash
|
||||
if len(hashPts) == 4 {
|
||||
hash.UserID = hashPts[0]
|
||||
hash.CreatedAt, _ = strconv.Atoi(hashPts[1])
|
||||
hash.ExpiresAt, _ = strconv.Atoi(hashPts[2])
|
||||
hash.RequestIP = hashPts[3]
|
||||
}
|
||||
|
||||
// Validate the hash.
|
||||
err = webcontext.Validator().StructCtx(ctx, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if int64(hash.ExpiresAt) < now.UTC().Unix() {
|
||||
err = errors.WithMessage(ErrInviteExpired, "Invite has expired.")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &hash, nil
|
||||
}
|
||||
|
@ -328,6 +328,10 @@ func (m *User) Response(ctx context.Context) *UserResponse {
|
||||
Gravatar: web.NewGravatarResponse(ctx, m.Email),
|
||||
}
|
||||
|
||||
if r.Name == "" {
|
||||
r.Name = r.Email
|
||||
}
|
||||
|
||||
if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() {
|
||||
at := web.NewTimeResponse(ctx, m.ArchivedAt.Time)
|
||||
r.ArchivedAt = &at
|
||||
|
Reference in New Issue
Block a user