diff --git a/Justfile b/Justfile index b6514a4a2..068f2fb4a 100644 --- a/Justfile +++ b/Justfile @@ -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 diff --git a/app/components/v2/header_component.html.erb b/app/components/v2/header_component.html.erb index 2fad95f18..2bca1fa44 100644 --- a/app/components/v2/header_component.html.erb +++ b/app/components/v2/header_component.html.erb @@ -6,122 +6,114 @@ <%= inline_svg("tramline.svg", classname: "inline-flex w-12") %> -
- <%= 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 %> -
-
- <%= current_organization.name %> -
-
+ <% 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 %> -
-
- <%= organization.name %> -
- -
- Created <%= time_format organization.created_at, with_year: true, with_time: false %> -
-
- <% 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 %> -
- <%= render V2::IconComponent.new("v2/#{default_app.platform}.svg", size: :md) %> - <%= default_app.name %> - - <%= default_app.bundle_identifier %> - +
+
+ <%= current_organization.name %> +
<% 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 %>
- <%= app.name %> -
-
- <%= app.bundle_identifier %> + <%= organization.name %>
- <%= app.display_attr(:platform) %> + Created <%= time_format organization.created_at, with_year: true, with_time: false %>
<% 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 %> +
+ <%= render V2::IconComponent.new("v2/#{default_app.platform}.svg", size: :md) %> + <%= default_app.name %> + + <%= default_app.bundle_identifier %> + +
+ <% 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 %> +
+
+ <%= app.name %> +
+
+ <%= app.bundle_identifier %> +
+
+ <%= app.display_attr(:platform) %> +
+
+ <% end %> + <% end %> + <% end %> <% end %> - <% end %> - - - - <%= 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 %> + + <%= 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 %> +
+ You are not a member of any organization +
<% end %>
-
<%= render V2::ButtonComponent.new( scheme: :naked_icon, @@ -132,9 +124,7 @@ b.with_tooltip("Read the docs") b.with_icon("book.svg", rounded: false) end %> - - <%= 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) %> @@ -143,31 +133,26 @@
<%= user_name %>
-
<%= user_email %>
<% 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 @@ -177,6 +162,5 @@
- <%= sticky_message %> diff --git a/app/controllers/accounts/memberships_controller.rb b/app/controllers/accounts/memberships_controller.rb new file mode 100644 index 000000000..b666344b7 --- /dev/null +++ b/app/controllers/accounts/memberships_controller.rb @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 114cc2bb6..c883be4e5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -34,6 +34,7 @@ def current_organization else current_user&.organizations&.first end + @current_organization if current_user&.organizations&.any? end def current_user diff --git a/app/controllers/signed_in_application_controller.rb b/app/controllers/signed_in_application_controller.rb index 14718b52a..d7b1d6b36 100644 --- a/app/controllers/signed_in_application_controller.rb +++ b/app/controllers/signed_in_application_controller.rb @@ -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 @@ -144,7 +144,7 @@ def default_app end def new_app - current_organization.apps.new + current_organization&.apps&.new end def vcs_provider_logo @@ -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 diff --git a/app/helpers/organizations_helper.rb b/app/helpers/organizations_helper.rb index 2c0bbdace..78138b4cc 100644 --- a/app/helpers/organizations_helper.rb +++ b/app/helpers/organizations_helper.rb @@ -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 diff --git a/app/models/accounts/membership.rb b/app/models/accounts/membership.rb index cf4a8990d..cd5d05444 100644 --- a/app/models/accounts/membership.rb +++ b/app/models/accounts/membership.rb @@ -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 @@ -11,6 +12,7 @@ # user_id :uuid indexed => [organization_id, role] # class Accounts::Membership < ApplicationRecord + include Discard::Model include Roleable has_paper_trail @@ -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 diff --git a/app/models/accounts/organization.rb b/app/models/accounts/organization.rb index 1dbbeb653..f0cd5b1a3 100644 --- a/app/models/accounts/organization.rb +++ b/app/models/accounts/organization.rb @@ -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 diff --git a/app/models/accounts/user.rb b/app/models/accounts/user.rb index 156adfe61..d8024be56 100644 --- a/app/models/accounts/user.rb +++ b/app/models/accounts/user.rb @@ -23,7 +23,7 @@ class Accounts::User < ApplicationRecord validates :preferred_name, length: {maximum: 70, message: :too_long} validates :unique_authn_id, uniqueness: {message: :already_taken, case_sensitive: false} - has_many :memberships, dependent: :delete_all, inverse_of: :user + has_many :memberships, -> { kept }, dependent: :delete_all, inverse_of: :user has_many :organizations, -> { where(status: :active).sequential }, through: :memberships has_many :all_organizations, through: :memberships, source: :organization has_many :sent_invites, class_name: "Invite", foreign_key: "sender_id", inverse_of: :sender, dependent: :destroy @@ -159,7 +159,11 @@ def add_via_email(invite) end def role_for(organization) - access_for(organization).role + access_for(organization)&.role + end + + def membership_for(organization) + access_for(organization) end def team_for(organization) @@ -168,11 +172,11 @@ def team_for(organization) # TODO: [nplus1] def writer_for?(organization) - access_for(organization).writer? + access_for(organization)&.writer? end def owner_for?(organization) - access_for(organization).owner? + access_for(organization)&.owner? end def successful_invite_for(organization) diff --git a/app/views/accounts/organizations/_teams.html.erb b/app/views/accounts/organizations/_teams.html.erb index 07c5af318..44455bc0d 100644 --- a/app/views/accounts/organizations/_teams.html.erb +++ b/app/views/accounts/organizations/_teams.html.erb @@ -102,9 +102,9 @@ <% end %> <% user_role = current_user_role %> <% member_role = member.role_for(current_organization) %> - <% if can_current_user_edit_role?(member) %> - <% row.with_cell(wrap: true) do %> -
+ <% row.with_cell(wrap: true) do %> +
+ <% if can_current_user_edit_role?(member) %> <%= render V2::ModalComponent.new(title: "Edit role") do |modal| %> <% modal.with_button(scheme: :light, type: :action, @@ -114,13 +114,22 @@ <% modal.with_body do %> <%= render partial: "accounts/invitations/edit_form", locals: { email: member.email, member_role: member_role } %> <% end %> -
<% end %> - <% end %> + <% end %> + <% if can_current_user_remove_member?(member) %> + <%= render V2::ButtonComponent.new( + scheme: :danger, + type: :button, + size: :xxs, + options: accounts_organization_membership_path(current_organization, member.membership_for(current_organization)), + html_options: { method: :delete, data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to remove #{member.email} from the organization?" } }) do |b| + b.with_icon("v2/trash.svg") + end %> + <% end %> +
<% end %> <% end %> <% end %> - <% @invited_users.each do |invite| %> <% table.with_row do |row| %> <% row.with_cell do %> diff --git a/app/views/layouts/signed_in_application.html.erb b/app/views/layouts/signed_in_application.html.erb index bbed2fea6..04215f917 100644 --- a/app/views/layouts/signed_in_application.html.erb +++ b/app/views/layouts/signed_in_application.html.erb @@ -1,40 +1,4 @@ - - <%= render partial: "shared/favicon" %> - <%= current_organization.name %> | Tramline - - <%= csrf_meta_tags %> - <%= csp_meta_tag %> - - - - - - - <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> - <%= javascript_importmap_tags %> - - "/> - - - - -
-
- <%= render V2::HeaderComponent.new %> - -
- <%= yield :sticky_top_message %> -
-
<%= render V2::FlashComponent.new(flash) %>
-
<%= yield :error_resource %>
- <%= yield %> -
-
- - <%= render partial: "shared/footer" %> -
-
- + <%= current_organization ? "#{current_organization.name} | Tramline" : "Tramline" %> diff --git a/app/views/shared/no_organization.html.erb b/app/views/shared/no_organization.html.erb new file mode 100644 index 000000000..6b2fa46cf --- /dev/null +++ b/app/views/shared/no_organization.html.erb @@ -0,0 +1,8 @@ +
+
+

Oops! Looks like you don't have access to any organization.

+
+

Please contact your administrator to create a new organization to continue.

+
+
+
diff --git a/compose.yml b/compose.yml index bea32b985..e454e3d48 100644 --- a/compose.yml +++ b/compose.yml @@ -11,6 +11,7 @@ x-app: &app tmpfs: - /tmp - /app/tmp/pids + - /rails/tmp/pids x-backend: &backend <<: *app diff --git a/config/routes.rb b/config/routes.rb index 2a3a2f2ab..a21383dda 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -50,6 +50,7 @@ resources :teams, only: %i[create update destroy] resources :invitations, only: [:create] + resources :memberships, only: [:destroy] end resource :user, only: [:edit, :update] do diff --git a/db/migrate/20241102204836_add_discarded_at_to_memberships.rb b/db/migrate/20241102204836_add_discarded_at_to_memberships.rb new file mode 100644 index 000000000..fe7746a16 --- /dev/null +++ b/db/migrate/20241102204836_add_discarded_at_to_memberships.rb @@ -0,0 +1,8 @@ +class AddDiscardedAtToMemberships < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_column :memberships, :discarded_at, :datetime + add_index :memberships, :discarded_at, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index f62c5f4c5..56448b515 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_10_28_064604) do +ActiveRecord::Schema[7.2].define(version: 2024_11_02_204836) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -397,6 +397,8 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "team_id" + t.datetime "discarded_at" + t.index ["discarded_at"], name: "index_memberships_on_discarded_at" t.index ["organization_id"], name: "index_memberships_on_organization_id" t.index ["role"], name: "index_memberships_on_role" t.index ["user_id", "organization_id", "role"], name: "index_memberships_on_user_id_and_organization_id_and_role", unique: true