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/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" "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") // 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.") ) // SendUserInvites sends emails to the users inviting them to join an account. func (repo *Repository) SendUserInvites(ctx context.Context, claims auth.Claims, req SendUserInvitesRequest, now time.Time) ([]string, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.SendUserInvites") 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 = repo.Account.CanModifyAccount(ctx, claims, 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 := repo.User.Find(ctx, auth.Claims{}, user.UserFindRequest{ Where: fmt.Sprintf("email in ('%s')", strings.Join(req.Emails, "','")), }) 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 := repo.UserAccount.Find(ctx, claims, user_account.UserAccountFindRequest{ Where: fmt.Sprintf("user_id in ('%s') and status = '%s'", strings.Join(args, "','"), user_account.UserAccountStatus_Active.String()), }) 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 := repo.User.CreateInvite(ctx, claims, 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 = repo.UserAccount.Create(ctx, claims, 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 } fromUser, err := repo.User.ReadByID(ctx, claims, req.UserID) if err != nil { return nil, err } account, err := repo.Account.ReadByID(ctx, claims, req.AccountID) 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 { hash, err := NewInviteHash(ctx, repo.secretKey, userID, req.AccountID, requestIp, req.TTL, now) if err != nil { return nil, err } data := map[string]interface{}{ "FromUser": fromUser.Response(ctx), "Account": account.Response(ctx), "Url": repo.ResetUrl(hash), "Minutes": req.TTL.Minutes(), } subject := fmt.Sprintf("%s %s has invited you to %s", fromUser.FirstName, fromUser.LastName, account.Name) err = repo.Notify.Send(ctx, email, subject, "user_invite", data) if err != nil { err = errors.WithMessagef(err, "Send invite to %s failed.", email) return nil, err } inviteHashes = append(inviteHashes, hash) } return inviteHashes, nil } // AcceptInvite updates the user using the provided invite hash. func (repo *Repository) AcceptInvite(ctx context.Context, req AcceptInviteRequest, now time.Time) (*user_account.UserAccount, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.AcceptInvite") 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, repo.secretKey, now) if err != nil { return nil, err } u, err := repo.User.Read(ctx, auth.Claims{}, user.UserReadRequest{ID: hash.UserID, IncludeArchived: true}) if err != nil { return nil, err } if u.ArchivedAt != nil && !u.ArchivedAt.Time.IsZero() { err = repo.User.Restore(ctx, auth.Claims{}, user.UserRestoreRequest{ID: hash.UserID}, now) if err != nil { return nil, err } } usrAcc, err := repo.UserAccount.Read(ctx, auth.Claims{}, 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 usrAcc, errors.WithStack(ErrNoPendingInvite) } // 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. if len(u.PasswordHash) > 0 { usrAcc.Status = user_account.UserAccountStatus_Active err = repo.UserAccount.Update(ctx, auth.Claims{}, 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 (repo *Repository) AcceptInviteUser(ctx context.Context, req AcceptInviteUserRequest, 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, repo.secretKey, now) if err != nil { return nil, err } u, err := repo.User.Read(ctx, auth.Claims{}, user.UserReadRequest{ID: hash.UserID, IncludeArchived: true}) if err != nil { return nil, err } if u.ArchivedAt != nil && !u.ArchivedAt.Time.IsZero() { err = repo.User.Restore(ctx, auth.Claims{}, user.UserRestoreRequest{ID: hash.UserID}, now) if err != nil { return nil, err } } usrAcc, err := repo.UserAccount.Read(ctx, auth.Claims{}, 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) } // These three calls, user.Update, user.UpdatePassword, and user_account.Update // should probably be in a transaction! err = repo.User.Update(ctx, auth.Claims{}, user.UserUpdateRequest{ ID: hash.UserID, Email: &req.Email, FirstName: &req.FirstName, LastName: &req.LastName, Timezone: req.Timezone, }, now) if err != nil { return nil, err } err = repo.User.UpdatePassword(ctx, auth.Claims{}, user.UserUpdatePasswordRequest{ ID: hash.UserID, Password: req.Password, PasswordConfirm: req.PasswordConfirm, }, now) if err != nil { return nil, err } usrAcc.Status = user_account.UserAccountStatus_Active err = repo.UserAccount.Update(ctx, auth.Claims{}, user_account.UserAccountUpdateRequest{ UserID: usrAcc.UserID, AccountID: usrAcc.AccountID, Status: &usrAcc.Status, }, now) if err != nil { return nil, err } return usrAcc, nil }