organizations 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/.simplecov +17 -10
  5. data/Appraisals +2 -0
  6. data/CHANGELOG.md +112 -0
  7. data/README.md +714 -32
  8. data/app/controllers/organizations/invitations_controller.rb +27 -22
  9. data/app/controllers/organizations/memberships_controller.rb +37 -30
  10. data/app/controllers/organizations/organizations_controller.rb +8 -8
  11. data/app/controllers/organizations/public_invitations_controller.rb +1 -1
  12. data/app/mailers/organizations/invitation_mailer.rb +2 -2
  13. data/app/mailers/organizations/verification_mailer.rb +1 -1
  14. data/app/models/organizations/grant.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
  19. data/config/locales/en.yml +22 -0
  20. data/config/locales/es.yml +22 -0
  21. data/config/routes.rb +1 -2
  22. data/gemfiles/rails_7.2.gemfile +6 -1
  23. data/gemfiles/rails_8.1.gemfile +5 -1
  24. data/lib/generators/organizations/grants/grants_generator.rb +47 -0
  25. data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
  26. data/lib/generators/organizations/install/install_generator.rb +1 -0
  27. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
  28. data/lib/generators/organizations/join/join_generator.rb +113 -0
  29. data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
  30. data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
  31. data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
  32. data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
  33. data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
  34. data/lib/generators/organizations/join/templates/routes.rb +19 -0
  35. data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
  36. data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
  37. data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
  38. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
  39. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
  40. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
  41. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
  42. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
  43. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
  44. data/lib/generators/organizations/views/views_generator.rb +4 -6
  45. data/lib/organizations/account_deletion_plan.rb +118 -0
  46. data/lib/organizations/configuration.rb +155 -2
  47. data/lib/organizations/controller_helpers.rb +55 -15
  48. data/lib/organizations/grant_access.rb +75 -0
  49. data/lib/organizations/grant_holder.rb +152 -0
  50. data/lib/organizations/grantable.rb +28 -0
  51. data/lib/organizations/join_flow.rb +16 -12
  52. data/lib/organizations/models/concerns/has_organizations.rb +217 -9
  53. data/lib/organizations/models/domain.rb +283 -1
  54. data/lib/organizations/models/grant.rb +152 -0
  55. data/lib/organizations/models/invitation.rb +79 -8
  56. data/lib/organizations/models/join_request.rb +102 -43
  57. data/lib/organizations/models/membership.rb +96 -15
  58. data/lib/organizations/models/organization.rb +219 -48
  59. data/lib/organizations/organization_scoped.rb +16 -0
  60. data/lib/organizations/personal_owner_backfill.rb +122 -0
  61. data/lib/organizations/public_email_domains.rb +49 -0
  62. data/lib/organizations/resource.rb +11 -0
  63. data/lib/organizations/roles.rb +198 -36
  64. data/lib/organizations/version.rb +1 -1
  65. data/lib/organizations/view_helpers.rb +40 -9
  66. data/lib/organizations.rb +99 -4
  67. metadata +22 -2
@@ -0,0 +1,113 @@
1
+ <%# The join screen: one URL, four states, all decided by
2
+ Organizations::JoinState, so the page and its forms always agree.
3
+ Generated by `rails g organizations:join`. Retheme it freely; keep the
4
+ disclosure visible before every submit (see OrganizationJoinsController). %>
5
+
6
+ <div class="mx-auto max-w-lg">
7
+ <div class="mb-8 text-center">
8
+ <span class="mx-auto mb-4 inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-600 text-2xl font-bold text-white">
9
+ <%= @organization.name.first.upcase %>
10
+ </span>
11
+ <h1 class="text-2xl font-bold text-gray-900"><%= @organization.name %></h1>
12
+ <p class="mt-1 text-sm text-gray-500"><%= @organization.member_count %> members</p>
13
+ </div>
14
+
15
+ <div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
16
+ <% case @state.status %>
17
+ <% when :member %>
18
+
19
+ <div class="text-center">
20
+ <%= heroicon "check-circle", variant: :outline, options: { class: "mx-auto h-12 w-12 text-green-500" } %>
21
+ <h2 class="mt-2 text-lg font-semibold text-gray-900">You're a member!</h2>
22
+ <p class="mt-1 text-sm text-gray-500">You already belong to <%= @organization.name %>.</p>
23
+ <%= link_to "Go to dashboard", root_path,
24
+ class: "mt-4 inline-flex items-center rounded-lg bg-slate-600 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700" %>
25
+ </div>
26
+
27
+ <% when :verifying %>
28
+
29
+ <h2 class="text-lg font-semibold text-gray-900">Check your inbox</h2>
30
+ <p class="mt-1 text-sm text-gray-500">
31
+ We sent a 6-digit code to <span class="font-medium text-gray-900"><%= @state.challenge.verification_email %></span>.
32
+ </p>
33
+
34
+ <%= form_with url: organization_join_path(@organization), method: :post, class: "mt-4 space-y-3" do |form| %>
35
+ <%= form.text_field :verification_code,
36
+ autofocus: true, autocomplete: "one-time-code", inputmode: "numeric", maxlength: 6,
37
+ placeholder: "123456",
38
+ class: "block w-full rounded-lg border-gray-300 text-center text-2xl tracking-[0.5em] focus:border-slate-500 focus:ring-slate-500" %>
39
+ <%= form.submit "Verify", class: "w-full rounded-lg bg-slate-600 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700 cursor-pointer" %>
40
+ <% end %>
41
+
42
+ <%# resend_seconds derives from gem config — never mirror the throttle
43
+ as a magic number in your app. %>
44
+ <div class="mt-4 border-t border-gray-100 pt-4 text-center text-sm text-gray-500">
45
+ <% if @state.resend_seconds.zero? %>
46
+ <%= form_with url: organization_join_path(@organization), method: :post, class: "inline" do |form| %>
47
+ <%= form.hidden_field :email, value: @state.challenge.verification_email %>
48
+ <%= form.submit "Resend code", class: "font-medium text-slate-600 hover:text-slate-800 cursor-pointer bg-transparent border-0 p-0" %>
49
+ <% end %>
50
+ <% else %>
51
+ You can request another code in <%= @state.resend_seconds %>s.
52
+ <% end %>
53
+ </div>
54
+
55
+ <% when :pending %>
56
+
57
+ <div class="text-center">
58
+ <%= heroicon "clock", variant: :outline, options: { class: "mx-auto h-12 w-12 text-amber-500" } %>
59
+ <h2 class="mt-2 text-lg font-semibold text-gray-900">Request pending</h2>
60
+ <p class="mt-1 text-sm text-gray-500">An admin of <%= @organization.name %> will review your request.</p>
61
+ <%= button_to "Withdraw request", organization_withdraw_join_path(@organization), method: :delete,
62
+ class: "mt-4 text-sm font-medium text-gray-500 hover:text-red-600 cursor-pointer bg-transparent" %>
63
+ </div>
64
+
65
+ <% when :entry %>
66
+
67
+ <h2 class="text-lg font-semibold text-gray-900">Join <%= @organization.name %></h2>
68
+
69
+ <%= render "organization_joins/disclosure", organization: @organization %>
70
+
71
+ <%# The capability predicates decide which forms exist — an org with no
72
+ domains never shows an email form, etc. %>
73
+ <% if @organization.accepts_domain_joining? %>
74
+ <%= form_with url: organization_join_path(@organization), method: :post, class: "mt-4 space-y-3" do |form| %>
75
+ <label class="block text-sm font-medium text-gray-700">Your work email</label>
76
+ <%= form.email_field :email, placeholder: "you@company.com", required: true, maxlength: 254,
77
+ class: "block w-full rounded-lg border-gray-300 focus:border-slate-500 focus:ring-slate-500" %>
78
+ <%= form.submit "Send verification code", class: "w-full rounded-lg bg-slate-600 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700 cursor-pointer" %>
79
+ <% end %>
80
+ <% end %>
81
+
82
+ <% if @organization.accepts_code_joining? %>
83
+ <div class="<%= "mt-4 border-t border-gray-100 pt-4" if @organization.accepts_domain_joining? %>">
84
+ <%= form_with url: organization_join_path(@organization), method: :post, class: "space-y-3" do |form| %>
85
+ <label class="block text-sm font-medium text-gray-700">Have a join code?</label>
86
+ <%= form.text_field :code, value: @staged_code, placeholder: "XXXX-XXXX", maxlength: 32,
87
+ class: "block w-full rounded-lg border-gray-300 uppercase tracking-widest focus:border-slate-500 focus:ring-slate-500" %>
88
+ <%= form.submit "Join with code", class: "w-full rounded-lg border border-slate-600 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer" %>
89
+ <% end %>
90
+ </div>
91
+ <% end %>
92
+
93
+ <%# A request with no code and no email. JoinFlow joins right away when
94
+ the account email is confirmed at a domain of this organization;
95
+ otherwise the admins decide. %>
96
+ <% if @organization.accepts_join_requests? %>
97
+ <div class="mt-4 border-t border-gray-100 pt-4">
98
+ <%= form_with url: organization_join_path(@organization), method: :post, class: "space-y-3" do |form| %>
99
+ <label class="block text-sm font-medium text-gray-700">Or ask to join</label>
100
+ <%= form.text_area :message, rows: 2, maxlength: 500, placeholder: "A note for the admins (optional)",
101
+ class: "block w-full rounded-lg border-gray-300 text-sm focus:border-slate-500 focus:ring-slate-500" %>
102
+ <%= form.submit "Ask to join", class: "w-full rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 cursor-pointer" %>
103
+ <% end %>
104
+ </div>
105
+ <% end %>
106
+
107
+ <% unless @organization.accepts_join_requests? %>
108
+ <p class="mt-2 text-sm text-gray-500">This organization is not accepting join requests right now.</p>
109
+ <% end %>
110
+
111
+ <% end %>
112
+ </div>
113
+ </div>
@@ -0,0 +1,19 @@
1
+ # organizations: join and access screens (rails g organizations:join)
2
+ scope "orgs/:organization_id", as: :organization do
3
+ get "join", to: "organization_joins#show", as: :join
4
+ post "join", to: "organization_joins#create"
5
+ delete "join", to: "organization_joins#withdraw", as: :withdraw_join
6
+
7
+ get "access", to: "organization_access#show", as: :access
8
+ post "access/domains", to: "organization_access#add_domain", as: :access_domains
9
+ patch "access/domains/:domain_id", to: "organization_access#update_domain", as: :access_domain
10
+ delete "access/domains/:domain_id", to: "organization_access#remove_domain"
11
+ post "access/domains/:domain_id/verification", to: "organization_access#start_domain_verification", as: :access_domain_verification
12
+ post "access/domains/:domain_id/verify", to: "organization_access#verify_domain", as: :verify_access_domain
13
+ post "access/codes", to: "organization_access#generate_code", as: :access_codes
14
+ post "access/codes/:code_id/revoke", to: "organization_access#revoke_code", as: :revoke_access_code
15
+ post "access/allowlist", to: "organization_access#import_allowlist", as: :access_allowlist
16
+ delete "access/allowlist/:entry_id", to: "organization_access#remove_allowlist_entry", as: :access_allowlist_entry
17
+ post "access/requests/:request_id/approve", to: "organization_access#approve_request", as: :approve_access_request
18
+ post "access/requests/:request_id/reject", to: "organization_access#reject_request", as: :reject_access_request
19
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Upgrades organizations <= 0.6.x to 0.7.0: domain authority and sign-up
4
+ # domain policies. Additive only. Existing domains start unverified with the
5
+ # "none" policy: nothing changes until you verify a domain or turn on
6
+ # config.require_verified_domains.
7
+ class AddDomainVerification < ActiveRecord::Migration<%= migration_version %>
8
+ def change
9
+ # ⚠️ If your user model is NOT `User` (config.user_class), change
10
+ # `to_table: :users` to your user table before migrating.
11
+ change_table :organizations_domains do |t|
12
+ t.string :join_policy, null: false, default: "none"
13
+ t.string :verification_token
14
+ t.datetime :verification_started_at
15
+ t.datetime :verified_at
16
+ t.string :verification_method
17
+ t.references :verified_by, null: true, type: foreign_key_type,
18
+ foreign_key: { to_table: :users, on_delete: :nullify }
19
+ t.datetime :verification_revoked_at
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def foreign_key_type
26
+ config = Rails.configuration.generators
27
+ config.options[config.orm][:primary_key_type] || :bigint
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Upgrades organizations <= 0.6.x to 0.7.0: personal organizations.
4
+ # Additive only. Existing organizations stay unclassified (personal_owner_id
5
+ # is NULL): link the ones you have reviewed with
6
+ # Organizations::Organization.backfill_personal_owners!(mapping, dry_run: true).
7
+ class AddPersonalOrganizations < ActiveRecord::Migration<%= migration_version %>
8
+ def change
9
+ # ⚠️ If your user model is NOT `User` (config.user_class), change
10
+ # `to_table: :users` to your user table before migrating.
11
+ add_reference :organizations_organizations, :personal_owner,
12
+ null: true, type: foreign_key_type, index: { unique: true },
13
+ foreign_key: { to_table: :users, on_delete: :nullify }
14
+ end
15
+
16
+ private
17
+
18
+ def foreign_key_type
19
+ config = Rails.configuration.generators
20
+ config.options[config.orm][:primary_key_type] || :bigint
21
+ end
22
+ end
@@ -5,43 +5,108 @@ require "rails/generators/active_record"
5
5
 
6
6
  module Organizations
7
7
  module Generators
8
- # Upgrades an existing organizations install (<= 0.4.x) to 0.5.0
9
- # ("Verified Joining"): new tables for domains, join codes, allowlist
10
- # entries and join requests, plus provenance columns on memberships and
11
- # metadata columns on invitations.
8
+ # Brings an existing organizations install up to date. Each step is one
9
+ # additive migration, generated only when the app does not have it yet:
12
10
  #
13
- # Fresh installs don't need this — `rails g organizations:install`
14
- # already includes everything.
11
+ # 0.5.0 - verified joining: domains, join codes, allowlist entries,
12
+ # join requests, membership provenance, invitation metadata
13
+ # 0.7.0 - personal organizations (organizations.personal_owner_id)
14
+ # 0.7.0 - domain authority (verification and join policy on domains)
15
+ #
16
+ # A step counts as present when the database already has its table or
17
+ # column (when the generator can connect), when its upgrade migration is
18
+ # already in db/migrate, or when the app's install migration already
19
+ # creates it (an install made on a later version). Run it after every
20
+ # upgrade of the gem:
15
21
  #
16
22
  # rails g organizations:upgrade
17
23
  # rails db:migrate
18
24
  #
25
+ # For a squashed or renamed migration history, name the steps you need:
26
+ #
27
+ # rails g organizations:upgrade --steps add_personal_organizations
28
+ #
19
29
  class UpgradeGenerator < Rails::Generators::Base
20
30
  include ActiveRecord::Generators::Migration
21
31
 
22
32
  source_root File.expand_path("templates", __dir__)
23
- desc "Add the verified-joining tables/columns (organizations 0.5.0) to an existing install"
33
+ desc "Add the tables and columns of newer organizations versions to an existing install"
34
+
35
+ # migration name => [text the install migration contains when it creates
36
+ # this step, [table, column] that exist once it ran (column nil = table only)]
37
+ STEPS = {
38
+ "add_verified_joining_to_organizations" => ["organizations_domains", ["organizations_domains", nil]],
39
+ "add_personal_organizations" => ["personal_owner", %w[organizations_organizations personal_owner_id]],
40
+ "add_domain_verification" => ["verification_token", %w[organizations_domains verification_token]]
41
+ }.freeze
42
+
43
+ class_option :steps, type: :array, default: nil,
44
+ desc: "Generate exactly these steps and skip detection (#{STEPS.keys.join(', ')})"
24
45
 
25
46
  def self.next_migration_number(dir)
26
47
  ActiveRecord::Generators::Base.next_migration_number(dir)
27
48
  end
28
49
 
29
- def create_migration_file
30
- migration_template "add_verified_joining_to_organizations.rb.erb",
31
- File.join(db_migrate_path, "add_verified_joining_to_organizations.rb"),
32
- migration_version: migration_version
50
+ def create_migration_files
51
+ @created = []
52
+ selected_steps.each do |name|
53
+ next if options[:steps].nil? && step_present?(name)
54
+
55
+ migration_template "#{name}.rb.erb", File.join(db_migrate_path, "#{name}.rb"),
56
+ migration_version: migration_version
57
+ @created << name
58
+ end
33
59
  end
34
60
 
35
61
  def display_post_upgrade_message
36
- say "\n✅ organizations verified-joining migration created.", :green
62
+ if @created.empty?
63
+ say "\n✅ organizations is up to date: no migration needed.", :green
64
+ return
65
+ end
66
+
67
+ say "\n✅ organizations upgrade migrations created: #{@created.join(', ')}.", :green
37
68
  say "\nNext steps:"
38
69
  say " 1. Run 'rails db:migrate'."
39
- say " 2. See the README's \"Verified joining\" section for the new API"
40
- say " (domains, join codes, allowlists, join requests)."
70
+ say " 2. See CHANGELOG.md for what each version adds."
41
71
  end
42
72
 
43
73
  private
44
74
 
75
+ def selected_steps
76
+ return STEPS.keys unless options[:steps]
77
+
78
+ unknown = options[:steps] - STEPS.keys
79
+ raise Thor::Error, "Unknown step(s): #{unknown.join(', ')}. Valid: #{STEPS.keys.join(', ')}" if unknown.any?
80
+
81
+ STEPS.keys & options[:steps]
82
+ end
83
+
84
+ def step_present?(name)
85
+ marker, (table, column) = STEPS.fetch(name)
86
+ in_database?(table, column) || in_migrations?(name, marker)
87
+ end
88
+
89
+ # false when there is no database to ask (the generator still works offline).
90
+ def in_database?(table, column)
91
+ check = ->(c) { c.table_exists?(table) && (column.nil? || c.column_exists?(table, column)) }
92
+ return ActiveRecord::Base.with_connection(&check) if ActiveRecord::Base.respond_to?(:with_connection)
93
+
94
+ check.call(ActiveRecord::Base.connection) # Rails 7.1
95
+ rescue StandardError
96
+ false
97
+ end
98
+
99
+ def in_migrations?(name, marker)
100
+ migration_files.any? do |file|
101
+ File.basename(file).match?(/\A\d+_#{name}\.rb\z/) ||
102
+ (File.basename(file).match?(/\A\d+_create_organizations_tables\.rb\z/) && File.read(file).include?(marker))
103
+ end
104
+ end
105
+
106
+ def migration_files
107
+ @migration_files ||= Dir[File.join(destination_root, db_migrate_path, "*.rb")]
108
+ end
109
+
45
110
  def migration_version
46
111
  "[#{ActiveRecord::VERSION::STRING.to_f}]"
47
112
  end
@@ -35,7 +35,7 @@
35
35
  <div>
36
36
  <%= form.label :role, "Role", class: "block text-sm font-medium text-gray-700 mb-1" %>
37
37
  <%= form.select :role,
38
- Organizations::Roles::HIERARCHY.reject { |role| role == :owner }.map { |role| [role.to_s.capitalize, role] },
38
+ Organizations::Roles.hierarchy.select { |role| role != :owner && current_membership&.can_assign_role?(role) }.map { |role| [organization_role_label(role, organization: current_organization), role] },
39
39
  { selected: @invitation.role || :member },
40
40
  required: true,
41
41
  class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-slate-500 focus:ring-slate-500 sm:text-sm" %>
@@ -54,7 +54,7 @@
54
54
  when 'member' then 'bg-blue-100 text-blue-800'
55
55
  else 'bg-gray-100 text-gray-800'
56
56
  end %>">
57
- <%= invitation.role.capitalize %>
57
+ <%= organization_role_label(invitation.role, organization: invitation.organization) %>
58
58
  </span>
59
59
  </td>
60
60
  <td class="px-6 py-4 whitespace-nowrap">
@@ -84,7 +84,7 @@
84
84
  <% end %>
85
85
  </td>
86
86
  <td class="px-6 py-4 whitespace-nowrap text-right">
87
- <% if invitation.pending? %>
87
+ <% if invitation.pending? && current_membership&.can_assign_role?(invitation.role) %>
88
88
  <div class="flex items-center justify-end gap-2">
89
89
  <%= button_to resend_organization_invitation_path(invitation),
90
90
  method: :post,
@@ -21,7 +21,7 @@
21
21
  else 'bg-gray-100 text-gray-800'
22
22
  end %>">
23
23
  <%= heroicon "user-circle", variant: :solid, options: { class: "h-5 w-5" } %>
24
- You'll join as <%= @invitation.role.capitalize %>
24
+ You'll join as <%= organization_role_label(@invitation.role, organization: @invitation.organization) %>
25
25
  </span>
26
26
  </div>
27
27
 
@@ -48,33 +48,41 @@
48
48
  when 'member' then 'bg-blue-100 text-blue-800'
49
49
  else 'bg-gray-100 text-gray-800'
50
50
  end %>">
51
- <%= membership.role.capitalize %>
51
+ <%= organization_role_label(membership.role, organization: membership.organization) %>
52
52
  </span>
53
53
  </td>
54
54
  <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
55
55
  <%= time_ago_in_words(membership.created_at) %> ago
56
56
  </td>
57
57
  <td class="px-6 py-4 whitespace-nowrap text-right">
58
+ <% can_change_role = can_change_member_role?(current_user, membership) %>
59
+ <% can_remove = can_remove_member?(current_user, membership) %>
58
60
  <% if is_current_user %>
59
61
  <span class="text-sm text-gray-400 italic">Current user</span>
60
62
  <% elsif membership.role == 'owner' %>
61
63
  <span class="text-sm text-gray-400 italic">Organization owner</span>
64
+ <% elsif !can_change_role && !can_remove %>
65
+ <span class="text-sm text-gray-400 italic">No actions</span>
62
66
  <% else %>
63
67
  <div class="flex items-center justify-end gap-2">
64
- <%= form_with(model: membership, url: membership_path(membership), method: :patch, local: true, class: "flex items-center gap-2") do |form| %>
65
- <%= form.select :role,
66
- Organizations::Roles::HIERARCHY.reject { |r| r == :owner }.map { |role| [role.to_s.capitalize, role] },
67
- { selected: membership.role },
68
- class: "rounded-md border-gray-300 text-sm focus:border-slate-500 focus:ring-slate-500" %>
69
- <%= form.submit "Update",
70
- class: "inline-flex items-center rounded-md bg-white px-2.5 py-1.5 text-xs font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 cursor-pointer" %>
68
+ <% if can_change_role %>
69
+ <%= form_with(model: membership, url: membership_path(membership), method: :patch, local: true, class: "flex items-center gap-2") do |form| %>
70
+ <%= form.select :role,
71
+ Organizations::Roles.hierarchy.select { |r| r != :owner && current_membership.can_assign_role?(r) }.map { |role| [organization_role_label(role, organization: membership.organization), role] },
72
+ { selected: membership.role },
73
+ class: "rounded-md border-gray-300 text-sm focus:border-slate-500 focus:ring-slate-500" %>
74
+ <%= form.submit "Update",
75
+ class: "inline-flex items-center rounded-md bg-white px-2.5 py-1.5 text-xs font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 cursor-pointer" %>
76
+ <% end %>
71
77
  <% end %>
72
78
 
73
- <%= button_to membership_path(membership),
74
- method: :delete,
75
- data: { turbo_confirm: "Remove #{membership.user.email} from the organization?" },
76
- class: "inline-flex items-center rounded-md bg-red-50 px-2 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100" do %>
77
- <%= heroicon "trash", variant: :mini, options: { class: "h-4 w-4" } %>
79
+ <% if can_remove %>
80
+ <%= button_to membership_path(membership),
81
+ method: :delete,
82
+ data: { turbo_confirm: "Remove #{membership.user.email} from the organization?" },
83
+ class: "inline-flex items-center rounded-md bg-red-50 px-2 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100" do %>
84
+ <%= heroicon "trash", variant: :mini, options: { class: "h-4 w-4" } %>
85
+ <% end %>
78
86
  <% end %>
79
87
  </div>
80
88
  <% end %>
@@ -64,7 +64,7 @@
64
64
  when 'member' then 'bg-blue-100 text-blue-800'
65
65
  else 'bg-gray-100 text-gray-800'
66
66
  end %>">
67
- <%= membership&.role&.capitalize || "Unknown" %>
67
+ <%= membership ? organization_role_label(membership.role, organization: organization) : "Unknown" %>
68
68
  </span>
69
69
  </td>
70
70
  <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 bg-inherit">
@@ -59,7 +59,7 @@
59
59
  </div>
60
60
  <div>
61
61
  <p class="text-sm text-gray-500">Your Role</p>
62
- <p class="text-2xl font-bold text-gray-900"><%= current_user.role_in(@organization)&.capitalize || "N/A" %></p>
62
+ <p class="text-2xl font-bold text-gray-900"><%= (role = current_user.role_in(@organization)) ? organization_role_label(role, organization: @organization) : "N/A" %></p>
63
63
  </div>
64
64
  </div>
65
65
  </div>
@@ -117,7 +117,7 @@
117
117
  when 'member' then 'bg-blue-100 text-blue-800'
118
118
  else 'bg-gray-100 text-gray-700'
119
119
  end %>">
120
- <%= membership.role.capitalize %>
120
+ <%= organization_role_label(membership.role, organization: @organization) %>
121
121
  </span>
122
122
  </div>
123
123
 
@@ -126,7 +126,7 @@
126
126
  <div class="flex items-center gap-2">
127
127
  <%= form_with(model: membership, url: organizations.membership_path(membership), method: :patch, local: true, class: "flex items-center gap-2") do |form| %>
128
128
  <%= form.select :role,
129
- Organizations::Roles::HIERARCHY.reject { |r| r == :owner }.map { |role| [role.to_s.capitalize, role] },
129
+ Organizations::Roles.hierarchy.reject { |r| r == :owner }.map { |role| [organization_role_label(role, organization: @organization), role] },
130
130
  { selected: membership.role },
131
131
  class: "rounded-md border-gray-300 text-xs focus:border-slate-500 focus:ring-slate-500 py-1" %>
132
132
  <%= form.submit "Update",
@@ -17,10 +17,8 @@ module Organizations
17
17
  # so the dummy remains the living, boot-able preview of what you get).
18
18
  #
19
19
  # NOTE: verified joining (join screens, request queues, access
20
- # management) is deliberately NOT part of this set — those surfaces are
21
- # host ROUTES + CONTROLLERS too, not just views. Copy the reference
22
- # implementation from the gem's test/dummy app instead (see README
23
- # "Verified joining — reference UI").
20
+ # management) is not part of this set: those surfaces are host routes
21
+ # and controllers too. `rails g organizations:join` copies them.
24
22
  class ViewsGenerator < Rails::Generators::Base
25
23
  source_root File.expand_path("templates", __dir__)
26
24
 
@@ -38,8 +36,8 @@ module Organizations
38
36
  say "The organization page's owner-only Danger Zone (transfer/delete) renders only if you"
39
37
  say "provide app/views/shared/_danger_zone.html.erb — copy the full-featured one (Stimulus"
40
38
  say "confirm modal included) from the gem's test/dummy app."
41
- say "Verified-joining surfaces (join screen, request queue, access management) are host"
42
- say "controllers + views: copy the reference implementation from the gem's test/dummy app."
39
+ say "For the join screen and the access screen (domains, codes, allowlist, requests),"
40
+ say "run `rails g organizations:join`."
43
41
  say ""
44
42
  end
45
43
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # What deleting a user would do to their organizations, under a policy
5
+ # (config.account_deletion_policy). user.account_deletion_plan returns one
6
+ # without changing anything; the deletion guard builds one under row locks
7
+ # and acts on it.
8
+ #
9
+ # plan = user.account_deletion_plan
10
+ # plan.allowed? # => false
11
+ # plan.blockers # => [#<Blocker organization=…, reason=:owns_organization_with_members>]
12
+ # plan.organizations_to_destroy # personal organizations deleted with the account
13
+ # plan.memberships_to_remove # the user's other memberships, removed with the account
14
+ #
15
+ # Blocker reasons:
16
+ # :owns_organization_with_members - transfer ownership first
17
+ # :owns_organization - the user is the only member; delete the organization first
18
+ # :personal_organization_with_members - the personal organization has other members
19
+ # :personal_organization - the policy is :prevent
20
+ #
21
+ # Database effects only. Subscriptions, external services and retained
22
+ # records belong to the host's account-closure workflow: a rollback cannot
23
+ # undo an external call, so run those after the deletion commits.
24
+ class AccountDeletionPlan
25
+ Blocker = Struct.new(:organization, :reason, keyword_init: true)
26
+
27
+ BLOCKER_REASONS = %i[
28
+ owns_organization_with_members
29
+ owns_organization
30
+ personal_organization_with_members
31
+ personal_organization
32
+ ].freeze
33
+
34
+ attr_reader :user, :policy, :blockers, :organizations_to_destroy, :memberships_to_remove
35
+
36
+ # @param user [User]
37
+ # @param policy [Symbol] one of Configuration::ACCOUNT_DELETION_POLICIES
38
+ # @param lock [Boolean] lock the owned organizations (the deletion guard does)
39
+ def self.for(user, policy: Organizations.configuration.account_deletion_policy, lock: false)
40
+ new(user, policy.to_sym, lock: lock)
41
+ end
42
+
43
+ def initialize(user, policy, lock: false)
44
+ unless Configuration::ACCOUNT_DELETION_POLICIES.include?(policy)
45
+ raise ArgumentError, "Unknown account deletion policy: #{policy.inspect}"
46
+ end
47
+
48
+ @user = user
49
+ @policy = policy
50
+ @blockers = []
51
+ @organizations_to_destroy = []
52
+ classify(owned_organizations(lock: lock))
53
+ @memberships_to_remove = remaining_memberships
54
+ end
55
+
56
+ # @return [Boolean] true when the user can be deleted
57
+ def allowed?
58
+ blockers.empty?
59
+ end
60
+
61
+ # A plain Hash (ids and symbols) for audit logs and confirmation screens.
62
+ # @return [Hash]
63
+ def to_h
64
+ {
65
+ allowed: allowed?,
66
+ policy: policy,
67
+ blockers: blockers.map { |b| { organization_id: b.organization.id, reason: b.reason } },
68
+ organizations_to_destroy: organizations_to_destroy.map(&:id),
69
+ memberships_to_remove: memberships_to_remove.map(&:id)
70
+ }
71
+ end
72
+
73
+ private
74
+
75
+ # Organizations the user owns, plus any organization still linked to them
76
+ # as personal owner (a raw write can leave a link without the membership).
77
+ def owned_organizations(lock:)
78
+ owned_ids = Membership.where(user_id: user.id, role: "owner").select(:organization_id)
79
+ scope = Organization.where(id: owned_ids)
80
+ scope = scope.or(Organization.where(personal_owner_id: user.id)) if personal_column?
81
+ scope = scope.order(:id)
82
+ lock ? scope.lock.to_a : scope.to_a
83
+ end
84
+
85
+ def classify(organizations)
86
+ @shared_ids = Membership.where(organization_id: organizations.map(&:id)).where.not(user_id: user.id)
87
+ .distinct.pluck(:organization_id).to_set
88
+ organizations.each do |organization|
89
+ reason = blocker_reason(organization)
90
+ if reason
91
+ blockers << Blocker.new(organization: organization, reason: reason)
92
+ else
93
+ organizations_to_destroy << organization
94
+ end
95
+ end
96
+ end
97
+
98
+ def blocker_reason(organization)
99
+ others = @shared_ids.include?(organization.id)
100
+ personal = personal_column? && organization.personal_owner_id == user.id
101
+
102
+ return (others ? :owns_organization_with_members : :owns_organization) unless personal
103
+ return :personal_organization_with_members if others
104
+ return :personal_organization unless policy == :destroy_personal_organization
105
+
106
+ nil
107
+ end
108
+
109
+ def remaining_memberships
110
+ Membership.where(user_id: user.id).where.not(organization_id: organizations_to_destroy.map(&:id))
111
+ .where.not(role: "owner").order(:id).to_a
112
+ end
113
+
114
+ def personal_column?
115
+ Organization.column_names.include?("personal_owner_id")
116
+ end
117
+ end
118
+ end