diff --git a/Gemfile b/Gemfile
index d691dee..20d1473 100644
--- a/Gemfile
+++ b/Gemfile
@@ -32,6 +32,7 @@ gem 'devise_ldap_authenticatable'
gem 'net-ldap'
# Utilities
+gem 'aasm'
gem "image_processing", "~> 1.12.2"
gem "rqrcode", "~> 2.0"
gem 'rails-settings-cached', '~> 2.8.3'
diff --git a/Gemfile.lock b/Gemfile.lock
index 396c65e..c96c0c3 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,6 +1,8 @@
GEM
remote: https://rubygems.org/
specs:
+ aasm (5.5.0)
+ concurrent-ruby (~> 1.0)
actioncable (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
@@ -526,6 +528,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
+ aasm
aws-sdk-s3
bcrypt (~> 3.1)
capybara
diff --git a/README.md b/README.md
index 530186b..4b69035 100644
--- a/README.md
+++ b/README.md
@@ -128,6 +128,7 @@ command:
### Front-end
+* [Icons](https://feathericons.com)
* [Tailwind CSS](https://tailwindcss.com/)
* [Sass](https://sass-lang.com/documentation)
* [Stimulus](https://stimulus.hotwired.dev/handbook/)
diff --git a/app/components/sidenav_link_component.rb b/app/components/sidenav_link_component.rb
index 9436a67..8a3614b 100644
--- a/app/components/sidenav_link_component.rb
+++ b/app/components/sidenav_link_component.rb
@@ -29,7 +29,7 @@ class SidenavLinkComponent < ViewComponent::Base
def class_names_icon(path)
if @active
- "text-teal-500 group-hover:text-teal-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
+ "text-teal-600 group-hover:text-teal-600 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
elsif @disabled
"text-gray-300 group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
else
diff --git a/app/controllers/admin/donations_controller.rb b/app/controllers/admin/donations_controller.rb
index 51ea4e3..c12a868 100644
--- a/app/controllers/admin/donations_controller.rb
+++ b/app/controllers/admin/donations_controller.rb
@@ -4,11 +4,22 @@ class Admin::DonationsController < Admin::BaseController
# GET /donations
def index
- @pagy, @donations = pagy(Donation.completed.order('paid_at desc'))
+ @username = params[:username].presence
+
+ pending_scope = Donation.incomplete.joins(:user).order('paid_at desc')
+ completed_scope = Donation.completed.joins(:user).order('paid_at desc')
+
+ if @username
+ pending_scope = pending_scope.where(users: { cn: @username })
+ completed_scope = completed_scope.where(users: { cn: @username })
+ end
+
+ @pending_donations = pending_scope
+ @pagy, @donations = pagy(completed_scope)
@stats = {
- overall_sats: @donations.sum("amount_sats"),
- donor_count: Donation.completed.count(:user_id)
+ overall_sats: completed_scope.sum("amount_sats"),
+ donor_count: completed_scope.distinct.count(:user_id)
}
end
diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb
index 97a33b3..c5c0290 100644
--- a/app/controllers/admin/invitations_controller.rb
+++ b/app/controllers/admin/invitations_controller.rb
@@ -1,12 +1,28 @@
class Admin::InvitationsController < Admin::BaseController
+ before_action :set_current_section
+
def index
- @current_section = :invitations
- @pagy, @invitations_used = pagy(Invitation.used.order('used_at desc'))
+ @username = params[:username].presence
+ accepted_scope = Invitation.used.order('used_at desc')
+ unused_scope = Invitation.unused
+
+ if @username
+ accepted_scope = accepted_scope.joins(:user).where(users: { cn: @username })
+ unused_scope = unused_scope.joins(:user).where(users: { cn: @username })
+ end
+
+ @pagy, @invitations_used = pagy(accepted_scope)
@stats = {
- available: Invitation.unused.count,
- accepted: @invitations_used.length,
- users_with_referrals: Invitation.used.distinct.count(:user_id)
+ available: unused_scope.count,
+ accepted: accepted_scope.count,
+ users_with_referrals: accepted_scope.distinct.count(:user_id)
}
end
+
+ private
+
+ def set_current_section
+ @current_section = :invitations
+ end
end
diff --git a/app/controllers/admin/settings/membership_controller.rb b/app/controllers/admin/settings/membership_controller.rb
new file mode 100644
index 0000000..f01e958
--- /dev/null
+++ b/app/controllers/admin/settings/membership_controller.rb
@@ -0,0 +1,23 @@
+class Admin::Settings::MembershipController < Admin::SettingsController
+ def show
+ end
+
+ def update
+ update_settings
+
+ redirect_to admin_settings_membership_path, flash: {
+ success: "Settings saved"
+ }
+ end
+
+ private
+
+ def setting_params
+ params.require(:setting).permit([
+ :member_status_contributor,
+ :member_status_sustainer,
+ :user_index_show_contributors,
+ :user_index_show_sustainers
+ ])
+ end
+end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index e73d208..e6504e4 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -4,18 +4,30 @@ class Admin::UsersController < Admin::BaseController
# GET /admin/users
def index
- ldap = LdapService.new
- @ou = Setting.primary_domain
- @pagy, @users = pagy(User.where(ou: @ou).order(cn: :asc))
+ ldap = LdapService.new
+ ou = Setting.primary_domain
+ @show_contributors = Setting.user_index_show_contributors
+ @show_sustainers = Setting.user_index_show_sustainers
+
+ @contributors = ldap.search_users(:memberStatus, :contributor, :cn) if @show_contributors
+ @sustainers = ldap.search_users(:memberStatus, :sustainer, :cn) if @show_sustainers
+ @admins = ldap.search_users(:admin, true, :cn)
+ @pagy, @users = pagy(User.where(ou: ou).order(cn: :asc))
@stats = {
- users_confirmed: User.where(ou: @ou).confirmed.count,
- users_pending: User.where(ou: @ou).pending.count
+ users_confirmed: User.where(ou: ou).confirmed.count,
+ users_pending: User.where(ou: ou).pending.count
}
+ @stats[:users_contributing] = @contributors.size if @show_contributors
+ @stats[:users_paying] = @sustainers.size if @show_sustainers
end
# GET /admin/users/:username
def show
+ @invitees = @user.invitees
+ @recent_invitees = @user.invitees.order(created_at: :desc).limit(5)
+ @more_invitees = (@invitees - @recent_invitees).count
+
if Setting.lndhub_admin_enabled?
@lndhub_user = @user.lndhub_user
end
diff --git a/app/controllers/contributions/donations_controller.rb b/app/controllers/contributions/donations_controller.rb
index b9f46c4..99e614e 100644
--- a/app/controllers/contributions/donations_controller.rb
+++ b/app/controllers/contributions/donations_controller.rb
@@ -11,7 +11,7 @@ class Contributions::DonationsController < ApplicationController
def index
@current_section = :contributions
@donations_completed = current_user.donations.completed.order('paid_at desc')
- @donations_pending = current_user.donations.processing.order('created_at desc')
+ @donations_processing = current_user.donations.processing.order('created_at desc')
if Setting.lndhub_enabled?
begin
@@ -81,14 +81,11 @@ class Contributions::DonationsController < ApplicationController
case invoice["status"]
when "Settled"
- @donation.paid_at = DateTime.now
- @donation.payment_status = "settled"
- @donation.save!
+ @donation.complete!
flash_message = { success: "Thank you!" }
when "Processing"
unless @donation.processing?
- @donation.payment_status = "processing"
- @donation.save!
+ @donation.start_processing!
flash_message = { success: "Thank you! We will send you an email when the payment is confirmed." }
BtcpayCheckDonationJob.set(wait: 20.seconds).perform_later(@donation)
end
diff --git a/app/jobs/btcpay_check_donation_job.rb b/app/jobs/btcpay_check_donation_job.rb
index 3cfdbcb..839dda8 100644
--- a/app/jobs/btcpay_check_donation_job.rb
+++ b/app/jobs/btcpay_check_donation_job.rb
@@ -10,9 +10,7 @@ class BtcpayCheckDonationJob < ApplicationJob
case invoice["status"]
when "Settled"
- donation.paid_at = DateTime.now
- donation.payment_status = "settled"
- donation.save!
+ donation.complete!
NotificationMailer.with(user: donation.user)
.bitcoin_donation_confirmed
diff --git a/app/jobs/xmpp_exchange_contacts_job.rb b/app/jobs/xmpp_exchange_contacts_job.rb
index 0de829c..269604d 100644
--- a/app/jobs/xmpp_exchange_contacts_job.rb
+++ b/app/jobs/xmpp_exchange_contacts_job.rb
@@ -2,21 +2,6 @@ class XmppExchangeContactsJob < ApplicationJob
queue_as :default
def perform(inviter, invitee)
- return unless inviter.service_enabled?(:ejabberd) &&
- invitee.service_enabled?(:ejabberd) &&
- inviter.preferences[:xmpp_exchange_contacts_with_invitees]
-
- ejabberd = EjabberdApiClient.new
-
- ejabberd.add_rosteritem({
- "localuser": invitee.cn, "localhost": invitee.ou,
- "user": inviter.cn, "host": inviter.ou,
- "nick": inviter.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
- })
- ejabberd.add_rosteritem({
- "localuser": inviter.cn, "localhost": inviter.ou,
- "user": invitee.cn, "host": invitee.ou,
- "nick": invitee.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
- })
+ EjabberdManager::ExchangeContacts.call(inviter:, invitee:)
end
end
diff --git a/app/jobs/xmpp_send_message_job.rb b/app/jobs/xmpp_send_message_job.rb
index e8eebf5..37a7e57 100644
--- a/app/jobs/xmpp_send_message_job.rb
+++ b/app/jobs/xmpp_send_message_job.rb
@@ -2,7 +2,6 @@ class XmppSendMessageJob < ApplicationJob
queue_as :default
def perform(payload)
- ejabberd = EjabberdApiClient.new
- ejabberd.send_message payload
+ EjabberdManager::SendMessage.call(payload:)
end
end
diff --git a/app/jobs/xmpp_set_avatar_job.rb b/app/jobs/xmpp_set_avatar_job.rb
index 642225c..e1b89d7 100644
--- a/app/jobs/xmpp_set_avatar_job.rb
+++ b/app/jobs/xmpp_set_avatar_job.rb
@@ -1,97 +1,7 @@
-require 'digest'
-require "image_processing/vips"
-
class XmppSetAvatarJob < ApplicationJob
queue_as :default
def perform(user:, overwrite: false)
- return if Rails.env.development?
- @user = user
-
- unless overwrite
- current_avatar = get_current_avatar
- Rails.logger.info { "User #{user.cn} already has an avatar set" }
- return if current_avatar.present?
- end
-
- Rails.logger.debug { "Setting XMPP avatar for user #{user.cn}" }
-
- stanzas = build_xep0084_stanzas
-
- stanzas.each do |stanza|
- payload = { from: @user.address, to: @user.address, stanza: stanza }
- res = ejabberd.send_stanza payload
- raise res.inspect if res.status != 200
- end
+ EjabberdManager::SetAvatar.call(user:, overwrite:)
end
-
- private
-
- def ejabberd
- @ejabberd ||= EjabberdApiClient.new
- end
-
- def get_current_avatar
- res = ejabberd.get_vcard2 @user, "PHOTO", "BINVAL"
-
- if res.status == 200
- # VCARD PHOTO/BINVAL prop exists
- res.body
- elsif res.status == 400
- # VCARD or PHOTO/BINVAL prop does not exist
- nil
- else
- # Unexpected error, let job fail
- raise res.inspect
- end
- end
-
- def process_avatar
- @user.avatar.blob.open do |file|
- processed = ImageProcessing::Vips
- .source(file)
- .resize_to_fill(256, 256)
- .convert("png")
- .call
- processed.read
- end
- end
-
- # See https://xmpp.org/extensions/xep-0084.html
- def build_xep0084_stanzas
- img_data = process_avatar
- sha1_hash = Digest::SHA1.hexdigest(img_data)
- base64_data = Base64.strict_encode64(img_data)
-
- [
- """
-
-
-
- -
-
#{base64_data}
-
-
-
-
- """.strip,
- """
-
-
-
- -
-
-
-
-
-
-
-
- """.strip,
- ]
- end
end
diff --git a/app/jobs/xmpp_set_default_bookmarks_job.rb b/app/jobs/xmpp_set_default_bookmarks_job.rb
index 92cd8a5..e9539c9 100644
--- a/app/jobs/xmpp_set_default_bookmarks_job.rb
+++ b/app/jobs/xmpp_set_default_bookmarks_job.rb
@@ -2,25 +2,6 @@ class XmppSetDefaultBookmarksJob < ApplicationJob
queue_as :default
def perform(user)
- return unless Setting.xmpp_default_rooms.any?
- @user = user
- ejabberd = EjabberdApiClient.new
- ejabberd.private_set user, storage_content
- end
-
- def storage_content
- bookmarks = ""
- Setting.xmpp_default_rooms.each do |r|
- bookmarks << conference_element(
- jid: r[/<(.+)>/, 1], name: r[/^(.+)\s/, 1], nick: @user.cn,
- autojoin: Setting.xmpp_autojoin_default_rooms
- )
- end
-
- "#{bookmarks} "
- end
-
- def conference_element(jid:, name:, autojoin: false, nick:)
- "#{nick} "
+ EjabberdManager::SetDefaultBookmarks.call(user:)
end
end
diff --git a/app/models/concerns/settings/membership_settings.rb b/app/models/concerns/settings/membership_settings.rb
new file mode 100644
index 0000000..bf4712c
--- /dev/null
+++ b/app/models/concerns/settings/membership_settings.rb
@@ -0,0 +1,18 @@
+module Settings
+ module MembershipSettings
+ extend ActiveSupport::Concern
+
+ included do
+ field :member_status_contributor, type: :string,
+ default: "Contributor"
+ field :member_status_sustainer, type: :string,
+ default: "Sustainer"
+
+ # Admin panel
+ field :user_index_show_contributors, type: :boolean,
+ default: false
+ field :user_index_show_sustainers, type: :boolean,
+ default: false
+ end
+ end
+end
diff --git a/app/models/donation.rb b/app/models/donation.rb
index 9799ae6..f3e585c 100644
--- a/app/models/donation.rb
+++ b/app/models/donation.rb
@@ -1,22 +1,42 @@
class Donation < ApplicationRecord
- # Relations
+ include AASM
+
belongs_to :user
- # Validations
validates_presence_of :user
validates_presence_of :donation_method,
inclusion: { in: %w[ custom btcpay lndhub ] }
validates_presence_of :payment_status, allow_nil: true,
- inclusion: { in: %w[ processing settled ] }
+ inclusion: { in: %w[ pending processing settled ] }
validates_presence_of :paid_at, allow_nil: true
validates_presence_of :amount_sats, allow_nil: true
validates_presence_of :fiat_amount, allow_nil: true
validates_presence_of :fiat_currency, allow_nil: true,
inclusion: { in: %w[ EUR USD ] }
- #Scopes
+ scope :pending, -> { where(payment_status: "pending") }
scope :processing, -> { where(payment_status: "processing") }
- scope :completed, -> { where(payment_status: "settled") }
+ scope :completed, -> { where(payment_status: "settled") }
+ scope :incomplete, -> { where.not(payment_status: "settled") }
+
+ aasm column: :payment_status do
+ state :pending, initial: true
+ state :processing
+ state :settled
+
+ event :start_processing do
+ transitions from: :pending, to: :processing
+ end
+
+ event :complete do
+ transitions from: :processing, to: :settled, after: [:set_paid_at, :set_sustainer_status]
+ transitions from: :pending, to: :settled, after: [:set_paid_at, :set_sustainer_status]
+ end
+ end
+
+ def pending?
+ payment_status == "pending"
+ end
def processing?
payment_status == "processing"
@@ -25,4 +45,17 @@ class Donation < ApplicationRecord
def completed?
payment_status == "settled"
end
+
+ private
+
+ def set_paid_at
+ update paid_at: DateTime.now if paid_at.nil?
+ end
+
+ def set_sustainer_status
+ user.add_member_status :sustainer
+ rescue => e
+ Sentry.capture_exception(e) if Setting.sentry_enabled?
+ Rails.logger.error("Failed to set memberStatus: #{e.message}")
+ end
end
diff --git a/app/models/setting.rb b/app/models/setting.rb
index aa8072d..2c31ca8 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -16,6 +16,7 @@ class Setting < RailsSettings::Base
include Settings::LightningNetworkSettings
include Settings::MastodonSettings
include Settings::MediaWikiSettings
+ include Settings::MembershipSettings
include Settings::NostrSettings
include Settings::OpenCollectiveSettings
include Settings::RemoteStorageSettings
diff --git a/app/models/user.rb b/app/models/user.rb
index a0c49b0..4479800 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -163,7 +163,21 @@ class User < ApplicationRecord
def ldap_entry(reload: false)
return @ldap_entry if defined?(@ldap_entry) && !reload
- @ldap_entry = ldap.fetch_users(uid: self.cn, ou: self.ou).first
+ @ldap_entry = ldap.fetch_users(cn: self.cn).first
+ end
+
+ def add_to_ldap_array(attr_key, ldap_attr, value)
+ current_entries = ldap_entry[attr_key.to_sym] || []
+ new_entries = Array(value).map(&:to_s)
+ entries = (current_entries + new_entries).uniq.sort
+ ldap.replace_attribute(dn, ldap_attr.to_sym, entries)
+ end
+
+ def remove_from_ldap_array(attr_key, ldap_attr, value)
+ current_entries = ldap_entry[attr_key.to_sym] || []
+ entries_to_remove = Array(value).map(&:to_s)
+ entries = (current_entries - entries_to_remove).uniq.sort
+ ldap.replace_attribute(dn, ldap_attr.to_sym, entries)
end
def display_name
@@ -220,21 +234,39 @@ class User < ApplicationRecord
end
def enable_service(service)
- current_services = services_enabled
- new_services = Array(service).map(&:to_s)
- services = (current_services + new_services).uniq.sort
- ldap.replace_attribute(dn, :serviceEnabled, services)
+ add_to_ldap_array :services_enabled, :serviceEnabled, service
+ ldap_entry(reload: true)[:services_enabled]
end
def disable_service(service)
- current_services = services_enabled
- disabled_services = Array(service).map(&:to_s)
- services = (current_services - disabled_services).uniq.sort
- ldap.replace_attribute(dn, :serviceEnabled, services)
+ remove_from_ldap_array :services_enabled, :serviceEnabled, service
+ ldap_entry(reload: true)[:services_enabled]
end
def disable_all_services
- ldap.delete_attribute(dn,:service)
+ ldap.delete_attribute(dn, :serviceEnabled)
+ end
+
+ def member_status
+ ldap_entry[:member_status] || []
+ end
+
+ def add_member_status(status)
+ add_to_ldap_array :member_status, :memberStatus, status
+ ldap_entry(reload: true)[:member_status]
+ end
+
+ def remove_member_status(status)
+ remove_from_ldap_array :member_status, :memberStatus, status
+ ldap_entry(reload: true)[:member_status]
+ end
+
+ def is_contributing_member?
+ member_status.map(&:to_sym).include?(:contributor)
+ end
+
+ def is_paying_member?
+ member_status.map(&:to_sym).include?(:sustainer)
end
private
diff --git a/app/services/ejabberd_manager/exchange_contacts.rb b/app/services/ejabberd_manager/exchange_contacts.rb
new file mode 100644
index 0000000..f71cddc
--- /dev/null
+++ b/app/services/ejabberd_manager/exchange_contacts.rb
@@ -0,0 +1,25 @@
+module EjabberdManager
+ class ExchangeContacts < EjabberdManagerService
+ def initialize(inviter:, invitee:)
+ @inviter = inviter
+ @invitee = invitee
+ end
+
+ def call
+ return unless @inviter.service_enabled?(:ejabberd) &&
+ @invitee.service_enabled?(:ejabberd) &&
+ @inviter.preferences[:xmpp_exchange_contacts_with_invitees]
+
+ add_rosteritem({
+ "localuser": @invitee.cn, "localhost": @invitee.ou,
+ "user": @inviter.cn, "host": @inviter.ou,
+ "nick": @inviter.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
+ })
+ add_rosteritem({
+ "localuser": @inviter.cn, "localhost": @inviter.ou,
+ "user": @invitee.cn, "host": @invitee.ou,
+ "nick": @invitee.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
+ })
+ end
+ end
+end
diff --git a/app/services/ejabberd_manager/get_avatar.rb b/app/services/ejabberd_manager/get_avatar.rb
new file mode 100644
index 0000000..8569fb7
--- /dev/null
+++ b/app/services/ejabberd_manager/get_avatar.rb
@@ -0,0 +1,25 @@
+module EjabberdManager
+ class GetAvatar < EjabberdManagerService
+ def initialize(user:)
+ @user = user
+ end
+
+ def call
+ res = get_vcard2 @user, "PHOTO", "BINVAL"
+
+ if res.status == 200
+ # VCARD PHOTO/BINVAL prop exists
+ img_base64 = JSON.parse(res.body)["content"]
+ ct_res = get_vcard2 @user, "PHOTO", "TYPE"
+ content_type = JSON.parse(ct_res.body)["content"]
+ { content_type:, img_base64: }
+ elsif res.status == 400
+ # VCARD or PHOTO/BINVAL prop does not exist
+ nil
+ else
+ # Unexpected error, let job fail
+ raise res.inspect
+ end
+ end
+ end
+end
diff --git a/app/services/ejabberd_manager/send_message.rb b/app/services/ejabberd_manager/send_message.rb
new file mode 100644
index 0000000..86d6a9d
--- /dev/null
+++ b/app/services/ejabberd_manager/send_message.rb
@@ -0,0 +1,11 @@
+module EjabberdManager
+ class SendMessage < EjabberdManagerService
+ def initialize(payload:)
+ @payload = payload
+ end
+
+ def call
+ send_message @payload
+ end
+ end
+end
diff --git a/app/services/ejabberd_manager/set_avatar.rb b/app/services/ejabberd_manager/set_avatar.rb
new file mode 100644
index 0000000..fcff840
--- /dev/null
+++ b/app/services/ejabberd_manager/set_avatar.rb
@@ -0,0 +1,80 @@
+require 'digest'
+require "image_processing/vips"
+
+module EjabberdManager
+ class SetAvatar < EjabberdManagerService
+ def initialize(user:, overwrite: false)
+ @user = user
+ @overwrite = overwrite
+ end
+
+ def call
+ unless @overwrite
+ current_avatar = EjabberdManager::GetAvatar.call(user: @user)
+ Rails.logger.info { "User #{user.cn} already has an avatar set" }
+ return if current_avatar.present?
+ end
+
+ Rails.logger.debug { "Setting XMPP avatar for user #{@user.cn}" }
+
+ stanzas = build_xep0084_stanzas
+
+ stanzas.each do |stanza|
+ payload = { from: @user.address, to: @user.address, stanza: stanza }
+ res = send_stanza payload
+ raise res.inspect if res.status != 200
+ end
+ end
+ end
+
+ private
+
+ def process_avatar
+ @user.avatar.blob.open do |file|
+ processed = ImageProcessing::Vips
+ .source(file)
+ .resize_to_fill(256, 256)
+ .convert("png")
+ .call
+ processed.read
+ end
+ end
+
+ # See https://xmpp.org/extensions/xep-0084.html
+ def build_xep0084_stanzas
+ img_data = process_avatar
+ sha1_hash = Digest::SHA1.hexdigest(img_data)
+ base64_data = Base64.strict_encode64(img_data)
+
+ [
+ """
+
+
+
+ -
+
#{base64_data}
+
+
+
+
+ """.strip,
+ """
+
+
+
+ -
+
+
+
+
+
+
+
+ """.strip,
+ ]
+ end
+end
diff --git a/app/services/ejabberd_manager/set_default_bookmarks.rb b/app/services/ejabberd_manager/set_default_bookmarks.rb
new file mode 100644
index 0000000..4c94e06
--- /dev/null
+++ b/app/services/ejabberd_manager/set_default_bookmarks.rb
@@ -0,0 +1,31 @@
+module EjabberdManager
+ class SetDefaultBookmarks < EjabberdManagerService
+ def initialize(user:)
+ @user = user
+ end
+
+ def call
+ return unless Setting.xmpp_default_rooms.any?
+
+ private_set @user, storage_content
+ end
+
+ private
+
+ def storage_content
+ bookmarks = ""
+ Setting.xmpp_default_rooms.each do |r|
+ bookmarks << conference_element(
+ jid: r[/<(.+)>/, 1], name: r[/^(.+)\s/, 1], nick: @user.cn,
+ autojoin: Setting.xmpp_autojoin_default_rooms
+ )
+ end
+
+ "#{bookmarks} "
+ end
+
+ def conference_element(jid:, name:, autojoin: false, nick:)
+ "#{nick} "
+ end
+ end
+end
diff --git a/app/services/ejabberd_api_client.rb b/app/services/ejabberd_manager_service.rb
similarity index 76%
rename from app/services/ejabberd_api_client.rb
rename to app/services/ejabberd_manager_service.rb
index df22d25..733e384 100644
--- a/app/services/ejabberd_api_client.rb
+++ b/app/services/ejabberd_manager_service.rb
@@ -1,11 +1,16 @@
-class EjabberdApiClient
- def initialize
- @base_url = Setting.ejabberd_api_url
+class EjabberdManagerService < RestApiService
+ private
+
+ def base_url
+ @base_url ||= Setting.ejabberd_api_url
end
- def post(endpoint, payload)
- Faraday.post "#{@base_url}/#{endpoint}", payload.to_json,
- "Content-Type" => "application/json"
+ def headers
+ { "Content-Type" => "application/json" }
+ end
+
+ def parse_responses?
+ false
end
#
diff --git a/app/services/ldap_service.rb b/app/services/ldap_service.rb
index 316a58a..a7e9ff5 100644
--- a/app/services/ldap_service.rb
+++ b/app/services/ldap_service.rb
@@ -50,19 +50,17 @@ class LdapService < ApplicationService
end
def fetch_users(args={})
- if args[:ou]
- treebase = "ou=#{args[:ou]},cn=users,#{ldap_suffix}"
- else
- treebase = ldap_config["base"]
- end
-
attributes = %w[
- dn cn uid mail displayName admin serviceEnabled
+ dn cn uid mail displayName admin serviceEnabled memberStatus
mailRoutingAddress mailpassword nostrKey pgpKey
]
- filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*")
+ filter = Net::LDAP::Filter.eq('objectClass', 'person') &
+ Net::LDAP::Filter.eq("cn", args[:cn] || "*")
- entries = client.search(base: treebase, filter: filter, attributes: attributes)
+ entries = client.search(
+ base: ldap_config["base"], filter: filter,
+ attributes: attributes
+ )
entries.sort_by! { |e| e.cn[0] }
entries = entries.collect do |e|
{
@@ -71,6 +69,7 @@ class LdapService < ApplicationService
display_name: e.try(:displayName) ? e.displayName.first : nil,
admin: e.try(:admin) ? 'admin' : nil,
services_enabled: e.try(:serviceEnabled),
+ member_status: e.try(:memberStatus),
email_maildrop: e.try(:mailRoutingAddress),
email_password: e.try(:mailpassword),
nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil,
@@ -79,10 +78,20 @@ class LdapService < ApplicationService
end
end
+ def search_users(search_attr, value, return_attr)
+ filter = Net::LDAP::Filter.eq('objectClass', 'person') &
+ Net::LDAP::Filter.eq(search_attr.to_s, value.to_s) &
+ Net::LDAP::Filter.present('cn')
+ entries = client.search(
+ base: ldap_config["base"], filter: filter,
+ attributes: [return_attr]
+ )
+ entries.map { |entry| entry[return_attr].first }.compact
+ end
+
def fetch_organizations
attributes = %w{dn ou description}
filter = Net::LDAP::Filter.eq("objectClass", "organizationalUnit")
- # filter = Net::LDAP::Filter.eq("objectClass", "*")
treebase = "cn=users,#{ldap_suffix}"
entries = client.search(base: treebase, filter: filter, attributes: attributes)
diff --git a/app/services/rest_api_service.rb b/app/services/rest_api_service.rb
index e46cfb3..8e23fef 100644
--- a/app/services/rest_api_service.rb
+++ b/app/services/rest_api_service.rb
@@ -13,15 +13,17 @@ class RestApiService < ApplicationService
"#{base_url}/#{path.gsub(/^\//, '')}"
end
+ def parse_responses?
+ true
+ end
+
def get(path, params = {})
res = Faraday.get endpoint_url(path), params, headers
- # TODO handle unsuccessful responses with no valid JSON body
- JSON.parse(res.body)
+ parse_responses? ? JSON.parse(res.body) : res
end
def post(path, payload)
res = Faraday.post endpoint_url(path), payload.to_json, headers
- # TODO handle unsuccessful responses with no valid JSON body
- JSON.parse(res.body)
+ parse_responses? ? JSON.parse(res.body) : res
end
end
diff --git a/app/services/user_manager/update_avatar.rb b/app/services/user_manager/update_avatar.rb
index d03f37b..d3bcc31 100644
--- a/app/services/user_manager/update_avatar.rb
+++ b/app/services/user_manager/update_avatar.rb
@@ -8,6 +8,7 @@ module UserManager
LdapManager::UpdateAvatar.call(user: @user)
if Setting.ejabberd_enabled?
+ return if Rails.env.development?
XmppSetAvatarJob.perform_later(user: @user)
end
end
diff --git a/app/views/admin/_username_search_form.html.erb b/app/views/admin/_username_search_form.html.erb
new file mode 100644
index 0000000..b318c86
--- /dev/null
+++ b/app/views/admin/_username_search_form.html.erb
@@ -0,0 +1,11 @@
+<%= form_with url: path, method: :get, local: true, class: "flex gap-1" do %>
+ <%= text_field_tag :username, @username, placeholder: 'Filter by username' %>
+ <%= button_tag type: 'submit', name: nil, title: "Filter", class: 'btn-md btn-icon btn-outline' do %>
+ <%= render partial: "icons/filter", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %>
+ <% end %>
+ <% if @username %>
+ <%= link_to path, title: "Remove filter", class: 'btn-md btn-icon btn-outline' do %>
+ <%= render partial: "icons/x", locals: { custom_class: "text-red-600 h-4 w-4 inline" } %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/admin/donations/_list.html.erb b/app/views/admin/donations/_list.html.erb
new file mode 100644
index 0000000..aa405ec
--- /dev/null
+++ b/app/views/admin/donations/_list.html.erb
@@ -0,0 +1,34 @@
+
+
+
+ User
+ Sats
+ Fiat Amount
+ Public name
+ Date
+
+
+
+
+ <% donations.each do |donation| %>
+
+ <%= link_to donation.user.cn, admin_user_path(donation.user.cn), class: 'ks-text-link' %>
+ <% if donation.amount_sats.present? %><%= number_with_delimiter donation.amount_sats %><% end %>
+ <% if donation.fiat_amount.present? %><%= number_to_currency donation.fiat_amount.to_f / 100, unit: "" %> <%= donation.fiat_currency %><% end %>
+ <%= donation.public_name %>
+ <%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : donation.created_at.strftime("%Y-%m-%d (%H:%M UTC)") %>
+
+ <%= link_to 'Show', admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
+ <%= link_to 'Edit', edit_admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
+ <%= link_to 'Destroy', admin_donation_path(donation), class: 'btn btn-sm btn-red',
+ data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %>
+
+
+ <% end %>
+
+
+<% if defined?(pagy) %>
+
+ <%== pagy_nav pagy %>
+
+<% end %>
diff --git a/app/views/admin/donations/index.html.erb b/app/views/admin/donations/index.html.erb
index 90a755e..2e80ba2 100644
--- a/app/views/admin/donations/index.html.erb
+++ b/app/views/admin/donations/index.html.erb
@@ -5,7 +5,7 @@
<%= render QuickstatsContainerComponent.new do %>
<%= render QuickstatsItemComponent.new(
type: :number,
- title: 'Overall',
+ title: 'Received',
value: @stats[:overall_sats],
unit: 'sats'
) %>
@@ -19,41 +19,28 @@
- <% if @donations.any? %>
- Recent Donations
-
-
-
- User
- Sats
- Fiat Amount
- Public name
- Date
-
-
-
-
- <% @donations.each do |donation| %>
-
- <%= link_to donation.user.cn, admin_user_path(donation.user.cn), class: 'ks-text-link' %>
- <% if donation.amount_sats.present? %><%= number_with_delimiter donation.amount_sats %><% end %>
- <% if donation.fiat_amount.present? %><%= number_to_currency donation.fiat_amount.to_f / 100, unit: "" %> <%= donation.fiat_currency %><% end %>
- <%= donation.public_name %>
- <%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : "" %>
-
- <%= link_to 'Show', admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
- <%= link_to 'Edit', edit_admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
- <%= link_to 'Destroy', admin_donation_path(donation), class: 'btn btn-sm btn-red',
- data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %>
-
-
- <% end %>
-
-
- <%== pagy_nav @pagy %>
+ <%= render partial: "admin/username_search_form",
+ locals: { path: admin_donations_path } %>
+
+
+ <% if @pending_donations.present? %>
+
+ Pending
+ <%= render partial: "admin/donations/list", locals: {
+ donations: @pending_donations
+ } %>
+
+ <% end %>
+
+
+ <% if @donations.present? %>
+ Received
+ <%= render partial: "admin/donations/list", locals: {
+ donations: @donations, pagy: @pagy
+ } %>
<% else %>
- No donations yet.
+ No donations received yet.
<% end %>
diff --git a/app/views/admin/donations/show.html.erb b/app/views/admin/donations/show.html.erb
index 00b32cc..3e3ad65 100644
--- a/app/views/admin/donations/show.html.erb
+++ b/app/views/admin/donations/show.html.erb
@@ -25,7 +25,15 @@
<%= @donation.public_name %>
- Date
+ Payment status
+ <%= @donation.payment_status %>
+
+
+ Created at
+ <%= @donation.created_at&.strftime("%Y-%m-%d (%H:%M UTC)") %>
+
+
+ Paid at
<%= @donation.paid_at&.strftime("%Y-%m-%d (%H:%M UTC)") %>
diff --git a/app/views/admin/invitations/index.html.erb b/app/views/admin/invitations/index.html.erb
index 7c2cd43..acc5606 100644
--- a/app/views/admin/invitations/index.html.erb
+++ b/app/views/admin/invitations/index.html.erb
@@ -21,9 +21,15 @@
) %>
<% end %>
+
+
+ <%= render partial: "admin/username_search_form",
+ locals: { path: admin_invitations_path } %>
+
+
<% if @invitations_used.any? %>
- Recently Accepted
+ Accepted
diff --git a/app/views/admin/settings/membership/show.html.erb b/app/views/admin/settings/membership/show.html.erb
new file mode 100644
index 0000000..5b403d6
--- /dev/null
+++ b/app/views/admin/settings/membership/show.html.erb
@@ -0,0 +1,53 @@
+<%= render HeaderComponent.new(title: "Settings") %>
+
+<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/admin_sidenav_settings') do %>
+ <%= form_for(Setting.new, url: admin_settings_membership_path, method: :put) do |f| %>
+
+ Membership
+
+ <% if @errors && @errors.any? %>
+ <%= render partial: "admin/settings/errors", locals: { errors: @errors } %>
+ <% end %>
+
+
+ <%= render FormElements::FieldsetResettableSettingComponent.new(
+ key: :member_status_contributor,
+ title: "Status name for contributing users",
+ description: "A contributing member of your organization/group"
+ ) %>
+ <%= render FormElements::FieldsetResettableSettingComponent.new(
+ key: :member_status_sustainer,
+ title: "Status name for paying users",
+ description: "A paying/donating member or customer"
+ ) %>
+
+
+
+
+ Admin panel
+
+
+ <%= render FormElements::FieldsetToggleComponent.new(
+ form: f,
+ attribute: :user_index_show_contributors,
+ enabled: Setting.user_index_show_contributors?,
+ title: "Show #{Setting.member_status_contributor.downcase} status in user list",
+ description: "Can slow down page rendering with large user base"
+ ) %>
+ <%= render FormElements::FieldsetToggleComponent.new(
+ form: f,
+ attribute: :user_index_show_sustainers,
+ enabled: Setting.user_index_show_sustainers?,
+ title: "Show #{Setting.member_status_sustainer.downcase} status in user list",
+ description: "Can slow down page rendering with large user base"
+ ) %>
+
+
+
+
+
+ <%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %>
+
+
+ <% end %>
+<% end %>
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb
index 830057a..66bf6f6 100644
--- a/app/views/admin/users/index.html.erb
+++ b/app/views/admin/users/index.html.erb
@@ -13,6 +13,20 @@
title: 'Pending',
value: @stats[:users_pending],
) %>
+ <% if @show_contributors %>
+ <%= render QuickstatsItemComponent.new(
+ type: :number,
+ title: Setting.member_status_contributor.pluralize,
+ value: @stats[:users_contributing],
+ ) %>
+ <% end %>
+ <% if @show_sustainers %>
+ <%= render QuickstatsItemComponent.new(
+ type: :number,
+ title: Setting.member_status_sustainer.pluralize,
+ value: @stats[:users_paying],
+ ) %>
+ <% end %>
<% end %>
@@ -29,8 +43,12 @@
<% @users.each do |user| %>
<%= link_to(user.cn, admin_user_path(user.cn), class: 'ks-text-link') %>
- <%= user.confirmed_at.nil? ? badge("pending", :yellow) : "" %>
- <%= user.is_admin? ? badge("admin", :red) : "" %>
+
+ <%= user.confirmed_at.nil? ? badge("pending", :yellow) : "" %>
+ <% if @show_contributors %><%= @contributors.include?(user.cn) ? badge("contributor", :green) : "" %><% end %>
+ <% if @show_sustainers %><%= @sustainers.include?(user.cn) ? badge("sustainer", :green) : "" %><% end %>
+
+ <%= @admins.include?(user.cn) ? badge("admin", :red) : "" %>
<% end %>
diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb
index a2e86c2..367b5a7 100644
--- a/app/views/admin/users/show.html.erb
+++ b/app/views/admin/users/show.html.erb
@@ -32,6 +32,30 @@
Roles
<%= @user.is_admin? ? badge("admin", :red) : "—" %>
+
+ Status
+
+ <% if @user.is_contributing_member? || @user.is_paying_member? %>
+ <%= @user.is_contributing_member? ? badge("contributor", :green) : "" %>
+ <%= @user.is_paying_member? ? badge("sustainer", :green) : "" %>
+ <% else %>
+ —
+ <% end %>
+
+
+
+ Donations
+
+ <% if @user.donations.any? %>
+ <%= link_to admin_donations_path(username: @user.cn), class: "ks-text-link" do %>
+ <%= @user.donations.completed.count %> for
+ <%= number_with_delimiter @user.donations.completed.sum("amount_sats") %> sats
+ <% end %>
+ <% else %>
+ —
+ <% end %>
+
+
Invited by
@@ -75,10 +99,13 @@
Invited users
- <% if @user.invitees.length > 0 %>
+ <% if @invitees.any? %>
- <% @user.invitees.order(cn: :asc).each do |invitee| %>
- <%= link_to invitee.cn, admin_user_path(invitee.cn), class: 'ks-text-link' %>
+ <% @recent_invitees.each do |invitee| %>
+ <%= link_to invitee.cn, admin_user_path(invitee.cn), class: "ks-text-link" %>
+ <% end %>
+ <% if @more_invitees > 0 %>
+ and <%= link_to "#{@more_invitees} more", admin_invitations_path(username: @user.cn), class: "ks-text-link" %>
<% end %>
<% else %>—<% end %>
diff --git a/app/views/contributions/donations/index.html.erb b/app/views/contributions/donations/index.html.erb
index 3e03617..2e9d33c 100644
--- a/app/views/contributions/donations/index.html.erb
+++ b/app/views/contributions/donations/index.html.erb
@@ -22,17 +22,17 @@
- <% if @donations_pending.any? %>
+ <% if @donations_processing.any? %>
Pending
<%= render partial: "contributions/donations/list",
- locals: { donations: @donations_pending } %>
+ locals: { donations: @donations_processing } %>
<% end %>
<% if @donations_completed.any? %>
- Past contributions
+ Contributions
<%= render partial: "contributions/donations/list",
locals: { donations: @donations_completed } %>
diff --git a/app/views/icons/_filter.html.erb b/app/views/icons/_filter.html.erb
index 38a47e0..5adc889 100644
--- a/app/views/icons/_filter.html.erb
+++ b/app/views/icons/_filter.html.erb
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/views/icons/_server.html.erb b/app/views/icons/_server.html.erb
index 54ce094..f97725e 100644
--- a/app/views/icons/_server.html.erb
+++ b/app/views/icons/_server.html.erb
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/views/icons/_users.html.erb b/app/views/icons/_users.html.erb
index aacf6b0..81970a1 100644
--- a/app/views/icons/_users.html.erb
+++ b/app/views/icons/_users.html.erb
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/views/shared/_admin_sidenav_settings.html.erb b/app/views/shared/_admin_sidenav_settings.html.erb
index 48c5ced..14efb00 100644
--- a/app/views/shared/_admin_sidenav_settings.html.erb
+++ b/app/views/shared/_admin_sidenav_settings.html.erb
@@ -3,7 +3,11 @@
active: current_page?(admin_settings_registrations_path)
) %>
<%= render SidenavLinkComponent.new(
- name: "Services", path: admin_settings_services_path, icon: "grid",
+ name: "Membership", path: admin_settings_membership_path, icon: "users",
+ active: current_page?(admin_settings_membership_path)
+) %>
+<%= render SidenavLinkComponent.new(
+ name: "Services", path: admin_settings_services_path, icon: "server",
active: controller_name == "services"
) %>
<% if controller_name == "services" %>
diff --git a/config/routes.rb b/config/routes.rb
index 759abcc..115fadd 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -109,6 +109,7 @@ Rails.application.routes.draw do
namespace :settings do
resource 'registrations', only: ['show', 'update']
+ resource 'membership', only: ['show', 'update'], controller: 'membership'
resources 'services', param: 'service', only: ['index', 'show', 'update']
end
end
diff --git a/db/migrate/20250527113805_update_payment_status_to_pending.rb b/db/migrate/20250527113805_update_payment_status_to_pending.rb
new file mode 100644
index 0000000..f334c21
--- /dev/null
+++ b/db/migrate/20250527113805_update_payment_status_to_pending.rb
@@ -0,0 +1,6 @@
+class UpdatePaymentStatusToPending < ActiveRecord::Migration[8.0]
+ def change
+ Donation.where(payment_status: nil).update_all(payment_status: "pending")
+ Donation.where.not(payment_status: %w[pending processing settled]).update_all(payment_status: "pending")
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1140431..b7746ac 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_05_17_105755) do
+ActiveRecord::Schema[8.0].define(version: 2025_05_27_113805) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
diff --git a/lib/tasks/ldap.rake b/lib/tasks/ldap.rake
index 244a7d2..9f078bf 100644
--- a/lib/tasks/ldap.rake
+++ b/lib/tasks/ldap.rake
@@ -21,7 +21,7 @@ namespace :ldap do
desc "Add custom attributes to schema"
task add_custom_attributes: :environment do |t, args|
- %w[ admin service_enabled nostr_key pgp_key ].each do |name|
+ %w[ admin service_enabled member_status nostr_key pgp_key ].each do |name|
Rake::Task["ldap:modify_ldap_schema"].invoke(name, "add")
Rake::Task['ldap:modify_ldap_schema'].reenable
end
@@ -29,7 +29,7 @@ namespace :ldap do
desc "Delete custom attributes from schema"
task delete_custom_attributes: :environment do |t, args|
- %w[ admin service_enabled nostr_key pgp_key ].each do |name|
+ %w[ admin service_enabled member_status nostr_key pgp_key ].each do |name|
Rake::Task["ldap:modify_ldap_schema"].invoke(name, "delete")
Rake::Task['ldap:modify_ldap_schema'].reenable
end
diff --git a/schemas/ldap/member_status.ldif b/schemas/ldap/member_status.ldif
new file mode 100644
index 0000000..4b55f6f
--- /dev/null
+++ b/schemas/ldap/member_status.ldif
@@ -0,0 +1,8 @@
+dn: cn=schema
+changetype: modify
+add: attributeTypes
+attributeTypes: ( 1.3.6.1.4.1.61554.1.1.2.1.3
+ NAME 'memberStatus'
+ DESC 'Current member/contributor status'
+ EQUALITY caseExactMatch
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )
diff --git a/spec/fixtures/files/avatar-base64-png.txt b/spec/fixtures/files/avatar-base64-png.txt
new file mode 100644
index 0000000..77cb4bc
--- /dev/null
+++ b/spec/fixtures/files/avatar-base64-png.txt
@@ -0,0 +1 @@
+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
diff --git a/spec/jobs/btcpay_check_donation_job_spec.rb b/spec/jobs/btcpay_check_donation_job_spec.rb
index 71cd1df..6e12adb 100644
--- a/spec/jobs/btcpay_check_donation_job_spec.rb
+++ b/spec/jobs/btcpay_check_donation_job_spec.rb
@@ -8,16 +8,18 @@ RSpec.describe BtcpayCheckDonationJob, type: :job do
user.donations.create!(
donation_method: "btcpay",
btcpay_invoice_id: "K4e31MhbLKmr3D7qoNYRd3",
- paid_at: nil, payment_status: "processing",
- fiat_amount: 120, fiat_currency: "USD"
+ paid_at: nil,
+ payment_status: "processing",
+ fiat_amount: 120,
+ fiat_currency: "USD"
)
end
before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
- uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
- display_name: nil
+ uid: user.cn, ou: user.ou, mail: user.email, admin: nil, display_name: nil
})
+ allow_any_instance_of(User).to receive(:add_member_status)
end
after(:each) do
@@ -65,15 +67,20 @@ RSpec.describe BtcpayCheckDonationJob, type: :job do
it "notifies the user via email" do
perform_enqueued_jobs(only: described_class) { job }
expect(enqueued_jobs.size).to eq(1)
- job = enqueued_jobs.select{|j| j['job_class'] == "ActionMailer::MailDeliveryJob"}.first
+ job = enqueued_jobs.select { |j| j['job_class'] == "ActionMailer::MailDeliveryJob" }.first
expect(job['arguments'][0]).to eq('NotificationMailer')
expect(job['arguments'][1]).to eq('bitcoin_donation_confirmed')
- expect(job['arguments'][3]['params']['user']['_aj_globalid']).to eq('gid://akkounts/User/1')
+ expect(job['arguments'][3]['params']['user']['_aj_globalid']).to eq(user.to_global_id.to_s)
end
it "does not enqueue itself again" do
expect_any_instance_of(described_class).not_to receive(:re_enqueue_job)
perform_enqueued_jobs(only: described_class) { job }
end
+
+ it "updates the user's member status" do
+ expect_any_instance_of(User).to receive(:add_member_status).with(:sustainer)
+ perform_enqueued_jobs(only: described_class) { job }
+ end
end
end
diff --git a/spec/jobs/xmpp_exchange_contacts_job_spec.rb b/spec/jobs/xmpp_exchange_contacts_job_spec.rb
index 013a80d..8f25895 100644
--- a/spec/jobs/xmpp_exchange_contacts_job_spec.rb
+++ b/spec/jobs/xmpp_exchange_contacts_job_spec.rb
@@ -1,5 +1,4 @@
require 'rails_helper'
-require 'webmock/rspec'
RSpec.describe XmppExchangeContactsJob, type: :job do
let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" }
@@ -10,19 +9,11 @@ RSpec.describe XmppExchangeContactsJob, type: :job do
described_class.perform_later(user, guest)
}
- before do
- stub_request(:post, "http://xmpp.example.com/api/add_rosteritem")
- .to_return(status: 200, body: "", headers: {})
- allow_any_instance_of(User).to receive(:services_enabled).and_return(["ejabberd"])
- end
+ it "calls the service for exchanging contacts" do
+ expect(EjabberdManager::ExchangeContacts).to receive(:call)
+ .with(inviter: user, invitee: guest).and_return(true)
- it "posts add_rosteritem commands to the ejabberd API" do
perform_enqueued_jobs { job }
-
- expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/add_rosteritem")
- .with { |req| req.body == '{"localuser":"isaacnewton","localhost":"kosmos.org","user":"willherschel","host":"kosmos.org","nick":"willherschel","group":"Buddies","subs":"both"}' }
- expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/add_rosteritem")
- .with { |req| req.body == '{"localuser":"willherschel","localhost":"kosmos.org","user":"isaacnewton","host":"kosmos.org","nick":"isaacnewton","group":"Buddies","subs":"both"}' }
end
after do
diff --git a/spec/jobs/xmpp_send_message_spec.rb b/spec/jobs/xmpp_send_message_spec.rb
new file mode 100644
index 0000000..60166b9
--- /dev/null
+++ b/spec/jobs/xmpp_send_message_spec.rb
@@ -0,0 +1,25 @@
+require 'rails_helper'
+
+RSpec.describe XmppSendMessageJob, type: :job do
+ let(:payload) {{
+ type: "normal",
+ from: "kosmos.org", to: "willherschel@kosmos.org",
+ body: "This is a test message"
+ }}
+
+ subject(:job) {
+ described_class.perform_later(payload)
+ }
+
+ it "calls the service for exchanging contacts" do
+ expect(EjabberdManager::SendMessage).to receive(:call)
+ .with(payload: payload).and_return(true)
+
+ perform_enqueued_jobs { job }
+ end
+
+ after do
+ clear_enqueued_jobs
+ clear_performed_jobs
+ end
+end
diff --git a/spec/jobs/xmpp_set_default_bookmarks_job_spec.rb b/spec/jobs/xmpp_set_default_bookmarks_job_spec.rb
index 299cbcc..78c209f 100644
--- a/spec/jobs/xmpp_set_default_bookmarks_job_spec.rb
+++ b/spec/jobs/xmpp_set_default_bookmarks_job_spec.rb
@@ -1,30 +1,17 @@
require 'rails_helper'
-require 'webmock/rspec'
RSpec.describe XmppSetDefaultBookmarksJob, type: :job do
let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" }
- before do
- Setting.xmpp_default_rooms = [
- "Welcome ",
- "Kosmos Dev "
- ]
- end
-
subject(:job) {
described_class.perform_later(user)
}
- before do
- stub_request(:post, "http://xmpp.example.com/api/private_set")
- .to_return(status: 200, body: "", headers: {})
- end
+ it "calls the service for setting default bookmarks" do
+ expect(EjabberdManager::SetDefaultBookmarks).to receive(:call)
+ .with(user: user).and_return(true)
- it "posts a private_set command to the ejabberd API" do
perform_enqueued_jobs { job }
-
- expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/private_set")
- .with { |req| req.body == '{"user":"willherschel","host":"kosmos.org","element":"\u003cstorage xmlns=\'storage:bookmarks\'\u003e\u003cconference jid=\'welcome@kosmos.chat\' name=\'Welcome\' autojoin=\'false\'\u003e\u003cnick\u003ewillherschel\u003c/nick\u003e\u003c/conference\u003e\u003cconference jid=\'kosmos-dev@kosmos.chat\' name=\'Kosmos Dev\' autojoin=\'false\'\u003e\u003cnick\u003ewillherschel\u003c/nick\u003e\u003c/conference\u003e\u003c/storage\u003e"}' }
end
after do
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index a4e0f57..9b96edc 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -154,7 +154,7 @@ RSpec.describe User, type: :model do
it "removes all services from the LDAP entry" do
expect_any_instance_of(LdapService).to receive(:delete_attribute)
- .with(dn, :service).and_return(true)
+ .with(dn, :serviceEnabled).and_return(true)
user.disable_all_services
end
diff --git a/spec/requests/contributions/donations_spec.rb b/spec/requests/contributions/donations_spec.rb
index 01397eb..e6d1392 100644
--- a/spec/requests/contributions/donations_spec.rb
+++ b/spec/requests/contributions/donations_spec.rb
@@ -177,7 +177,7 @@ RSpec.describe "Donations", type: :request do
.to_return(status: 200, headers: {}, body: invoice)
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/MCkDbf2cUgBuuisUCgnRnb/payment-methods")
.to_return(status: 200, headers: {}, body: payments)
-
+ allow(user).to receive(:add_member_status).with(:sustainer).and_return(["sustainer"])
get confirm_btcpay_contributions_donation_path(subject)
end
@@ -185,11 +185,16 @@ RSpec.describe "Donations", type: :request do
subject.reload
expect(subject.paid_at).not_to be_nil
expect(subject.amount_sats).to eq(2061)
+ expect(subject.payment_status).to eq("settled")
end
it "redirects to the donations index" do
expect(response).to redirect_to(contributions_donations_url)
end
+
+ it "updates the user's member status" do
+ expect(user).to have_received(:add_member_status).with(:sustainer)
+ end
end
describe "amount in sats" do
diff --git a/spec/services/ejabberd_manager/exchange_contacts_spec.rb b/spec/services/ejabberd_manager/exchange_contacts_spec.rb
new file mode 100644
index 0000000..b15e436
--- /dev/null
+++ b/spec/services/ejabberd_manager/exchange_contacts_spec.rb
@@ -0,0 +1,22 @@
+require 'rails_helper'
+require 'webmock/rspec'
+
+RSpec.describe EjabberdManager::ExchangeContacts, type: :model do
+ let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" }
+ let(:guest) { create :user, cn: "isaacnewton", ou: "kosmos.org",
+ id: 2, email: "hotapple42@eol.com" }
+
+ before do
+ stub_request(:post, "http://xmpp.example.com/api/add_rosteritem")
+ .to_return(status: 200, body: "", headers: {})
+ allow_any_instance_of(User).to receive(:services_enabled).and_return(["ejabberd"])
+ described_class.call(inviter: user, invitee: guest)
+ end
+
+ it "posts add_rosteritem commands to the ejabberd API" do
+ expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/add_rosteritem")
+ .with { |req| req.body == '{"localuser":"isaacnewton","localhost":"kosmos.org","user":"willherschel","host":"kosmos.org","nick":"willherschel","group":"Buddies","subs":"both"}' }
+ expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/add_rosteritem")
+ .with { |req| req.body == '{"localuser":"willherschel","localhost":"kosmos.org","user":"isaacnewton","host":"kosmos.org","nick":"isaacnewton","group":"Buddies","subs":"both"}' }
+ end
+end
diff --git a/spec/services/ejabberd_manager/get_avatar_spec.rb b/spec/services/ejabberd_manager/get_avatar_spec.rb
new file mode 100644
index 0000000..f7772d5
--- /dev/null
+++ b/spec/services/ejabberd_manager/get_avatar_spec.rb
@@ -0,0 +1,37 @@
+require 'rails_helper'
+require 'webmock/rspec'
+
+RSpec.describe EjabberdManager::GetAvatar, type: :model do
+ let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" }
+ let(:img_base64) { File.read("#{Rails.root}/spec/fixtures/files/avatar-base64-png.txt").chomp }
+
+ context "when no avatar is set yet" do
+ before do
+ stub_request(:post, "http://xmpp.example.com/api/get_vcard2")
+ .with { |req| req.body == '{"user":"willherschel","host":"kosmos.org","name":"PHOTO","subname":"BINVAL"}' }
+ .to_return(status: 400, body: "", headers: {})
+ end
+
+ it "returns nil" do
+ res = described_class.call(user: user)
+ expect(res).to be_nil
+ end
+ end
+
+ context "when avatar exists" do
+ before do
+ stub_request(:post, "http://xmpp.example.com/api/get_vcard2")
+ .with { |req| req.body == '{"user":"willherschel","host":"kosmos.org","name":"PHOTO","subname":"BINVAL"}' }
+ .and_return(status: 200, body: { content: img_base64 }.to_json, headers: {})
+ stub_request(:post, "http://xmpp.example.com/api/get_vcard2")
+ .with { |req| req.body == '{"user":"willherschel","host":"kosmos.org","name":"PHOTO","subname":"TYPE"}' }
+ .and_return(status: 200, body: { content: "image/png" }.to_json, headers: {})
+ end
+
+ it "fetches the avatar and content type" do
+ res = described_class.call(user: user)
+ expect(res[:img_base64]).to eq(img_base64)
+ expect(res[:content_type]).to eq("image/png")
+ end
+ end
+end
diff --git a/spec/services/ejabberd_manager/set_default_bookmarks_spec.rb b/spec/services/ejabberd_manager/set_default_bookmarks_spec.rb
new file mode 100644
index 0000000..2986a4c
--- /dev/null
+++ b/spec/services/ejabberd_manager/set_default_bookmarks_spec.rb
@@ -0,0 +1,22 @@
+require 'rails_helper'
+require 'webmock/rspec'
+
+RSpec.describe EjabberdManager::SetDefaultBookmarks, type: :model do
+ let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" }
+
+ before do
+ Setting.xmpp_default_rooms = [
+ "Welcome ",
+ "Kosmos Dev "
+ ]
+ stub_request(:post, "http://xmpp.example.com/api/private_set")
+ .to_return(status: 200, body: "", headers: {})
+
+ described_class.call(user:)
+ end
+
+ it "posts a private_set command to the ejabberd API" do
+ expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/private_set")
+ .with { |req| req.body == '{"user":"willherschel","host":"kosmos.org","element":"\u003cstorage xmlns=\'storage:bookmarks\'\u003e\u003cconference jid=\'welcome@kosmos.chat\' name=\'Welcome\' autojoin=\'false\'\u003e\u003cnick\u003ewillherschel\u003c/nick\u003e\u003c/conference\u003e\u003cconference jid=\'kosmos-dev@kosmos.chat\' name=\'Kosmos Dev\' autojoin=\'false\'\u003e\u003cnick\u003ewillherschel\u003c/nick\u003e\u003c/conference\u003e\u003c/storage\u003e"}' }
+ end
+end