2019-08-02 15:03:32 -08:00
|
|
|
package invite
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"fmt"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/account"
|
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
|
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/platform/notify"
|
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
|
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/user"
|
|
|
|
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
|
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
// ErrInviteExpired occurs when the the reset hash exceeds the expiration.
|
|
|
|
ErrInviteExpired = errors.New("Invite expired")
|
|
|
|
|
2019-08-05 14:32:45 -08:00
|
|
|
// 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.")
|
2019-08-02 15:03:32 -08:00
|
|
|
)
|
|
|
|
|
2019-08-04 14:48:43 -08:00
|
|
|
// SendUserInvites sends emails to the users inviting them to join an account.
|
|
|
|
func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, resetUrl func(string) string, notify notify.Email, req SendUserInvitesRequest, secretKey string, now time.Time) ([]string, error) {
|
|
|
|
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.SendUserInvites")
|
2019-08-02 15:03:32 -08:00
|
|
|
defer span.Finish()
|
|
|
|
|
|
|
|
v := webcontext.Validator()
|
|
|
|
|
|
|
|
// Validate the request.
|
|
|
|
err := v.StructCtx(ctx, req)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Ensure the claims can modify the account specified in the request.
|
|
|
|
err = user_account.CanModifyAccount(ctx, claims, dbConn, req.AccountID)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Find all the users by email address.
|
|
|
|
emailUserIDs := make(map[string]string)
|
|
|
|
{
|
|
|
|
// Find all users without passing in claims to search all users.
|
|
|
|
users, err := user.Find(ctx, auth.Claims{}, dbConn, user.UserFindRequest{
|
2019-08-05 17:12:28 -08:00
|
|
|
Where: fmt.Sprintf("email in ('%s')",
|
|
|
|
strings.Join(req.Emails, "','")),
|
2019-08-02 15:03:32 -08:00
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, u := range users {
|
|
|
|
emailUserIDs[u.Email] = u.ID
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Find users that are already active for this account.
|
|
|
|
activelUserIDs := make(map[string]bool)
|
|
|
|
{
|
|
|
|
var args []string
|
|
|
|
for _, userID := range emailUserIDs {
|
|
|
|
args = append(args, userID)
|
|
|
|
}
|
|
|
|
|
|
|
|
userAccs, err := user_account.Find(ctx, claims, dbConn, user_account.UserAccountFindRequest{
|
2019-08-05 17:12:28 -08:00
|
|
|
Where: fmt.Sprintf("user_id in ('%s') and status = '%s'",
|
|
|
|
strings.Join(args, "','"),
|
|
|
|
user_account.UserAccountStatus_Active.String()),
|
2019-08-02 15:03:32 -08:00
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, userAcc := range userAccs {
|
|
|
|
activelUserIDs[userAcc.UserID] = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Always store the time as UTC.
|
|
|
|
now = now.UTC()
|
|
|
|
|
|
|
|
// Postgres truncates times to milliseconds when storing. We and do the same
|
|
|
|
// here so the value we return is consistent with what we store.
|
|
|
|
now = now.Truncate(time.Millisecond)
|
|
|
|
|
|
|
|
// Create any users that don't already exist.
|
|
|
|
for _, email := range req.Emails {
|
|
|
|
if uId, ok := emailUserIDs[email]; ok && uId != "" {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
u, err := user.CreateInvite(ctx, claims, dbConn, user.UserCreateInviteRequest{
|
|
|
|
Email: email,
|
|
|
|
}, now)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
emailUserIDs[email] = u.ID
|
|
|
|
}
|
|
|
|
|
|
|
|
// Loop through all the existing users who either do not have an user_account record or
|
|
|
|
// have an existing record, but the status is disabled.
|
|
|
|
for _, userID := range emailUserIDs {
|
|
|
|
// User already is active, skip.
|
|
|
|
if activelUserIDs[userID] {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
status := user_account.UserAccountStatus_Invited
|
|
|
|
_, err = user_account.Create(ctx, claims, dbConn, user_account.UserAccountCreateRequest{
|
|
|
|
UserID: userID,
|
|
|
|
AccountID: req.AccountID,
|
|
|
|
Roles: req.Roles,
|
|
|
|
Status: &status,
|
|
|
|
}, now)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if req.TTL.Seconds() == 0 {
|
|
|
|
req.TTL = time.Minute * 90
|
|
|
|
}
|
|
|
|
|
2019-08-04 14:48:43 -08:00
|
|
|
fromUser, err := user.ReadByID(ctx, claims, dbConn, req.UserID)
|
2019-08-02 15:03:32 -08:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2019-08-04 14:48:43 -08:00
|
|
|
account, err := account.ReadByID(ctx, claims, dbConn, req.AccountID)
|
2019-08-02 15:03:32 -08:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Load the current IP makings the request.
|
|
|
|
var requestIp string
|
|
|
|
if vals, _ := webcontext.ContextValues(ctx); vals != nil {
|
|
|
|
requestIp = vals.RequestIP
|
|
|
|
}
|
|
|
|
|
|
|
|
var inviteHashes []string
|
|
|
|
for email, userID := range emailUserIDs {
|
2019-08-05 14:32:45 -08:00
|
|
|
hash, err := NewInviteHash(ctx, secretKey, userID, req.AccountID, requestIp, req.TTL, now)
|
2019-08-02 15:03:32 -08:00
|
|
|
if err != nil {
|
2019-08-05 13:27:23 -08:00
|
|
|
return nil, err
|
2019-08-02 15:03:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
data := map[string]interface{}{
|
|
|
|
"FromUser": fromUser.Response(ctx),
|
|
|
|
"Account": account.Response(ctx),
|
2019-08-05 13:27:23 -08:00
|
|
|
"Url": resetUrl(hash),
|
2019-08-02 15:03:32 -08:00
|
|
|
"Minutes": req.TTL.Minutes(),
|
|
|
|
}
|
|
|
|
|
|
|
|
subject := fmt.Sprintf("%s %s has invited you to %s", fromUser.FirstName, fromUser.LastName, account.Name)
|
|
|
|
|
|
|
|
err = notify.Send(ctx, email, subject, "user_invite", data)
|
|
|
|
if err != nil {
|
|
|
|
err = errors.WithMessagef(err, "Send invite to %s failed.", email)
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2019-08-05 13:27:23 -08:00
|
|
|
inviteHashes = append(inviteHashes, hash)
|
2019-08-02 15:03:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
return inviteHashes, nil
|
|
|
|
}
|
|
|
|
|
2019-08-04 14:48:43 -08:00
|
|
|
// AcceptInvite updates the user using the provided invite hash.
|
2019-08-05 18:47:42 -08:00
|
|
|
func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest, secretKey string, now time.Time) (*user_account.UserAccount, error) {
|
2019-08-04 14:48:43 -08:00
|
|
|
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.AcceptInvite")
|
2019-08-02 15:03:32 -08:00
|
|
|
defer span.Finish()
|
|
|
|
|
|
|
|
v := webcontext.Validator()
|
|
|
|
|
|
|
|
// Validate the request.
|
|
|
|
err := v.StructCtx(ctx, req)
|
|
|
|
if err != nil {
|
2019-08-05 17:23:56 -08:00
|
|
|
return nil, err
|
2019-08-02 15:03:32 -08:00
|
|
|
}
|
|
|
|
|
2019-08-05 18:47:42 -08:00
|
|
|
hash, err := ParseInviteHash(ctx, req.InviteHash, secretKey, now)
|
2019-08-02 15:03:32 -08:00
|
|
|
if err != nil {
|
2019-08-05 17:23:56 -08:00
|
|
|
return nil, err
|
2019-08-02 15:03:32 -08:00
|
|
|
}
|
|
|
|
|
2019-08-04 14:48:43 -08:00
|
|
|
u, err := user.Read(ctx, auth.Claims{}, dbConn,
|
|
|
|
user.UserReadRequest{ID: hash.UserID, IncludeArchived: true})
|
2019-08-02 15:03:32 -08:00
|
|
|
if err != nil {
|
2019-08-05 17:23:56 -08:00
|
|
|
return nil, err
|
2019-08-02 15:03:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
if u.ArchivedAt != nil && !u.ArchivedAt.Time.IsZero() {
|
2019-08-04 14:48:43 -08:00
|
|
|
err = user.Restore(ctx, auth.Claims{}, dbConn, user.UserRestoreRequest{ID: hash.UserID}, now)
|
2019-08-02 15:03:32 -08:00
|
|
|
if err != nil {
|
2019-08-05 17:23:56 -08:00
|
|
|
return nil, err
|
2019-08-05 14:32:45 -08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
usrAcc, err := user_account.Read(ctx, auth.Claims{}, dbConn, user_account.UserAccountReadRequest{
|
|
|
|
UserID: hash.UserID,
|
|
|
|
AccountID: hash.AccountID,
|
|
|
|
})
|
|
|
|
if err != nil {
|
2019-08-05 18:47:42 -08:00
|
|
|
return nil, err
|
2019-08-05 14:32:45 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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 {
|
2019-08-05 18:47:42 -08:00
|
|
|
return usrAcc, errors.WithStack(ErrUserAccountActive)
|
2019-08-02 15:03:32 -08:00
|
|
|
}
|
2019-08-05 18:47:42 -08:00
|
|
|
return usrAcc, errors.WithStack(ErrNoPendingInvite)
|
2019-08-05 14:32:45 -08:00
|
|
|
}
|
|
|
|
|
2019-08-05 18:47:42 -08:00
|
|
|
// If the user already has a password set, then just update the user_account entry to status of active.
|
|
|
|
// The user will need to login and should not be auto-authenticated.
|
2019-08-05 14:32:45 -08:00
|
|
|
if len(u.PasswordHash) > 0 {
|
2019-08-05 18:47:42 -08:00
|
|
|
usrAcc.Status = user_account.UserAccountStatus_Active
|
|
|
|
|
|
|
|
err = user_account.Update(ctx, auth.Claims{}, dbConn, user_account.UserAccountUpdateRequest{
|
|
|
|
UserID: usrAcc.UserID,
|
|
|
|
AccountID: usrAcc.AccountID,
|
|
|
|
Status: &usrAcc.Status,
|
|
|
|
}, now)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return usrAcc, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// AcceptInviteUser updates the user using the provided invite hash.
|
|
|
|
func AcceptInviteUser(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteUserRequest, secretKey string, now time.Time) (*user_account.UserAccount, error) {
|
|
|
|
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.AcceptInviteUser")
|
|
|
|
defer span.Finish()
|
|
|
|
|
|
|
|
v := webcontext.Validator()
|
|
|
|
|
|
|
|
// Validate the request.
|
|
|
|
err := v.StructCtx(ctx, req)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
hash, err := ParseInviteHash(ctx, req.InviteHash, secretKey, now)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
u, err := user.Read(ctx, auth.Claims{}, dbConn,
|
|
|
|
user.UserReadRequest{ID: hash.UserID, IncludeArchived: true})
|
|
|
|
if err != nil {
|
|
|
|
return nil, 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 nil, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
usrAcc, err := user_account.Read(ctx, auth.Claims{}, dbConn, user_account.UserAccountReadRequest{
|
|
|
|
UserID: hash.UserID,
|
|
|
|
AccountID: hash.AccountID,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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 usrAcc, errors.WithStack(ErrUserAccountActive)
|
|
|
|
}
|
|
|
|
return nil, errors.WithStack(ErrNoPendingInvite)
|
2019-08-02 15:03:32 -08:00
|
|
|
}
|
|
|
|
|
2019-08-05 18:47:42 -08:00
|
|
|
// These three calls, user.Update, user.UpdatePassword, and user_account.Update
|
|
|
|
// should probably be in a transaction!
|
2019-08-02 15:03:32 -08:00
|
|
|
err = user.Update(ctx, auth.Claims{}, dbConn, user.UserUpdateRequest{
|
|
|
|
ID: hash.UserID,
|
2019-08-05 14:32:45 -08:00
|
|
|
Email: &req.Email,
|
2019-08-02 15:03:32 -08:00
|
|
|
FirstName: &req.FirstName,
|
|
|
|
LastName: &req.LastName,
|
|
|
|
Timezone: req.Timezone,
|
|
|
|
}, now)
|
|
|
|
if err != nil {
|
2019-08-05 17:23:56 -08:00
|
|
|
return nil, err
|
2019-08-02 15:03:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
err = user.UpdatePassword(ctx, auth.Claims{}, dbConn, user.UserUpdatePasswordRequest{
|
|
|
|
ID: hash.UserID,
|
|
|
|
Password: req.Password,
|
|
|
|
PasswordConfirm: req.PasswordConfirm,
|
|
|
|
}, now)
|
|
|
|
if err != nil {
|
2019-08-05 17:23:56 -08:00
|
|
|
return nil, err
|
2019-08-05 14:32:45 -08:00
|
|
|
}
|
|
|
|
|
2019-08-05 18:47:42 -08:00
|
|
|
usrAcc.Status = user_account.UserAccountStatus_Active
|
2019-08-05 14:32:45 -08:00
|
|
|
err = user_account.Update(ctx, auth.Claims{}, dbConn, user_account.UserAccountUpdateRequest{
|
|
|
|
UserID: usrAcc.UserID,
|
|
|
|
AccountID: usrAcc.AccountID,
|
2019-08-05 18:47:42 -08:00
|
|
|
Status: &usrAcc.Status,
|
2019-08-05 14:32:45 -08:00
|
|
|
}, now)
|
|
|
|
if err != nil {
|
2019-08-05 17:23:56 -08:00
|
|
|
return nil, err
|
2019-08-02 15:03:32 -08:00
|
|
|
}
|
|
|
|
|
2019-08-05 18:47:42 -08:00
|
|
|
return usrAcc, nil
|
2019-08-02 15:03:32 -08:00
|
|
|
}
|