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
12 changes: 12 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,15 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
# (Omakase defaults this to `indented_internal_methods`.)
Layout/IndentationConsistency:
EnforcedStyle: normal

# Define nested classes/modules with a compact `class Foo::Bar` declaration.
# Rails-generated boot/framework boilerplate is excluded: it relies on the nested
# form to define its namespace (e.g. config/application.rb would fail to boot as
# `class CommunityFoundations::Application` since the module is defined nowhere else).
Style/ClassAndModuleChildren:
Enabled: true
EnforcedStyle: compact
Exclude:
- config/application.rb
- app/channels/application_cable/connection.rb
- test/test_helper.rb
25 changes: 25 additions & 0 deletions app/controllers/organization_memberships_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
class OrganizationMembershipsController < ApplicationController
before_action :require_member_management
before_action :set_membership, only: :update

def index
@memberships = Current.organization.organization_memberships
.includes(:user).order(:created_at)
end

def update
OrganizationMembership::RoleUpdater.new(@membership, actor: Current.user, role: params[:role]).call
redirect_to organization_memberships_path
end

private

def set_membership
@membership = Current.organization.organization_memberships.find(params[:id])
end

def require_member_management
redirect_to root_path, alert: "You don't have access to that." unless
Current.user.admin_of?(Current.organization)
end
end
3 changes: 3 additions & 0 deletions app/models/organization_membership.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@ class OrganizationMembership < ApplicationRecord
belongs_to :user
belongs_to :organization

enum :role, { member: "member", admin: "admin", owner: "owner" }, default: :member

validates :user_id, uniqueness: { scope: :organization_id }
validates :role, presence: true
end
34 changes: 34 additions & 0 deletions app/models/organization_membership/role_updater.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class OrganizationMembership::RoleUpdater
ASSIGNABLE_ROLES = %w[ admin member ].freeze

Result = Data.define(:status) do
def updated? = status == :updated
def forbidden? = status == :forbidden
def rejected? = status == :rejected
def success? = updated?
end

def initialize(membership, actor:, role:)
@membership = membership
@actor = actor
@role = role
end

def call
return Result.new(:rejected) if @membership.owner? || ASSIGNABLE_ROLES.exclude?(@role)
return Result.new(:forbidden) if demotion? && !actor_owner?

@membership.update(role: @role)
Result.new(:updated)
end

private

def demotion?
@role == "member"
end

def actor_owner?
@actor.owner_of?(@membership.organization)
end
end
13 changes: 13 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ def member_of?(organization)
organization && organizations.exists?(organization.id)
end

def membership_in(organization)
organization && organization_memberships.find_by(organization_id: organization.id)
end

def admin_of?(organization)
membership = membership_in(organization)
membership&.admin? || membership&.owner?
end

def owner_of?(organization)
membership_in(organization)&.owner?
end

def password_set?
password_digest.present?
end
Expand Down
3 changes: 3 additions & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
<%= link_to "Mailer previews", "/rails/mailers", class: "text-ink-soft hover:text-ink" %>
<% end %>
<% if authenticated? %>
<% if Current.user&.admin_of?(Current.organization) %>
<%= link_to "Members", organization_memberships_path, class: "text-ink-soft hover:text-ink" %>
<% end %>
<%= link_to "Settings", users_password_path, class: "text-ink-soft hover:text-ink" %>
<%= button_to "Sign out", session_path, method: :delete, class: "text-ink-soft hover:text-ink cursor-pointer" %>
<% end %>
Expand Down
38 changes: 38 additions & 0 deletions app/views/organization_memberships/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<% content_for :title, "Members" %>

<div class="mx-auto max-w-4xl w-full px-4 py-12">
<div>
<h1 class="font-serif font-medium text-4xl tracking-tight text-ink">Members</h1>
<p class="mt-3 max-w-xl text-ink-soft">
Manage who belongs to <%= Current.organization.name %>. Admins and owners can promote members
to admins; only owners can demote admins.
</p>
</div>

<% owner = Current.user.owner_of?(Current.organization) %>

<div class="mt-10 overflow-hidden rounded-2xl border border-line bg-surface shadow-sm">
<ul class="divide-y divide-line">
<% @memberships.each do |membership| %>
<li class="flex items-center justify-between gap-4 px-5 py-4">
<div class="min-w-0">
<p class="truncate text-ink"><%= membership.user.email_address %></p>
<span class="mt-1 inline-block rounded-full border border-line px-2 py-0.5 text-xs text-ink-soft">
<%= membership.role.capitalize %>
</span>
</div>

<div class="flex shrink-0 items-center gap-3 text-sm">
<% if membership.member? %>
<%= button_to "Make admin", organization_membership_path(membership), method: :patch, params: { role: "admin" },
class: "text-brand hover:underline cursor-pointer bg-transparent p-0" %>
<% elsif membership.admin? && owner %>
<%= button_to "Make member", organization_membership_path(membership), method: :patch, params: { role: "member" },
class: "text-ink-soft hover:underline cursor-pointer bg-transparent p-0" %>
<% end %>
</div>
</li>
<% end %>
</ul>
</div>
</div>
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
# Dev-only convenience: sign in as the first user without a password
get "auto_sign_in", to: "auto_sign_in#create" if Rails.env.development?

resources :organization_memberships, only: %i[ index update ]

resources :scenarios do
resource :name, only: %i[ show edit update ], module: :scenarios
resources :allocations, only: %i[ create update destroy ]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddRoleToOrganizationMemberships < ActiveRecord::Migration[8.1]
def change
add_column :organization_memberships, :role, :string, null: false, default: "member"
end
end
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 14 additions & 8 deletions db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,25 @@
# MovieGenre.find_or_create_by!(name: genre_name)
# end

user = User.find_or_initialize_by(email_address: "user@example.com")
user.password = "password"
user.confirmed_at = Time.current
user.save!

arlington = Organization.find_or_create_by!(subdomain: "arlington") do |org|
org.name = "Arlington Community Foundation"
org.website = "https://www.arlcf.org/"
end

OrganizationMembership.find_or_create_by!(user: user, organization: arlington)
# One user per role, all members of arlington.
%i[ owner admin member ].each do |role|
user = User.find_or_initialize_by(email_address: "#{role}@example.com")
user.password = "password"
user.confirmed_at = Time.current
user.save!

membership = OrganizationMembership.find_or_create_by!(user: user, organization: arlington)
membership.update!(role: role)
end

owner = User.find_by!(email_address: "owner@example.com")

balanced = user.scenarios.find_or_create_by!(organization: arlington, name: "Balanced giving") do |scenario|
balanced = owner.scenarios.find_or_create_by!(organization: arlington, name: "Balanced giving") do |scenario|
scenario.total_giving_amount = 10_000
end

Expand All @@ -31,7 +37,7 @@
balanced.one_time_allocations.create!(option: "Specific org", amount: 1_000)
end

education = user.scenarios.find_or_create_by!(organization: arlington, name: "Education focus") do |scenario|
education = owner.scenarios.find_or_create_by!(organization: arlington, name: "Education focus") do |scenario|
scenario.total_giving_amount = 5_000
end

Expand Down
79 changes: 79 additions & 0 deletions test/controllers/organization_memberships_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
require "test_helper"

class OrganizationMembershipsControllerTest < ActionDispatch::IntegrationTest
setup do
host! "arlington.localhost"
@owner = users(:one)
@admin = users(:admin)
@member = users(:passwordless)
@owner_membership = organization_memberships(:one_arlington)
@admin_membership = organization_memberships(:admin_arlington)
@member_membership = organization_memberships(:passwordless_arlington)
end

# index

test "owners and admins can view the members page" do
sign_in_as(@owner)
get organization_memberships_path
assert_response :success

sign_in_as(@admin)
get organization_memberships_path
assert_response :success
end

test "plain members are redirected away from the members page" do
sign_in_as(@member)
get organization_memberships_path
assert_redirected_to root_path
end

# update — promote

test "an admin can promote a member to admin" do
sign_in_as(@admin)
patch organization_membership_path(@member_membership), params: { role: "admin" }
assert_redirected_to organization_memberships_path
assert @member_membership.reload.admin?
end

test "a member cannot promote anyone" do
sign_in_as(@member)
patch organization_membership_path(@admin_membership), params: { role: "admin" }
assert_redirected_to root_path
assert @admin_membership.reload.admin?
end

# update — demote

test "an owner can demote an admin to member" do
sign_in_as(@owner)
patch organization_membership_path(@admin_membership), params: { role: "member" }
assert_redirected_to organization_memberships_path
assert @admin_membership.reload.member?
end

test "an admin cannot demote another admin" do
sign_in_as(@admin)
patch organization_membership_path(@admin_membership), params: { role: "member" }
assert_redirected_to organization_memberships_path
assert @admin_membership.reload.admin?
end

# update — owner rows and roles are protected

test "owners cannot be changed via update" do
sign_in_as(@owner)
patch organization_membership_path(@owner_membership), params: { role: "member" }
assert_redirected_to organization_memberships_path
assert @owner_membership.reload.owner?
end

test "owner is not an assignable role" do
sign_in_as(@owner)
patch organization_membership_path(@member_membership), params: { role: "owner" }
assert_redirected_to organization_memberships_path
assert @member_membership.reload.member?
end
end
8 changes: 8 additions & 0 deletions test/fixtures/organization_memberships.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
one_arlington:
user: one
organization: arlington
role: owner

admin_arlington:
user: admin
organization: arlington
role: admin

two_boston:
user: two
organization: boston
role: owner

passwordless_arlington:
user: passwordless
organization: arlington
role: member
5 changes: 5 additions & 0 deletions test/fixtures/users.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ unconfirmed:
password_digest: <%= password_digest %>
confirmed_at: <%= nil %>

admin:
email_address: admin@example.com
password_digest: <%= password_digest %>
confirmed_at: <%= Time.current.to_fs(:db) %>

# Passwordless (magic-link) member of arlington — no password_digest.
passwordless:
email_address: passwordless@example.com
Expand Down
49 changes: 49 additions & 0 deletions test/models/organization_membership/role_updater_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
require "test_helper"

class OrganizationMembership::RoleUpdaterTest < ActiveSupport::TestCase
setup do
@owner = users(:one)
@admin = users(:admin)
@member_membership = organization_memberships(:passwordless_arlington)
@admin_membership = organization_memberships(:admin_arlington)
@owner_membership = organization_memberships(:one_arlington)
end

test "an admin can promote a member to admin" do
result = OrganizationMembership::RoleUpdater.new(@member_membership, actor: @admin, role: "admin").call
assert result.updated?
assert result.success?
assert @member_membership.reload.admin?
end

test "an owner can demote an admin to member" do
result = OrganizationMembership::RoleUpdater.new(@admin_membership, actor: @owner, role: "member").call
assert result.updated?
assert @admin_membership.reload.member?
end

test "an admin cannot demote and the role is unchanged" do
result = OrganizationMembership::RoleUpdater.new(@admin_membership, actor: @admin, role: "member").call
assert result.forbidden?
assert_not result.success?
assert @admin_membership.reload.admin?
end

test "owner rows are rejected" do
result = OrganizationMembership::RoleUpdater.new(@owner_membership, actor: @owner, role: "member").call
assert result.rejected?
assert @owner_membership.reload.owner?
end

test "owner is not an assignable role" do
result = OrganizationMembership::RoleUpdater.new(@member_membership, actor: @owner, role: "owner").call
assert result.rejected?
assert @member_membership.reload.member?
end

test "unknown roles are rejected" do
result = OrganizationMembership::RoleUpdater.new(@member_membership, actor: @owner, role: "wizard").call
assert result.rejected?
assert @member_membership.reload.member?
end
end
Loading
Loading