diff --git a/app/controllers/allocations_controller.rb b/app/controllers/allocations_controller.rb
index b708dca..d7ec421 100644
--- a/app/controllers/allocations_controller.rb
+++ b/app/controllers/allocations_controller.rb
@@ -1,4 +1,6 @@
class AllocationsController < ApplicationController
+ include ScenarioScoping
+
before_action :set_scenario
def create
@@ -27,7 +29,7 @@ def destroy
private
def set_scenario
- @scenario = Current.user.scenarios.where(organization: Current.organization).find(params[:scenario_id])
+ @scenario = accessible_scenarios.find(params[:scenario_id])
end
def allocation_params
diff --git a/app/controllers/concerns/scenario_scoping.rb b/app/controllers/concerns/scenario_scoping.rb
new file mode 100644
index 0000000..6640c07
--- /dev/null
+++ b/app/controllers/concerns/scenario_scoping.rb
@@ -0,0 +1,21 @@
+module ScenarioScoping
+ extend ActiveSupport::Concern
+
+ included do
+ helper_method :viewing_on_behalf?
+ end
+
+ private
+
+ def accessible_scenarios
+ Current.organization_membership&.accessible_scenarios || owned_scenarios
+ end
+
+ def owned_scenarios
+ Current.user.scenarios.where(organization: Current.organization)
+ end
+
+ def viewing_on_behalf?(scenario)
+ scenario.present? && scenario.user_id != Current.user.id
+ end
+end
diff --git a/app/controllers/scenarios/names_controller.rb b/app/controllers/scenarios/names_controller.rb
index b7c8037..01aaba4 100644
--- a/app/controllers/scenarios/names_controller.rb
+++ b/app/controllers/scenarios/names_controller.rb
@@ -1,4 +1,6 @@
class Scenarios::NamesController < ApplicationController
+ include ScenarioScoping
+
before_action :set_scenario
def show
@@ -18,7 +20,7 @@ def update
private
def set_scenario
- @scenario = Current.user.scenarios.where(organization: Current.organization).find(params[:scenario_id])
+ @scenario = accessible_scenarios.find(params[:scenario_id])
end
def name_params
diff --git a/app/controllers/scenarios/total_giving_amounts_controller.rb b/app/controllers/scenarios/total_giving_amounts_controller.rb
index 631e8f6..54910a9 100644
--- a/app/controllers/scenarios/total_giving_amounts_controller.rb
+++ b/app/controllers/scenarios/total_giving_amounts_controller.rb
@@ -1,4 +1,6 @@
class Scenarios::TotalGivingAmountsController < ApplicationController
+ include ScenarioScoping
+
before_action :set_scenario
def show
@@ -18,7 +20,7 @@ def update
private
def set_scenario
- @scenario = Current.user.scenarios.where(organization: Current.organization).find(params[:scenario_id])
+ @scenario = accessible_scenarios.find(params[:scenario_id])
end
def total_giving_amount_params
diff --git a/app/controllers/scenarios_controller.rb b/app/controllers/scenarios_controller.rb
index 31e186a..f3ff772 100644
--- a/app/controllers/scenarios_controller.rb
+++ b/app/controllers/scenarios_controller.rb
@@ -1,19 +1,21 @@
class ScenariosController < ApplicationController
+ include ScenarioScoping
+
before_action :set_scenario, only: %i[ show update destroy ]
def index
- @scenarios = scenarios.order(created_at: :desc)
+ @scenarios = owned_scenarios.order(created_at: :desc)
end
def show
end
def new
- @scenario = scenarios.new
+ @scenario = owned_scenarios.new
end
def create
- @scenario = scenarios.new(scenario_params)
+ @scenario = owned_scenarios.new(scenario_params)
if @scenario.save
redirect_to scenario_path(@scenario)
else
@@ -30,18 +32,15 @@ def update
end
def destroy
+ on_behalf = viewing_on_behalf?(@scenario)
@scenario.destroy
- redirect_to scenarios_path
+ redirect_to on_behalf ? admin_scenarios_path : scenarios_path
end
private
- def scenarios
- Current.user.scenarios.where(organization: Current.organization)
- end
-
def set_scenario
- @scenario = scenarios.find(params[:id])
+ @scenario = accessible_scenarios.find(params[:id])
end
def scenario_params
diff --git a/app/models/current.rb b/app/models/current.rb
index 68edde0..9f18593 100644
--- a/app/models/current.rb
+++ b/app/models/current.rb
@@ -2,4 +2,8 @@ class Current < ActiveSupport::CurrentAttributes
attribute :session
attribute :organization
delegate :user, to: :session, allow_nil: true
+
+ def organization_membership
+ user&.membership_in(organization)
+ end
end
diff --git a/app/models/organization_membership.rb b/app/models/organization_membership.rb
index 5c641b3..559ae85 100644
--- a/app/models/organization_membership.rb
+++ b/app/models/organization_membership.rb
@@ -6,4 +6,16 @@ class OrganizationMembership < ApplicationRecord
validates :user_id, uniqueness: { scope: :organization_id }
validates :role, presence: true
+
+ def accessible_scenarios
+ if admin? || owner?
+ organization.scenarios
+ else
+ owned_scenarios
+ end
+ end
+
+ def owned_scenarios
+ user.scenarios.where(organization: organization)
+ end
end
diff --git a/app/views/admin/scenarios/index.html.erb b/app/views/admin/scenarios/index.html.erb
index 91ea923..02fa66e 100644
--- a/app/views/admin/scenarios/index.html.erb
+++ b/app/views/admin/scenarios/index.html.erb
@@ -38,9 +38,13 @@
data: { turbo_frame: "_top" },
class: "text-ink hover:text-ink-soft hover:underline" %>
-
<%= scenario.name %> |
+
+ <%= link_to scenario.name, scenario_path(scenario), data: { turbo_frame: "_top" }, class: "block hover:underline" %>
+ |
- <%= scenario.total_giving_amount.present? ? number_to_currency(scenario.total_giving_amount, precision: 0) : content_tag(:span, "—", class: "text-ink-faint") %>
+ <%= link_to scenario_path(scenario), data: { turbo_frame: "_top" }, class: "block" do %>
+ <%= scenario.total_giving_amount.present? ? number_to_currency(scenario.total_giving_amount, precision: 0) : content_tag(:span, "—", class: "text-ink-faint") %>
+ <% end %>
|
<% end %>
diff --git a/app/views/scenarios/show.html.erb b/app/views/scenarios/show.html.erb
index 1c191ee..7c70cec 100644
--- a/app/views/scenarios/show.html.erb
+++ b/app/views/scenarios/show.html.erb
@@ -1,6 +1,17 @@
- <%= link_to "← Back to explore options", scenarios_path,
- class: "inline-block rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink-soft hover:bg-canvas transition" %>
+ <% if viewing_on_behalf?(@scenario) %>
+ <%= link_to "← Back to all scenarios", admin_scenarios_path,
+ class: "inline-block rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink-soft hover:bg-canvas transition" %>
+ <% else %>
+ <%= link_to "← Back to explore options", scenarios_path,
+ class: "inline-block rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink-soft hover:bg-canvas transition" %>
+ <% end %>
+
+ <% if viewing_on_behalf?(@scenario) %>
+
+ Viewing <%= @scenario.user.display_name %>'s scenario as an admin. Changes you make are saved to their account.
+
+ <% end %>
<%= render "scenarios/names/name", scenario: @scenario %>
diff --git a/test/controllers/admin/scenarios_controller_test.rb b/test/controllers/admin/scenarios_controller_test.rb
index 47d9f9d..a19b5fb 100644
--- a/test/controllers/admin/scenarios_controller_test.rb
+++ b/test/controllers/admin/scenarios_controller_test.rb
@@ -34,6 +34,15 @@ class Admin::ScenariosControllerTest < ActionDispatch::IntegrationTest
assert_select "td", text: @admin.display_name
end
+ test "rows link to the scenario and break out of the table turbo frame" do
+ sign_in_as(@owner)
+ get admin_scenarios_path
+
+ # data-turbo-frame="_top" is required so the link navigates the whole page
+ # instead of trying (and failing) to render into the scenarios_table frame.
+ assert_select "a[href=?][data-turbo-frame=_top]", scenario_path(scenarios(:one_arlington))
+ end
+
test "does not show scenarios from another organization" do
sign_in_as(@owner)
get admin_scenarios_path
diff --git a/test/controllers/allocations_controller_test.rb b/test/controllers/allocations_controller_test.rb
index a037dad..754c8ff 100644
--- a/test/controllers/allocations_controller_test.rb
+++ b/test/controllers/allocations_controller_test.rb
@@ -103,4 +103,32 @@ class AllocationsControllerTest < ActionDispatch::IntegrationTest
end
assert_response :not_found
end
+
+ test "admin can add an allocation to another user's scenario in the same org" do
+ sign_in_as users(:admin)
+ assert_difference -> { @scenario.allocations.count }, 1 do
+ post scenario_allocations_url(@scenario), params: {
+ allocation: { type: "Allocation::Ongoing", option: "Population: Youth", percentage: 25 }
+ }
+ end
+ assert_redirected_to scenario_path(@scenario)
+ end
+
+ test "admin can destroy an allocation on another user's scenario in the same org" do
+ sign_in_as users(:admin)
+ assert_difference -> { @scenario.allocations.count }, -1 do
+ delete scenario_allocation_url(@scenario, allocations(:greatest_need))
+ end
+ assert_redirected_to scenario_path(@scenario)
+ end
+
+ test "plain member cannot add an allocation to another user's scenario in the same org" do
+ sign_in_as users(:passwordless)
+ assert_no_difference -> { Allocation.count } do
+ post scenario_allocations_url(@scenario), params: {
+ allocation: { type: "Allocation::Ongoing", option: "X", percentage: 10 }
+ }
+ end
+ assert_response :not_found
+ end
end
diff --git a/test/controllers/scenarios/names_controller_test.rb b/test/controllers/scenarios/names_controller_test.rb
index 937e24b..162fe53 100644
--- a/test/controllers/scenarios/names_controller_test.rb
+++ b/test/controllers/scenarios/names_controller_test.rb
@@ -35,4 +35,20 @@ class Scenarios::NamesControllerTest < ActionDispatch::IntegrationTest
get edit_scenario_name_url(scenarios(:two_boston))
assert_response :not_found
end
+
+ test "admin can edit another user's scenario name in the same org" do
+ sign_in_as users(:admin)
+ get edit_scenario_name_url(@scenario)
+ assert_response :success
+
+ patch scenario_name_url(@scenario), params: { scenario: { name: "Admin renamed" } }
+ assert_response :success
+ assert_equal "Admin renamed", @scenario.reload.name
+ end
+
+ test "plain member cannot edit another user's scenario name in the same org" do
+ sign_in_as users(:passwordless)
+ get edit_scenario_name_url(@scenario)
+ assert_response :not_found
+ end
end
diff --git a/test/controllers/scenarios/total_giving_amounts_controller_test.rb b/test/controllers/scenarios/total_giving_amounts_controller_test.rb
index c74978b..0281475 100644
--- a/test/controllers/scenarios/total_giving_amounts_controller_test.rb
+++ b/test/controllers/scenarios/total_giving_amounts_controller_test.rb
@@ -29,4 +29,20 @@ class Scenarios::TotalGivingAmountsControllerTest < ActionDispatch::IntegrationT
get edit_scenario_total_giving_amount_url(scenarios(:two_boston))
assert_response :not_found
end
+
+ test "admin can edit another user's scenario total in the same org" do
+ sign_in_as users(:admin)
+ get edit_scenario_total_giving_amount_url(@scenario)
+ assert_response :success
+
+ patch scenario_total_giving_amount_url(@scenario), params: { scenario: { total_giving_amount: 7500 } }
+ assert_response :success
+ assert_equal 7500, @scenario.reload.total_giving_amount
+ end
+
+ test "plain member cannot edit another user's scenario total in the same org" do
+ sign_in_as users(:passwordless)
+ get edit_scenario_total_giving_amount_url(@scenario)
+ assert_response :not_found
+ end
end
diff --git a/test/controllers/scenarios_controller_test.rb b/test/controllers/scenarios_controller_test.rb
index 33a63f0..8b2131b 100644
--- a/test/controllers/scenarios_controller_test.rb
+++ b/test/controllers/scenarios_controller_test.rb
@@ -63,4 +63,45 @@ class ScenariosControllerTest < ActionDispatch::IntegrationTest
get scenario_url(scenarios(:two_boston))
assert_response :not_found
end
+
+ test "admin can view another user's scenario in the same org" do
+ sign_in_as users(:admin)
+ get scenario_url(scenarios(:one_arlington))
+ assert_response :success
+ assert_select "a[href=?]", admin_scenarios_path
+ assert_match "as an admin", response.body
+ end
+
+ test "admin can update another user's scenario" do
+ sign_in_as users(:admin)
+ patch scenario_url(scenarios(:one_arlington)), params: { scenario: { total_giving_amount: 999 } }
+ assert_equal 999, scenarios(:one_arlington).reload.total_giving_amount
+ end
+
+ test "admin destroy of another user's scenario returns to the admin dashboard" do
+ sign_in_as users(:admin)
+ assert_difference -> { Scenario.count }, -1 do
+ delete scenario_url(scenarios(:one_arlington))
+ end
+ assert_redirected_to admin_scenarios_path
+ end
+
+ test "admin still cannot reach a scenario in another org" do
+ sign_in_as users(:admin)
+ get scenario_url(scenarios(:two_boston))
+ assert_response :not_found
+ end
+
+ test "plain member cannot reach another user's scenario in the same org" do
+ sign_in_as users(:passwordless)
+ get scenario_url(scenarios(:one_arlington))
+ assert_response :not_found
+ end
+
+ test "super admin cannot change another user's scenario" do
+ sign_in_as users(:super_admin)
+ patch scenario_url(scenarios(:one_arlington)), params: { scenario: { total_giving_amount: 1 } }
+ assert_response :not_found
+ assert_equal 10000, scenarios(:one_arlington).reload.total_giving_amount
+ end
end
diff --git a/test/models/organization_membership_test.rb b/test/models/organization_membership_test.rb
index 7d4b0eb..1f2843d 100644
--- a/test/models/organization_membership_test.rb
+++ b/test/models/organization_membership_test.rb
@@ -37,4 +37,24 @@ class OrganizationMembershipTest < ActiveSupport::TestCase
assert_not membership.valid?
assert_includes membership.errors[:role], "can't be blank"
end
+
+ test "owned_scenarios are the member's own scenarios in the organization" do
+ membership = organization_memberships(:one_arlington)
+ assert_includes membership.owned_scenarios, scenarios(:one_arlington)
+ assert_not_includes membership.owned_scenarios, scenarios(:admin_arlington)
+ end
+
+ test "admins and owners can access any scenario in the organization" do
+ %i[ admin_arlington one_arlington ].each do |fixture|
+ membership = organization_memberships(fixture)
+ assert_includes membership.accessible_scenarios, scenarios(:one_arlington)
+ assert_includes membership.accessible_scenarios, scenarios(:admin_arlington)
+ end
+ end
+
+ test "plain members can only access their own scenarios" do
+ membership = organization_memberships(:passwordless_arlington)
+ assert_equal membership.owned_scenarios.to_a, membership.accessible_scenarios.to_a
+ assert_not_includes membership.accessible_scenarios, scenarios(:one_arlington)
+ end
end