Class: Panda::Core::User

Inherits:
ApplicationRecord show all
Includes:
HasMetadata, HasUUID
Defined in:
app/models/panda/core/user.rb

Class Method Summary collapse

Instance Method Summary collapse

Methods included from HasMetadata

#metadata_value, #remove_metadata, #set_metadata, #set_metadata_attribute

Class Method Details

.admin_columnObject

Determine which column stores admin flag (supports legacy admin and new is_admin)



33
34
35
36
# File 'app/models/panda/core/user.rb', line 33

def self.admin_column
  # Prefer canonical `admin` if available, otherwise fall back to legacy `is_admin`
  @admin_column ||= column_names.include?("admin") ? "admin" : "is_admin"
end

.find_or_create_from_auth_hash(auth_hash) ⇒ Object



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'app/models/panda/core/user.rb', line 51

def self.find_or_create_from_auth_hash(auth_hash)
  # Email is the cross-provider identity key, so we must only trust it
  # when the identity provider actually attests the user owns it.
  # Otherwise a provider that lets a user assert an arbitrary email
  # (a generic OAuth2/OIDC/SAML strategy, or a misconfigured one) would
  # allow matching into an existing account — an account-takeover vector.
  unless email_verified_for_auth?(auth_hash)
    user = new(
      email: auth_hash.info.email.to_s.downcase,
      name: auth_hash.info.name || "Unknown User"
    )
    user.errors.add(:base, "Your email address could not be verified with the identity provider. Please contact your administrator.")
    return user
  end

  user = find_by(email: auth_hash.info.email.downcase)

  avatar_url = auth_hash.info.image
  if user
    has_stored_avatar = begin
      user.avatar.attached?
    rescue
      false
    end

    if avatar_url.present?
      # Update image_url with latest OAuth URL only if no local avatar is stored
      user.update_column(:image_url, avatar_url) unless has_stored_avatar

      # Skip OAuth avatar download when user has a manually uploaded avatar
      # (indicated by oauth_avatar_url being nil while an avatar is attached)
      manually_uploaded = has_stored_avatar && user.oauth_avatar_url.nil?
      unless manually_uploaded
        # Try to download and store avatar if URL changed or no avatar attached
        if avatar_url != user.oauth_avatar_url || !has_stored_avatar
          AttachAvatarService.call(user: user, avatar_url: avatar_url)
        end
      end
    end
    return user
  end

  # Check if user creation is restricted (e.g. invite-only mode)
  restrict = Panda::Core.config.restrict_user_creation
  restricted = restrict.respond_to?(:call) ? restrict.call(auth_hash) : restrict
  if restricted
    user = new(email: auth_hash.info.email.downcase, name: auth_hash.info.name || "Unknown User")
    user.errors.add(:base, "No account exists for this email address. Please contact your administrator.")
    return user
  end

  attributes = {
    :email => auth_hash.info.email.downcase,
    :name => auth_hash.info.name || "Unknown User",
    :image_url => avatar_url,
    admin_column => User.count.zero? # First user is admin
  }

  user = create!(attributes)

  # Attach avatar for new user (will clear image_url on success)
  if avatar_url.present?
    AttachAvatarService.call(user: user, avatar_url: avatar_url)
  end

  user
end

Instance Method Details

#accept_invitation!Object



215
216
217
218
219
220
# File 'app/models/panda/core/user.rb', line 215

def accept_invitation!
  update!(
    invitation_accepted_at: Time.current,
    invitation_token: nil
  )
end

#active_for_authentication?Boolean

Returns:

  • (Boolean)


191
192
193
# File 'app/models/panda/core/user.rb', line 191

def active_for_authentication?
  enabled?
end

#adminObject Also known as: is_admin

Support both legacy admin and new is_admin columns



181
182
183
# File 'app/models/panda/core/user.rb', line 181

def admin
  self[self.class.admin_column]
end

#admin=(value) ⇒ Object Also known as: is_admin=



185
186
187
# File 'app/models/panda/core/user.rb', line 185

def admin=(value)
  self[self.class.admin_column] = ActiveRecord::Type::Boolean.new.cast(value)
end

#admin?Boolean

Admin status check

Returns:

  • (Boolean)


176
177
178
# File 'app/models/panda/core/user.rb', line 176

def admin?
  ActiveRecord::Type::Boolean.new.cast(admin)
end

#avatar_url(size: nil) ⇒ String?

Returns the URL for the user's avatar Prefers Active Storage attachment over OAuth provider URL

Parameters:

  • size (Symbol) (defaults to: nil)

    The variant size (:thumb, :small, :medium, :large, or nil for original)

Returns:

  • (String, nil)

    The avatar URL or nil if no avatar available



234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'app/models/panda/core/user.rb', line 234

def avatar_url(size: nil)
  return self[:image_url].presence unless avatar.attached?

  helpers = Rails.application.routes.url_helpers
  if size && [:thumb, :small, :medium, :large].include?(size)
    helpers.rails_representation_path(avatar.variant(size), only_path: true)
  else
    helpers.rails_blob_path(avatar.blob, only_path: true)
  end
rescue => e
  Rails.logger.error("Error generating avatar URL for user #{id}: #{e.message}\n#{e.backtrace&.first(3)&.join("\n")}")
  self[:image_url].presence
end

#disable!Object



199
200
201
# File 'app/models/panda/core/user.rb', line 199

def disable!
  update!(enabled: false)
end

#enable!Object



195
196
197
# File 'app/models/panda/core/user.rb', line 195

def enable!
  update!(enabled: true)
end

#enabled?Boolean

Returns:

  • (Boolean)


203
204
205
# File 'app/models/panda/core/user.rb', line 203

def enabled?
  self[:enabled] != false
end

#invite!(invited_by:) ⇒ Object



207
208
209
210
211
212
213
# File 'app/models/panda/core/user.rb', line 207

def invite!(invited_by:)
  update!(
    invitation_token: SecureRandom.urlsafe_base64(32),
    invitation_sent_at: Time.current,
    invited_by: invited_by
  )
end

#track_login!(request) ⇒ Object



222
223
224
225
226
227
228
# File 'app/models/panda/core/user.rb', line 222

def track_login!(request)
  update!(
    last_login_at: Time.current,
    last_login_ip: request.remote_ip,
    login_count: ( || 0) + 1
  )
end