Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Delete an organisation member #668

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
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
6 changes: 3 additions & 3 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ lint:
bin/rubocop --autocorrect

rails +command="console":
docker exec -it site-web-1 bundle exec rails {{ command }}
docker exec -it tramline-web-1 bundle exec rails {{ command }}

rake +command:
docker exec -it site-web-1 bundle exec rake {{ command }}
docker exec -it tramline-web-1 bundle exec rake {{ command }}

bundle +command:
docker exec -it site-web-1 bundle {{ command }}
docker exec -it tramline-web-1 bundle {{ command }}

devlog log_lines="1000":
tail -f -n {{ log_lines }} log/development.log
Expand Down
176 changes: 80 additions & 96 deletions app/components/v2/header_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,122 +6,114 @@
<%= inline_svg("tramline.svg", classname: "inline-flex w-12") %>
</a>
</div>

<div class="flex flex-wrap justify-between items-center py-3 px-4 pb-5 sm:pb-3 lg:px-6">
<div class="flex items-center mb-2 sm:mb-0 gap-x-2">
<%= render V2::ButtonComponent.new(scheme: :light,
<% if current_organization %>
<%= render V2::ButtonComponent.new(scheme: :light,
options: edit_accounts_organization_path(current_organization),
type: :link,
size: :xxs) do |b| %>
<% b.with_tooltip("Organization Settings") %>
<% b.with_icon("v2/building.svg", size: :md) %>
<% end %>

<%= render V2::DropdownComponent.new(authz: false) do |dropdown| %>
<% button = dropdown.with_button(size: :xs) %>
<% button.with_icon("art/org_default.png", size: :xxl) %>
<% button.with_title_text do %>
<div class="text-left">
<div class="text-sm font-semibold leading-none text-main dark:text-white">
<%= current_organization.name %>
</div>
</div>
<% b.with_tooltip("Organization Settings") %>
<% b.with_icon("v2/building.svg", size: :md) %>
<% end %>

<% dropdown.with_item_group do |group| %>
<% current_user.organizations.each do |organization| %>
<% group.with_item(link: { path: switch_accounts_organization_path(organization), "data-turbo": false },
selected: organization.id == current_organization.id) do %>
<div class="text-left">
<div class="font-medium leading-none mb-0.5 text-sm">
<%= organization.name %>
</div>

<div class="text-xs text-secondary dark:text-secondary-50">
Created <%= time_format organization.created_at, with_year: true, with_time: false %>
</div>
</div>
<% end %>
<% end %>
<% end %>
<% end %>

<% if default_app %>
<%= inline_svg("layer_separator.svg", classname: "w-3 h-3 text-secondary dark:text-secondary-50") %>

<%= render V2::DropdownComponent.new(authz: false) do |dropdown| %>
<% button = dropdown.with_button(size: :xs) %>
<% button.with_icon("art/cross_platform_default.png", size: :xxl, rounded: false) %>
<% button.with_icon("art/org_default.png", size: :xxl) %>
<% button.with_title_text do %>
<div class="flex items-center justify-center gap-x-1 text-sm font-semibold leading-none text-main dark:text-white">
<%= render V2::IconComponent.new("v2/#{default_app.platform}.svg", size: :md) %>
<%= default_app.name %>
<span class="text-sm text-secondary dark:text-secondary-50">
<%= default_app.bundle_identifier %>
</span>
<div class="text-left">
<div class="text-sm font-semibold leading-none text-main dark:text-white">
<%= current_organization.name %>
</div>
</div>
<% end %>

<% dropdown.with_item_group do |group| %>
<% current_organization.apps.filter(&:persisted?).each do |app| %>
<% group.with_item(link: { path: app_path(app) }, selected: app.id == default_app.id) do %>
<% current_user.organizations.each do |organization| %>
<% group.with_item(link: { path: switch_accounts_organization_path(organization), "data-turbo": false },
selected: organization.id == current_organization.id) do %>
<div class="text-left">
<div class="font-medium leading-none mb-0.5 text-sm">
<%= app.name %>
</div>
<div class="text-xs text-secondary dark:text-secondary-50">
<%= app.bundle_identifier %>
<%= organization.name %>
</div>
<div class="text-xs text-secondary dark:text-secondary-50">
<%= app.display_attr(:platform) %>
Created <%= time_format organization.created_at, with_year: true, with_time: false %>
</div>
</div>
<% end %>
<% end %>
<% end %>
<% end %>


<%= render V2::ButtonComponent.new(scheme: :light,
options: edit_app_path(default_app),
type: :link,
size: :xxs) do |b| %>
<% b.with_tooltip("App Settings") %>
<% b.with_icon("v2/cog.svg", size: :md) %>
<% end %>

<%= render V2::ButtonComponent.new(scheme: :light,
options: all_builds_app_path(default_app),
type: :link,
size: :xxs,
authz: false) do |b| %>
<% b.with_tooltip("All Builds") %>
<% b.with_icon("v2/drill.svg", size: :md) %>
<% end %>

<%= render V2::ModalComponent.new(title: "Store Status", size: :xs, authz: false) do |modal| %>
<% button = modal.with_button(scheme: :light, type: :action, size: :xxs, arrow: :none) %>
<% button.with_tooltip("Store Status") %>
<% button.with_icon("v2/upload_cloud.svg", size: :md) %>
<% modal.with_body do %>
<%= render V2::ExternalAppComponent.new(app: default_app) %>
<% if default_app %>
<%= inline_svg("layer_separator.svg", classname: "w-3 h-3 text-secondary dark:text-secondary-50") %>
<%= render V2::DropdownComponent.new(authz: false) do |dropdown| %>
<% button = dropdown.with_button(size: :xs) %>
<% button.with_icon("art/cross_platform_default.png", size: :xxl, rounded: false) %>
<% button.with_title_text do %>
<div class="flex items-center justify-center gap-x-1 text-sm font-semibold leading-none text-main dark:text-white">
<%= render V2::IconComponent.new("v2/#{default_app.platform}.svg", size: :md) %>
<%= default_app.name %>
<span class="text-sm text-secondary dark:text-secondary-50">
<%= default_app.bundle_identifier %>
</span>
</div>
<% end %>
<% dropdown.with_item_group do |group| %>
<% current_organization.apps.filter(&:persisted?).each do |app| %>
<% group.with_item(link: { path: app_path(app) }, selected: app.id == default_app.id) do %>
<div class="text-left">
<div class="font-medium leading-none mb-0.5 text-sm">
<%= app.name %>
</div>
<div class="text-xs text-secondary dark:text-secondary-50">
<%= app.bundle_identifier %>
</div>
<div class="text-xs text-secondary dark:text-secondary-50">
<%= app.display_attr(:platform) %>
</div>
</div>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>

<span class="mx-2 w-px h-5 bg-main-200 dark:bg-main-600"></span>

<%= render V2::ModalComponent.new(title: "Add a new app", dismissable: false) do |modal| %>
<% mb = modal.with_button(scheme: :light, type: :action, size: :xxs) %>
<% mb.with_tooltip("Add a new app") %>
<% mb.with_icon("plus.svg", size: :md) %>
<% modal.with_body do %>
<%= render partial: "apps/form", locals: { app: new_app } %>
<%= render V2::ButtonComponent.new(scheme: :light,
options: edit_app_path(default_app),
type: :link,
size: :xxs) do |b| %>
<% b.with_tooltip("App Settings") %>
<% b.with_icon("v2/cog.svg", size: :md) %>
<% end %>
<%= render V2::ButtonComponent.new(scheme: :light,
options: all_builds_app_path(default_app),
type: :link,
size: :xxs,
authz: false) do |b| %>
<% b.with_tooltip("All Builds") %>
<% b.with_icon("v2/drill.svg", size: :md) %>
<% end %>
<%= render V2::ModalComponent.new(title: "Store Status", size: :xs, authz: false) do |modal| %>
<% button = modal.with_button(scheme: :light, type: :action, size: :xxs, arrow: :none) %>
<% button.with_tooltip("Store Status") %>
<% button.with_icon("v2/upload_cloud.svg", size: :md) %>
<% modal.with_body do %>
<%= render V2::ExternalAppComponent.new(app: default_app) %>
<% end %>
<% end %>
<span class="mx-2 w-px h-5 bg-main-200 dark:bg-main-600"></span>
<%= render V2::ModalComponent.new(title: "Add a new app", dismissable: false) do |modal| %>
<% mb = modal.with_button(scheme: :light, type: :action, size: :xxs) %>
<% mb.with_tooltip("Add a new app") %>
<% mb.with_icon("plus.svg", size: :md) %>
<% modal.with_body do %>
<%= render partial: "apps/form", locals: { app: new_app } %>
<% end %>
<% end %>
<% end %>
<% else %>
<div class="text-sm text-secondary dark:text-secondary-50 px-4">
You are not a member of any organization
</div>
<% end %>
</div>
</div>

<div class="flex justify-end items-center lg:order-2">
<%= render V2::ButtonComponent.new(
scheme: :naked_icon,
Expand All @@ -132,9 +124,7 @@
b.with_tooltip("Read the docs")
b.with_icon("book.svg", rounded: false)
end %>

<span class="mx-4 w-px h-5 bg-main-200 dark:bg-main-600"></span>

<%= render V2::DropdownComponent.new(authz: false) do |dropdown| %>
<% dropdown.with_button(size: :none, html_options: { class: "flex" }, scheme: :avatar_icon)
.with_icon(user_avatar(user_full_name), raw_svg: true, size: :xl_3) %>
Expand All @@ -143,31 +133,26 @@
<div class="text-sm text-secondary dark:text-secondary-50">
<%= user_name %>
</div>

<div class="text-sm truncate font-semibold text-main-600 dark:text-secondary-50">
<%= user_email %>
</div>
</div>
<% end %>

<% dropdown.with_item_group(list_style: "text-secondary dark:text-secondary-50") do |group| %>
<% group.with_item(link: { path: edit_accounts_user_path, "data-turbo": false }) do %>
Settings
<% end %>
<% end %>

<% dropdown.with_item_group(list_style: "text-secondary dark:text-secondary-50") do |group| %>
<% if billing? %>
<% group.with_item(link: { path: billing_link, external: true }) do %>
Go to billing ↗
<% end %>
<% end %>

<% group.with_item(link: { path: "https://calendar.app.google/bs6wimzo316W3yKz9", external: true }) do %>
Book a demo ↗
<% end %>
<% end %>

<% dropdown.with_item_group(list_style: "text-secondary dark:text-secondary-50") do |group| %>
<% group.with_item(link: { path: logout_path, "data-turbo": false }) do %>
Sign out
Expand All @@ -177,6 +162,5 @@
</div>
</div>
</nav>

<%= sticky_message %>
</header>
25 changes: 25 additions & 0 deletions app/controllers/accounts/memberships_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
class Accounts::MembershipsController < SignedInApplicationController
before_action :require_write_access!, only: %i[destroy]

def destroy
@membership = current_organization.memberships.find_by(id: params[:id])

unless helpers.can_current_user_remove_member?(@membership.user)
redirect_to teams_accounts_organization_path(current_organization),
flash: { error: "You don't have permission to remove this member" }
return
end

if @membership.discarded?
redirect_to teams_accounts_organization_path(current_organization),
flash: { error: "Member was already removed" }
elsif @membership.discard
puts "MembershipsController inside foo destroy"
redirect_to teams_accounts_organization_path(current_organization),
notice: "Member #{@membership.user.email} has been removed"
else
redirect_to teams_accounts_organization_path(current_organization),
flash: {error: "#{@membership.errors.full_messages.to_sentence}"}
end
end
end
1 change: 1 addition & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def current_organization
else
current_user&.organizations&.first
end
@current_organization if current_user&.organizations&.any?
end

def current_user
Expand Down
11 changes: 9 additions & 2 deletions app/controllers/signed_in_application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class SignedInApplicationController < ApplicationController
Action = Coordinators::Actions

layout -> { ensure_supported_layout("signed_in_application") }

before_action :require_organization!
before_action :authenticate_sso_request!, if: :sso_authentication_signed_in?
before_action :turbo_frame_request_variant
before_action :set_currents
Expand Down Expand Up @@ -144,7 +144,7 @@ def default_app
end

def new_app
current_organization.apps.new
current_organization&.apps&.new
end

def vcs_provider_logo
Expand Down Expand Up @@ -186,4 +186,11 @@ def v2?
def stream_flash
turbo_stream.update("flash_stream", V2::FlashComponent.new(flash))
end

def require_organization!
unless current_organization.present?
flash.now[:error] = "You are not a member of any organization"
render template: 'shared/no_organization', status: :unauthorized and return
end
end
end
6 changes: 6 additions & 0 deletions app/helpers/organizations_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ def can_current_user_edit_role?(member)
def available_roles_for_member(member_role)
(member_role == "viewer") ? Accounts::Membership.allowed_roles : Accounts::Membership.all_roles
end

def can_current_user_remove_member?(member)
return false if current_user.id == member.id # Users can't remove themselves
return false if current_user_role != Accounts::Membership.roles[:owner]
true
end
end
13 changes: 13 additions & 0 deletions app/models/accounts/membership.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Table name: memberships
#
# id :uuid not null, primary key
# discarded_at :datetime indexed
# role :string not null, indexed, indexed => [user_id, organization_id]
# created_at :datetime not null
# updated_at :datetime not null
Expand All @@ -11,6 +12,7 @@
# user_id :uuid indexed => [organization_id, role]
#
class Accounts::Membership < ApplicationRecord
include Discard::Model
include Roleable
has_paper_trail

Expand All @@ -22,6 +24,17 @@ class Accounts::Membership < ApplicationRecord
validate :team_can_only_be_set_once
validate :valid_role_change, on: :update

before_discard :ensure_at_least_one_owner_remains

def ensure_at_least_one_owner_remains
return true unless role == Accounts::Membership.roles[:owner]

remaining_owners = organization.memberships.kept.where(role: Accounts::Membership.roles[:owner]).where.not(id: id)
if remaining_owners.empty?
errors.add(:base, "Organization must have at least one owner")
end
end

def self.allowed_roles
roles.except(:owner).transform_keys(&:titleize).to_a
end
Expand Down
2 changes: 1 addition & 1 deletion app/models/accounts/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class Accounts::Organization < ApplicationRecord
extend FriendlyId
has_paper_trail

has_many :memberships, dependent: :delete_all, inverse_of: :organization
has_many :memberships, -> { kept }, dependent: :delete_all, inverse_of: :organization
has_many :teams, -> { sequential }, dependent: :delete_all, inverse_of: :organization
has_many :users, through: :memberships, dependent: :delete_all
has_many :apps, -> { sequential }, dependent: :destroy, inverse_of: :organization
Expand Down
Loading