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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/.rubocop_todo.yml +7 -0
- data/.simplecov +17 -10
- data/Appraisals +2 -0
- data/CHANGELOG.md +112 -0
- data/README.md +714 -32
- data/app/controllers/organizations/invitations_controller.rb +27 -22
- data/app/controllers/organizations/memberships_controller.rb +37 -30
- data/app/controllers/organizations/organizations_controller.rb +8 -8
- data/app/controllers/organizations/public_invitations_controller.rb +1 -1
- data/app/mailers/organizations/invitation_mailer.rb +2 -2
- data/app/mailers/organizations/verification_mailer.rb +1 -1
- data/app/models/organizations/grant.rb +6 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
- data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
- data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
- data/config/locales/en.yml +22 -0
- data/config/locales/es.yml +22 -0
- data/config/routes.rb +1 -2
- data/gemfiles/rails_7.2.gemfile +6 -1
- data/gemfiles/rails_8.1.gemfile +5 -1
- data/lib/generators/organizations/grants/grants_generator.rb +47 -0
- data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
- data/lib/generators/organizations/install/install_generator.rb +1 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
- data/lib/generators/organizations/join/join_generator.rb +113 -0
- data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
- data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
- data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
- data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
- data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
- data/lib/generators/organizations/join/templates/routes.rb +19 -0
- data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
- data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
- data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
- data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
- data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
- data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
- data/lib/generators/organizations/views/views_generator.rb +4 -6
- data/lib/organizations/account_deletion_plan.rb +118 -0
- data/lib/organizations/configuration.rb +155 -2
- data/lib/organizations/controller_helpers.rb +55 -15
- data/lib/organizations/grant_access.rb +75 -0
- data/lib/organizations/grant_holder.rb +152 -0
- data/lib/organizations/grantable.rb +28 -0
- data/lib/organizations/join_flow.rb +16 -12
- data/lib/organizations/models/concerns/has_organizations.rb +217 -9
- data/lib/organizations/models/domain.rb +283 -1
- data/lib/organizations/models/grant.rb +152 -0
- data/lib/organizations/models/invitation.rb +79 -8
- data/lib/organizations/models/join_request.rb +102 -43
- data/lib/organizations/models/membership.rb +96 -15
- data/lib/organizations/models/organization.rb +219 -48
- data/lib/organizations/organization_scoped.rb +16 -0
- data/lib/organizations/personal_owner_backfill.rb +122 -0
- data/lib/organizations/public_email_domains.rb +49 -0
- data/lib/organizations/resource.rb +11 -0
- data/lib/organizations/roles.rb +198 -36
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +40 -9
- data/lib/organizations.rb +99 -4
- 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
|
-
#
|
|
9
|
-
#
|
|
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
|
-
#
|
|
14
|
-
#
|
|
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
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
65
|
-
<%=
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 %>
|
data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
21
|
-
#
|
|
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 "
|
|
42
|
-
say "
|
|
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
|