diff --git a/app/controllers/admin/members_controller.rb b/app/controllers/admin/members_controller.rb index 781d88e86..97406e67f 100644 --- a/app/controllers/admin/members_controller.rb +++ b/app/controllers/admin/members_controller.rb @@ -8,8 +8,10 @@ module Admin responders :flash def index - @members = Member.order(:login_name) - @members = @members.where("login_name ILIKE ?", "%#{search_term}%") if search_term.present? + @status = params[:status] == 'deactivated' ? 'deactivated' : 'active' + @members = @status == 'deactivated' ? Member.discarded : Member.kept + @members = @members.includes(:roles).order(created_at: :desc) + @members = @members.where("login_name ILIKE ? OR email ILIKE ?", "%#{search_term}%", "%#{search_term}%") if search_term.present? @members = @members.paginate(page: params[:page]) end @@ -27,6 +29,26 @@ module Admin def destroy @member = Member.find_by!(slug: params[:slug]) @member.discard + redirect_to admin_members_path(status: params[:status]) + end + + def restore + @member = Member.find_by!(slug: params[:slug]) + @member.undiscard + redirect_to admin_members_path(status: 'deactivated') + end + + def bulk_ban + member_ids = params[:member_ids] + if member_ids.present? && member_ids.is_a?(Array) + members_to_ban = Member.kept.where(id: member_ids) + # Prevent admins from self-banning via bulk ban + members_to_ban = members_to_ban.where.not(id: current_member.id) + members_to_ban.find_each(&:discard) + flash[:notice] = "Selected members have been banned." + else + flash[:alert] = "No members were selected." + end redirect_to admin_members_path end diff --git a/app/views/admin/members/index.html.haml b/app/views/admin/members/index.html.haml index b5eca1c76..76cfaf01f 100644 --- a/app/views/admin/members/index.html.haml +++ b/app/views/admin/members/index.html.haml @@ -2,32 +2,141 @@ %li.breadcrumb-item= link_to 'Admin', admin_path %li.breadcrumb-item.active= link_to 'Members', admin_members_path -.row +.row.mb-3 .col-md-6 = bootstrap_form_tag url: admin_members_path, method: :get, layout: :horizontal do |f| + = hidden_field_tag :status, @status .input-group %span.input-group-text= icon 'fas', 'user' - = f.text_field(:q, 'aria-label': 'search members', placeholder: 'search members', hide_label: true) + = f.text_field(:q, value: params[:q], 'aria-label': 'search members', placeholder: 'search members', hide_label: true) = f.submit 'search', class: 'btn btn-secondary' -%table.table.table-striped - %thead - %tr - %th{scope: "col"} # - %th{scope: "col"} Login Name - - @members.each do |member| - %tr - %td= render 'members/tiny', member: member - %td= member.login_name - %td - %ul - - member.roles.pluck(:name).each do |r| - %li= r - %td - = link_to edit_admin_member_path(member), class: 'btn' do - = edit_icon - = link_to admin_member_path(member), method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn btn-light text-danger' do - = icon 'fas', 'ban' - Ban member +%ul.nav.nav-tabs.mb-3 + %li.nav-item + = link_to 'Active', admin_members_path(status: 'active', q: params[:q]), class: "nav-link #{@status == 'active' ? 'active' : ''}" + %li.nav-item + = link_to 'Deactivated', admin_members_path(status: 'deactivated', q: params[:q]), class: "nav-link #{@status == 'deactivated' ? 'active' : ''}" + +- if @status == 'active' + = form_tag bulk_ban_admin_members_path, method: :post, id: 'bulk_ban_form' do + .d-flex.justify-content-between.align-items-center.mb-2 + %div + = submit_tag 'Bulk Ban Selected', class: 'btn btn-danger btn-sm', data: { confirm: 'Are you sure you want to ban the selected members?' } + + .table-responsive + %table.table.table-striped.table-sm.align-middle + %thead + %tr + %th{scope: "col"} + %input#select_all_members{type: "checkbox", onclick: "document.querySelectorAll('.member-select-checkbox').forEach(cb => cb.checked = this.checked);"} + %th{scope: "col"} # + %th{scope: "col"} Login Name + %th{scope: "col"} Email + %th{scope: "col"} Confirmed + %th{scope: "col"} Last Login + %th{scope: "col"} Bio + %th{scope: "col"} Roles + %th{scope: "col"} Photos + %th{scope: "col"} Plantings + %th{scope: "col"} Forums + %th{scope: "col"} Gardens + %th{scope: "col"} Harvests + %th{scope: "col"} Jobs + %th{scope: "col"} Actions + %tbody + - @members.each do |member| + %tr + %td + - unless member.id == current_member.id + = check_box_tag "member_ids[]", member.id, false, class: 'member-select-checkbox' + %td= render 'members/tiny', member: member + %td= link_to member.login_name, member + %td= member.email + %td + - if member.confirmed? + %span.badge.bg-success Yes + - else + %span.badge.bg-secondary No + %td + - if member.last_sign_in_at.present? + %small= l(member.last_sign_in_at, format: :short) + - else + %small.text-muted Never + %td + - if member.bio.present? + %small{title: member.bio}= truncate(member.bio, length: 255) + %td + - if member.roles.any? + %small= member.roles.map(&:name).join(', ') + %td= member.photos_count || 0 + %td= member.plantings_count || 0 + %td= member.forums_count || 0 + %td= member.gardens_count || 0 + %td= member.harvests_count || 0 + %td= member.jobs_count || 0 + %td + .btn-group.btn-group-sm + = link_to edit_admin_member_path(member), class: 'btn btn-outline-secondary', title: 'Edit roles' do + = edit_icon + - unless member.id == current_member.id + = link_to admin_member_path(member, status: @status), method: :delete, data: { confirm: 'Are you sure you want to ban this member?' }, class: 'btn btn-outline-danger', title: 'Ban member' do + = icon 'fas', 'ban' + +- else + .table-responsive + %table.table.table-striped.table-sm.align-middle + %thead + %tr + %th{scope: "col"} # + %th{scope: "col"} Login Name + %th{scope: "col"} Email + %th{scope: "col"} Confirmed + %th{scope: "col"} Last Login + %th{scope: "col"} Bio + %th{scope: "col"} Instagram + %th{scope: "col"} Roles + %th{scope: "col"} Photos + %th{scope: "col"} Plantings + %th{scope: "col"} Forums + %th{scope: "col"} Gardens + %th{scope: "col"} Harvests + %th{scope: "col"} Jobs + %th{scope: "col"} Actions + %tbody + - @members.each do |member| + %tr + %td= render 'members/tiny', member: member + %td= member.login_name + %td= member.email + %td + - if member.confirmed? + %span.badge.bg-success Yes + - else + %span.badge.bg-secondary No + %td + - if member.last_sign_in_at.present? + %small= l(member.last_sign_in_at, format: :short) + - else + %small.text-muted Never + %td + - if member.bio.present? + %small{title: member.bio}= truncate(member.bio, length: 255) + %td + - if member.instagram_handle.present? + %small= member.instagram_handle + %td + - if member.roles.any? + %small= member.roles.map(&:name).join(', ') + %td= member.photos_count || 0 + %td= member.plantings_count || 0 + %td= member.forums_count || 0 + %td= member.gardens_count || 0 + %td= member.harvests_count || 0 + %td= member.jobs_count || 0 + %td + = link_to restore_admin_member_path(member), method: :post, data: { confirm: 'Restore this member?' }, class: 'btn btn-sm btn-outline-success', title: 'Restore member' do + = icon 'fas', 'undo' + Restore + = page_entries_info @members = will_paginate @members diff --git a/config/routes.rb b/config/routes.rb index 362952bb7..7f95fdeaf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -185,7 +185,10 @@ Rails.application.routes.draw do end namespace :admin do - resources :members, param: :slug + resources :members, param: :slug do + post :bulk_ban, on: :collection + post :restore, on: :member + end resources :roles resources :crops, param: :slug do resources :crop_companions diff --git a/spec/requests/admin/members_spec.rb b/spec/requests/admin/members_spec.rb new file mode 100644 index 000000000..ef21906ce --- /dev/null +++ b/spec/requests/admin/members_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin::Members', type: :request do + let(:admin_role) { create(:role, name: 'admin') } + let(:admin) { create(:member, roles: [admin_role]) } + let!(:active_member) do + create( + :member, + login_name: 'active_spammer', + email: 'active@example.com', + bio: 'A' * 300, + photos_count: 3, + plantings_count: 5, + forums_count: 1, + gardens_count: 2, + harvests_count: 4, + jobs_count: 6, + confirmed_at: Time.current, + last_sign_in_at: Time.current + ) + end + let!(:deleted_member) do + member = create(:member, login_name: 'deleted_user', email: 'deleted@example.com') + member.discard + member + end + + before do + login_as admin, scope: :member + end + + describe 'GET /admin/members' do + context 'by default (Active tab)' do + it 'filters out deleted members and shows active members' do + get admin_members_path + expect(response).to have_http_status(:ok) + expect(response.body).to include('active_spammer') + expect(response.body).not_to include('deleted_user') + end + + it 'surfaces login name, email, confirmed status, last login, truncated bio, and count attributes' do + get admin_members_path + expect(response.body).to include('active@example.com') + expect(response.body).to include('A' * 252 + '...') # truncated to 255 chars + expect(response.body).to include('Photos') + expect(response.body).to include('Plantings') + expect(response.body).to include('Forums') + expect(response.body).to include('Gardens') + expect(response.body).to include('Harvests') + expect(response.body).to include('Jobs') + end + end + + context 'when requesting deactivated tab (status=deactivated)' do + it 'shows discarded members and excludes active members' do + get admin_members_path(status: 'deactivated') + expect(response).to have_http_status(:ok) + expect(response.body).to include('deleted_user') + expect(response.body).not_to include('active_spammer') + end + end + end + + describe 'POST /admin/members/bulk_ban' do + it 'discards selected active members' do + expect do + post bulk_ban_admin_members_path, params: { member_ids: [active_member.id] } + end.to change { active_member.reload.discarded? }.from(false).to(true) + + expect(response).to redirect_to(admin_members_path) + end + + it 'does not allow admin to ban themselves via bulk ban' do + post bulk_ban_admin_members_path, params: { member_ids: [admin.id] } + expect(admin.reload.discarded?).to be false + end + end + + describe 'POST /admin/members/:slug/restore' do + it 'undiscards a deactivated member' do + expect do + post restore_admin_member_path(deleted_member) + end.to change { deleted_member.reload.discarded? }.from(true).to(false) + + expect(response).to redirect_to(admin_members_path(status: 'deactivated')) + end + end +end