package models import ( "crypto/md5" "fmt" "log" "time" "github.com/lib/pq" "github.com/pkg/errors" "gorm.io/datatypes" "rocketgit.ru/rsmon/worker/app/models/authidentity" "rocketgit.ru/rsmon/worker/app/models/concerns" ) // User represents a platform user. // // A User is a global identity that may belong to many tenants via the // Access join table (see Access). Contacts created by or assigned to a // User carry that UserID; admins reach them via the cross-tenant // /admin/users page, while per-account management is via // /settings/users (which shows Accesses preloaded with User + Invite). // // Authentication state (password, confirmation, lock, recover) lives // here, not on Access, because those attributes are account-independent. type User struct { // concerns.Model ID int64 `gorm:"primarykey" json:"id"` Email *string `gorm:"uniqueIndex;size:255" json:"email" validate:"required"` Name string `json:"name"` Enabled bool `gorm:"not null;default:true" json:"-"` // Operator grants platform-wide operational access. Account ownership alone // must never grant cross-tenant task inspection or replay. Operator bool `gorm:"not null;default:false" json:"operator"` Timezone string `json:"timezone"` Language string `gorm:"default:ru" json:"language"` // Settings holds small per-user UI preferences. It intentionally stays // separate from account settings because the sidebar is a personal view. Settings datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'::jsonb" json:"settings"` Phone string `gorm:"index,size:255" json:"phone"` TelegramID *int64 `gorm:"uniqueIndex" json:"telegram_id"` TelegramUsername string `gorm:"size:255" json:"telegram_username"` Accesses []Access `json:"-"` Contacts []Contact `json:"-"` LastActiveAt *time.Time `json:"last_active_at"` LastActiveIP *string `json:"last_active_ip"` EncryptedPassword *string `json:"-"` PasswordSetAt *time.Time `json:"-"` // Confirm ConfirmationToken *string `json:"-"` Confirmed bool `json:"confirmed"` // Lock AttemptCount int `json:"-"` LastAttempt *time.Time `json:"-"` LockedAt *time.Time `json:"-"` // Recover RecoverToken *string `json:"-"` RecoverTokenAt *time.Time `json:"-"` RememberTokens pq.StringArray `gorm:"index;type:varchar(100)[]" json:"-"` // DeletionRequestedAt is set when the user requests account deletion. // During the 7-day grace period the user can cancel the deletion // (clearing this field). After 7 days the user and all related data // are hard-deleted by a scheduled job. DeletionRequestedAt *time.Time `json:"deletion_requested_at"` concerns.Timestamped `json:"-"` } // DeletionPending returns true if the user has requested deletion and is // still within the 7-day grace period. func (u *User) DeletionPending() bool { return u.DeletionRequestedAt != nil } // GetLabel returns info label for user. func (u *User) GetLabel() string { return u.DisplayName() } // DisplayName implements qor.CurrentUser for admin. func (u *User) DisplayName() string { if u.Email != nil { return u.Name + " " + *u.Email } return u.Name } // AfterSocialLogin is a callback after social login. func (u *User) AfterSocialLogin(inviteID int64) (*User, error) { oldUser := User{} DB().Where("email = ?", u.Email).Where("id != ?", u.ID).First(&oldUser) if oldUser.ID > 0 { log.Println("new user", u.ID, "has same email", u.Email, "as old user", oldUser.ID, "so replacing") err := DB().Model(&authidentity.AuthIdentity{}).Where("user_id = ?", u.ID).Updates( authidentity.Basic{ UserID: &oldUser.ID, }, ).Error return &oldUser, err } err := u.AfterRegister(inviteID) return u, err } // AfterInvite is a callback after invite acceptance. func (u *User) AfterInvite(invite *Invite) error { invite.InviteeID = &u.ID err := DB().Table("accesses").Where("invite_id = ?", invite.ID).Updates(map[string]interface{}{"user_id": u.ID}).Error if err != nil { return errors.Wrap(err, "invite: failed to add accesses to invited user") } err = DB().Model(&authidentity.AuthIdentity{}).Where("provider = ? AND user_id = ?", "password", u.ID).Updates(map[string]interface{}{ "confirmed_at": time.Now(), }).Error if err != nil { return errors.Wrap(err, "invite: failed set user as confirmed") } eml := invite.Email u.Email = &eml if invite.Name != "" { u.Name = invite.Name } err = DB().Save(&u).Error if err != nil { return errors.Wrap(err, "invite: failed to save user") } if invite.Name == "" { log.Println("set name", u.Name) invite.Name = u.Name } invite.State = stateOK err = DB().Save(&invite).Error if err != nil { return errors.Wrap(err, "invite: failed to save invite") } invite.Invitee = u return nil } // AfterRegister is a callback after registration. func (u *User) AfterRegister(inviteID int64) error { var err error if inviteID > 0 { invite := Invite{} err := DB().First(&invite, inviteID).Error if err != nil { return errors.Wrap(err, "invite: failed to find invite") } err = u.AfterInvite(&invite) if err != nil { return err } } _, err = CreateAccountForUser("", u) if err != nil { return err } return nil } // AfterLogin is a callback after login. func (u *User) AfterLogin(inviteID int64) error { var err error if inviteID > 0 { invite := Invite{} err = DB().First(&invite, inviteID).Error if err == nil { invite.InviteeID = &u.ID invite.State = stateOK err = DB().Save(&invite).Error if err != nil { return errors.Wrap(err, "failed to save invite") } err = DB().Table("accesses").Where("invite_id = ?", invite.ID).Updates(map[string]interface{}{"user_id": u.ID}).Error if err != nil { return errors.Wrap(err, "failed to add accesses to invited user") } err = DB().Model(&authidentity.AuthIdentity{}).Where("provider = ? AND user_id = ?", "password", u.ID).Updates(map[string]interface{}{ //nolint:lll "confirmed_at": time.Now(), }).Error if err != nil { return errors.Wrap(err, "failed set user as confirmed") } } } return nil } // Gravatar returns the Gravatar URL for the user. func (u *User) Gravatar(size int) string { if u.Email == nil { return "" } hash := md5.Sum([]byte(*u.Email)) return fmt.Sprintf("https://www.gravatar.com/avatar/%x?s=%d&d=blank", hash, size) } // AsJSON returns a JSON representation of user. func (u User) AsJSON() map[string]interface{} { //nolint:gocritic // hugeParam: accepted for interface compatibility r := map[string]interface{}{ "id": u.ID, "email": u.Email, "avatar": u.Gravatar(32), "deletion_requested_at": u.DeletionRequestedAt, } return r }