Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion app/controllers/admin/superuser_abilities_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,28 @@ module Admin
class SuperuserAbilitiesController < Admin::BaseController
include Binxtils::SortableTable

before_action :find_superuser_ability, except: [:index]
before_action :find_superuser_ability, only: %i[edit update]

def index
@per_page = permitted_per_page(default: 50)
@pagy, @collection = pagy(:countish, searched_superuser_abilities.reorder("superuser_abilities.#{sort_column} #{sort_direction}")
.includes(:user), limit: @per_page, page: permitted_page)
end

def new
@superuser_ability = SuperuserAbility.new(user_identifier: params[:user_id])
end

def create
@superuser_ability = SuperuserAbility.new(permitted_create_parameters)
if @superuser_ability.save
flash[:success] = "Superuser Ability created!"
redirect_to edit_admin_superuser_ability_path(@superuser_ability)
else
render action: :new
end
end

def edit
end

Expand Down Expand Up @@ -65,5 +79,11 @@ def permitted_parameters
.select { |so| Binxtils::InputNormalizer.boolean(params[so]) }
{su_options: su_options.keys}
end

def permitted_create_parameters
params.require(:superuser_ability)
.permit(:user_identifier, :controller_name, :action_name)
.merge(permitted_parameters)
end
end
end
9 changes: 9 additions & 0 deletions app/models/superuser_ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ class SuperuserAbility < ApplicationRecord

belongs_to :user, touch: true

# user_id rather than user, so an ability outliving its soft-deleted user stays editable
validates :user_id, presence: true

# Admin creation looks the user up by email, username or id
attr_accessor :user_identifier

before_validation :set_calculated_attributes

scope :non_universal, -> { where.not(kind: "universal") }
Expand Down Expand Up @@ -86,6 +92,9 @@ def su_option?(option)
end

def set_calculated_attributes
self.controller_name = controller_name.presence
self.action_name = action_name.presence
self.user ||= User.friendly_find(user_identifier) if user_identifier.present?
self.kind = calculated_kind
self.su_options ||= []
self.su_options = su_options.sort
Expand Down
11 changes: 11 additions & 0 deletions app/views/admin/superuser_abilities/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
<% nav_header_list_items = capture do %>
<li class="nav-item tw:text-sm">
<%= render UI::ButtonLink::Component.new(
href: new_admin_superuser_ability_path(user_id: params[:user_id]),
text: "New Superuser Ability",
size: :sm
) %>
</li>
<% end %>

<%= render(Admin::IndexSkeleton::Component.new(
index_title: "Superuser Abilities",
nav_header_list_items:,
chart_collection: @render_chart && searched_superuser_abilities
)) %>
50 changes: 50 additions & 0 deletions app/views/admin/superuser_abilities/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<h1 class="mt-4">New Superuser Ability</h1>

<div class="row mb-4">
<div class="col-md-6">
<div class="card bg-light">
<div class="card-body">
<%= form_for [:admin, @superuser_ability] do |f| %>
<%= render(UI::Alerts::ObjectError::Component.new(object: @superuser_ability)) %>

<%= render(UI::Forms::Group::Component.new(form_builder: f, attribute: :user_identifier,
label_text: "User", required: true, wrapper_class: "tw:mb-1",
html_options: {autocomplete: "off"})) %>

<p class="tw:mb-4 tw:text-xs tw:text-gray-500 tw:dark:text-gray-400">
email, username or id
</p>

<%= render(UI::Forms::Group::Component.new(form_builder: f, attribute: :controller_name,
wrapper_class: "tw:mb-1", html_options: {autocomplete: "off"})) %>

<p class="tw:mb-4 tw:text-xs tw:text-gray-500 tw:dark:text-gray-400">
<em>leave blank</em> for a universal ability - access to all of
admin
</p>

<%= render(UI::Forms::Group::Component.new(form_builder: f, attribute: :action_name,
wrapper_class: "tw:mb-1", html_options: {autocomplete: "off"})) %>

<p class="tw:mb-4 tw:text-xs tw:text-gray-500 tw:dark:text-gray-400">
only applies with a controller name
</p>

<div class="tw:mb-4">
<% SuperuserAbility::SU_OPTIONS.each do |ability| %>
<%= render UI::Forms::Checkbox::Component.new(
name: ability,
label: ability.to_s.humanize,
checked: @superuser_ability.su_option?(ability)
) %>
<% end %>
</div>

<div class="tw:flex tw:justify-end">
<%= render UI::Button::Component.new(text: "Create", color: :primary, kind: :submit) %>
</div>
<% end %>
</div>
</div>
</div>
</div>
4 changes: 2 additions & 2 deletions spec/integration/landing_pages_lead_submission_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def fill_in_and_submit_demo_form(name_label:, name_value:, contact_name: "Jane D
it "submits a school lead via hero button, persisting entry across a reload" do
visit "/for_schools"
expect(page).to have_content("campus bike management")
first("button[data-open-modal]").click
open_modal(first("button[data-open-modal]"))

expect(page).to have_content("Contact us for a free trial", wait: 5)
fill_in "Name", with: "Jane Doe"
Expand Down Expand Up @@ -78,7 +78,7 @@ def fill_in_and_submit_demo_form(name_label:, name_value:, contact_name: "Jane D
log_in_via_browser(user)
visit "/for_law_enforcement"
expect(page).to have_content("bike theft recovery")
find(".le-cta-section button[data-open-modal]").click
open_modal(".le-cta-section button[data-open-modal]")

expect {
fill_in_and_submit_demo_form(name_label: "City", name_value: "Portland")
Expand Down
4 changes: 2 additions & 2 deletions spec/integration/registration/register_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@ def start_registration
expect(page).to have_field("b_param[owner_email]", with: owner_email)

# Coming back from step 2 offers starting over - dismissing keeps the registration
click_button "Start over"
open_modal(find_button("Start over"))
find("#start-over-modal [aria-label='Close']").click
click_button "Next"
expect(page).to have_current_path(details_url, url: true)

# Confirming abandons it for a blank registration, which has nothing to start over from
click_link "Back"
click_button "Start over"
open_modal(find_button("Start over"))
click_link "Yes, start over"
expect(page).to have_field("b_param[owner_email]", with: "")
expect(page).to have_no_button("Start over")
Expand Down
23 changes: 23 additions & 0 deletions spec/models/superuser_ability_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,29 @@
expect(user.reload.superuser_abilities.can_access?(controller_name: "graphs")).to be_falsey
expect(user.superuser_abilities.can_access?(controller_name: "graphs", action_name: "tables")).to be_truthy
end

context "with blank names" do
let(:superuser_ability) { SuperuserAbility.create(user: user, controller_name: " ", action_name: "") }
it "stores nil, so universal abilities stay findable" do
expect(superuser_ability.reload.controller_name).to be_nil
expect(superuser_ability.action_name).to be_nil
expect(superuser_ability.kind).to eq "universal"
end
end

context "with user_identifier" do
it "finds by email, username or id" do
expect(SuperuserAbility.create(user_identifier: user.email).user_id).to eq user.id
expect(SuperuserAbility.create(user_identifier: user.username).user_id).to eq user.id
expect(SuperuserAbility.create(user_identifier: user.id.to_s).user_id).to eq user.id
end

it "is invalid without a matching user" do
superuser_ability = SuperuserAbility.create(user_identifier: "nobody@example.com")
expect(superuser_ability).to_not be_valid
expect(superuser_ability.errors.full_messages.join).to match(/user/i)
end
end
end

describe "user touch" do
Expand Down
54 changes: 54 additions & 0 deletions spec/requests/admin/superuser_abilities_request_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,60 @@
end
end

describe "new" do
it "renders" do
get "#{base_url}/new"
expect(response.status).to eq(200)
expect(response).to render_template(:new)
end
end

describe "create" do
let(:new_user) { FactoryBot.create(:user_confirmed) }

it "creates" do
expect {
post base_url, params: {
superuser_ability: {user_identifier: new_user.email, controller_name: "bikes", action_name: "edit"},
no_hide_spam: 1
}
}.to change(SuperuserAbility, :count).by 1

superuser_ability = SuperuserAbility.last
expect(response).to redirect_to(edit_admin_superuser_ability_path(superuser_ability))
expect(superuser_ability.user_id).to eq new_user.id
expect(superuser_ability.kind).to eq "action"
expect(superuser_ability.controller_name).to eq "bikes"
expect(superuser_ability.action_name).to eq "edit"
expect(superuser_ability.su_options).to eq(%w[no_hide_spam])
end

context "with blank controller_name" do
it "creates a universal ability" do
expect {
post base_url, params: {
superuser_ability: {user_identifier: new_user.username, controller_name: "", action_name: ""}
}
}.to change(SuperuserAbility, :count).by 1

superuser_ability = SuperuserAbility.last
expect(superuser_ability.user_id).to eq new_user.id
expect(superuser_ability.kind).to eq "universal"
expect(superuser_ability.controller_name).to be_nil
expect(superuser_ability.su_options).to eq([])
end
end

context "with an unmatched user" do
it "renders new" do
expect {
post base_url, params: {superuser_ability: {user_identifier: "nobody@example.com"}}
}.to_not change(SuperuserAbility, :count)
expect(response).to render_template(:new)
end
end
end

describe "edit" do
it "renders" do
get "#{base_url}/#{superuser_ability.id}/edit"
Expand Down
15 changes: 15 additions & 0 deletions spec/support/system_spec_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ def click_combobox_option(text)
retry_on_detach { find(".hw-combobox__option", text:, match: :first).click }
end

# ui--modal wires its trigger in `connect`, and application.js lazy loads controllers -
# so a click landing before that module arrives is swallowed, leaving Capybara waiting
# on a dialog that will never open. Click again, the way a rider whose click did
# nothing would, until the dialog reports itself open. Takes the trigger (a page
# with two of them opens the same modal from either), which names the dialog.
def open_modal(trigger, attempts: 5)
element = trigger.is_a?(Capybara::Node::Element) ? trigger : find(trigger)
modal_id = element["data-open-modal"]
attempts.times do
retry_on_detach { element.click }
return if page.has_css?("##{modal_id}[open]", wait: 1)
end
raise "##{modal_id} never opened after #{attempts} clicks"
end

private

# Retry a Playwright action when the node detaches mid-action -- the raw
Expand Down