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/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/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/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 2c101b8..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 %>
@@ -267,7 +294,9 @@
) %>
+ <% if @user.nostr_pubkey.present? %>
<%= link_to "Open profile", "https://njump.me/#{@user.nostr_pubkey_bech32}", class: "btn-sm btn-gray" %>
+ <% end %>
<% 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/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/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