organizations 0.6.0 → 0.7.1
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/CHANGELOG.md +102 -0
- data/README.md +704 -33
- 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 +5 -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 +152 -0
- 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 +224 -9
- data/lib/organizations/models/domain.rb +283 -1
- data/lib/organizations/models/grant.rb +152 -0
- data/lib/organizations/models/invitation.rb +76 -7
- data/lib/organizations/models/join_request.rb +102 -43
- data/lib/organizations/models/membership.rb +87 -5
- 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 +55 -0
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +40 -9
- data/lib/organizations.rb +99 -4
- metadata +22 -2
|
@@ -74,6 +74,15 @@ module Organizations
|
|
|
74
74
|
def require_organization_role(role, **options)
|
|
75
75
|
before_action(**options) { require_scoped_organization_role!(role) }
|
|
76
76
|
end
|
|
77
|
+
|
|
78
|
+
# Gate actions behind a permission of the viewer's role in the URL's
|
|
79
|
+
# organization (custom roles included). Same not-found posture as
|
|
80
|
+
# require_organization_role.
|
|
81
|
+
# require_organization_permission :invite_members
|
|
82
|
+
# require_organization_permission :manage_settings, only: %i[verify_domain]
|
|
83
|
+
def require_organization_permission(permission, **options)
|
|
84
|
+
before_action(**options) { require_scoped_organization_permission!(permission) }
|
|
85
|
+
end
|
|
77
86
|
end
|
|
78
87
|
|
|
79
88
|
# The organization resolved from the URL for this request.
|
|
@@ -120,6 +129,13 @@ module Organizations
|
|
|
120
129
|
scoped_organization_not_found!
|
|
121
130
|
end
|
|
122
131
|
|
|
132
|
+
def require_scoped_organization_permission!(permission)
|
|
133
|
+
membership = current_scoped_membership
|
|
134
|
+
return if membership&.has_permission_to?(permission)
|
|
135
|
+
|
|
136
|
+
scoped_organization_not_found!
|
|
137
|
+
end
|
|
138
|
+
|
|
123
139
|
def scoped_organization_not_found!
|
|
124
140
|
case self.class.organization_not_found_behavior
|
|
125
141
|
when :forbidden
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Links reviewed users to their personal organizations, for installs that
|
|
5
|
+
# created personal workspaces before personal organizations existed.
|
|
6
|
+
# Called through Organizations::Organization.backfill_personal_owners!.
|
|
7
|
+
#
|
|
8
|
+
# The mapping is the only source of truth. A sole member, a name such as
|
|
9
|
+
# "Personal", or the oldest organization never proves that an organization
|
|
10
|
+
# is someone's personal workspace, and a wrong link would later let the
|
|
11
|
+
# account-deletion policy delete a one-person business.
|
|
12
|
+
class PersonalOwnerBackfill
|
|
13
|
+
# assignments - [[user_id, organization_id], ...] written (or to write, in a dry run)
|
|
14
|
+
# unchanged - [[user_id, organization_id], ...] already linked
|
|
15
|
+
# conflicts - [{ user_id:, organization_id:, reason: }, ...]
|
|
16
|
+
Report = Struct.new(:assignments, :unchanged, :conflicts, :applied, keyword_init: true) do
|
|
17
|
+
def applied? = applied
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
CONFLICT_REASONS = %i[
|
|
21
|
+
organization_not_found
|
|
22
|
+
not_the_owner
|
|
23
|
+
organization_personal_for_another_user
|
|
24
|
+
user_has_another_personal_organization
|
|
25
|
+
organization_mapped_twice
|
|
26
|
+
user_mapped_twice
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
def initialize(mapping)
|
|
30
|
+
user_id_type = Organizations.user_class.type_for_attribute(Organizations.user_class.primary_key)
|
|
31
|
+
organization_id_type = Organization.type_for_attribute(Organization.primary_key)
|
|
32
|
+
# Pairs, not a Hash: a record and its id are two keys for one user.
|
|
33
|
+
@pairs = mapping.map do |user, organization|
|
|
34
|
+
[user_id_type.cast(id_of(user)), organization_id_type.cast(id_of(organization))]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param dry_run [Boolean]
|
|
39
|
+
# @return [Report]
|
|
40
|
+
def run(dry_run: false)
|
|
41
|
+
return analyze if dry_run
|
|
42
|
+
|
|
43
|
+
apply
|
|
44
|
+
rescue ActiveRecord::RecordNotUnique
|
|
45
|
+
# A concurrent provisioning linked one of these users meanwhile. The
|
|
46
|
+
# savepoint rolled back every write; report the state as it is now.
|
|
47
|
+
analyze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def apply
|
|
53
|
+
report = nil
|
|
54
|
+
# SAVEPOINT: a rollback must undo only these writes, also inside a host
|
|
55
|
+
# transaction, and leave it usable on PostgreSQL.
|
|
56
|
+
Organization.transaction(requires_new: true) do
|
|
57
|
+
# Lock the organizations so the checks hold until the write.
|
|
58
|
+
Organization.where(id: @pairs.map(&:last)).order(:id).lock.to_a
|
|
59
|
+
report = analyze
|
|
60
|
+
raise ActiveRecord::Rollback unless report.conflicts.empty?
|
|
61
|
+
|
|
62
|
+
report.assignments.each do |user_id, organization_id|
|
|
63
|
+
Organization.find(organization_id).update!(personal_owner_id: user_id)
|
|
64
|
+
end
|
|
65
|
+
report.applied = true
|
|
66
|
+
end
|
|
67
|
+
report
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def analyze
|
|
71
|
+
load_lookups
|
|
72
|
+
report = Report.new(assignments: [], unchanged: [], conflicts: [], applied: false)
|
|
73
|
+
@pairs.each do |user_id, organization_id|
|
|
74
|
+
reason = conflict_for(user_id, organization_id)
|
|
75
|
+
if reason
|
|
76
|
+
report.conflicts << { user_id: user_id, organization_id: organization_id, reason: reason }
|
|
77
|
+
elsif @linked[user_id] == organization_id
|
|
78
|
+
report.unchanged << [user_id, organization_id]
|
|
79
|
+
else
|
|
80
|
+
report.assignments << [user_id, organization_id]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
report
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# One query per rule, for the whole mapping.
|
|
87
|
+
def load_lookups
|
|
88
|
+
user_ids = @pairs.map(&:first)
|
|
89
|
+
organization_ids = @pairs.map(&:last)
|
|
90
|
+
@organizations = Organization.where(id: organization_ids).index_by(&:id)
|
|
91
|
+
@owners = Membership.where(organization_id: @organizations.keys, role: "owner")
|
|
92
|
+
.pluck(:organization_id, :user_id).to_h
|
|
93
|
+
@linked = Organization.personal.where(personal_owner_id: user_ids).pluck(:personal_owner_id, :id).to_h
|
|
94
|
+
@mapped_twice = repeated(organization_ids)
|
|
95
|
+
@users_mapped_twice = repeated(user_ids)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def repeated(ids) = ids.tally.select { |_, count| count > 1 }.keys
|
|
99
|
+
|
|
100
|
+
def conflict_for(user_id, organization_id)
|
|
101
|
+
organization = @organizations[organization_id]
|
|
102
|
+
return :organization_not_found unless organization
|
|
103
|
+
return :user_mapped_twice if @users_mapped_twice.include?(user_id)
|
|
104
|
+
return :organization_mapped_twice if @mapped_twice.include?(organization_id)
|
|
105
|
+
return :not_the_owner unless @owners[organization_id] == user_id
|
|
106
|
+
|
|
107
|
+
link_conflict(user_id, organization)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def link_conflict(user_id, organization)
|
|
111
|
+
if organization.personal_owner_id.present? && organization.personal_owner_id != user_id
|
|
112
|
+
:organization_personal_for_another_user
|
|
113
|
+
elsif @linked[user_id] && @linked[user_id] != organization.id
|
|
114
|
+
:user_has_another_personal_organization
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def id_of(record)
|
|
119
|
+
record.respond_to?(:id) ? record.id : record
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Common free email providers, for config.blocked_email_domains. Opt-in:
|
|
5
|
+
#
|
|
6
|
+
# config.blocked_email_domains = Organizations::PUBLIC_EMAIL_DOMAINS
|
|
7
|
+
# config.blocked_email_domains = Organizations::PUBLIC_EMAIL_DOMAINS + ["example-isp.net"]
|
|
8
|
+
#
|
|
9
|
+
# Defence in depth, not proof of authority and not a complete list: the
|
|
10
|
+
# proof is the DNS challenge. A blocked domain cannot be enrolled and never
|
|
11
|
+
# admits through a domain. People with these addresses can still join by
|
|
12
|
+
# invitation, code or allowlist.
|
|
13
|
+
PUBLIC_EMAIL_DOMAINS = %w[
|
|
14
|
+
aol.com
|
|
15
|
+
fastmail.com
|
|
16
|
+
gmail.com
|
|
17
|
+
gmx.com
|
|
18
|
+
gmx.de
|
|
19
|
+
gmx.net
|
|
20
|
+
googlemail.com
|
|
21
|
+
hey.com
|
|
22
|
+
hotmail.co.uk
|
|
23
|
+
hotmail.com
|
|
24
|
+
hotmail.es
|
|
25
|
+
hotmail.fr
|
|
26
|
+
icloud.com
|
|
27
|
+
live.com
|
|
28
|
+
mac.com
|
|
29
|
+
mail.com
|
|
30
|
+
mail.ru
|
|
31
|
+
me.com
|
|
32
|
+
msn.com
|
|
33
|
+
outlook.com
|
|
34
|
+
outlook.es
|
|
35
|
+
pm.me
|
|
36
|
+
proton.me
|
|
37
|
+
protonmail.com
|
|
38
|
+
qq.com
|
|
39
|
+
tutanota.com
|
|
40
|
+
web.de
|
|
41
|
+
yahoo.co.uk
|
|
42
|
+
yahoo.com
|
|
43
|
+
yahoo.es
|
|
44
|
+
yahoo.fr
|
|
45
|
+
yandex.com
|
|
46
|
+
yandex.ru
|
|
47
|
+
zoho.com
|
|
48
|
+
].freeze
|
|
49
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# A resource of the host app named by type and id, for grants on records
|
|
5
|
+
# that are not ActiveRecord models (or that you do not want to load):
|
|
6
|
+
#
|
|
7
|
+
# entity = Organizations::Resource.new("Ledger::Entity", entity_id)
|
|
8
|
+
# membership.grant!(entity, capabilities: %w[read])
|
|
9
|
+
# membership.can?(:read, entity)
|
|
10
|
+
Resource = Data.define(:type, :id)
|
|
11
|
+
end
|
data/lib/organizations/roles.rb
CHANGED
|
@@ -124,6 +124,52 @@ module Organizations
|
|
|
124
124
|
idx_a < idx_b ? -1 : 1
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
# Check if an account holding actor_role may act on role: give it to a
|
|
128
|
+
# member, take it away, remove a member who holds it, or invite to it.
|
|
129
|
+
# See assignment_refusal for the rule.
|
|
130
|
+
# @param actor_role [Symbol, String] The role of the account making the change
|
|
131
|
+
# @param role [Symbol, String] The role being acted on
|
|
132
|
+
# @return [Boolean]
|
|
133
|
+
#
|
|
134
|
+
# @example
|
|
135
|
+
# Roles.can_assign?(:admin, :admin) # => true (same rank)
|
|
136
|
+
# Roles.can_assign?(:admin, :member) # => true
|
|
137
|
+
# Roles.can_assign?(:member, :admin) # => false (admin ranks above member)
|
|
138
|
+
#
|
|
139
|
+
def can_assign?(actor_role, role) = assignment_refusal(actor_role, role).nil?
|
|
140
|
+
|
|
141
|
+
# Why an account holding actor_role may not act on role, or nil when it
|
|
142
|
+
# may. A role acts only on roles at or below its own rank, so a custom
|
|
143
|
+
# role that can edit member roles still cannot create or demote an
|
|
144
|
+
# admin. Rank alone is not enough below admin: two custom roles with the
|
|
145
|
+
# same parent are ranked by definition order, so a role below admin may
|
|
146
|
+
# also act only on roles whose permissions it holds itself. Admins and
|
|
147
|
+
# the owner keep the rank rule alone, as before. A role that is no
|
|
148
|
+
# longer configured ranks below every configured role and has no
|
|
149
|
+
# permissions; an actor whose own role is no longer configured may act
|
|
150
|
+
# on nothing. The owner role keeps its own rules on top of this: it
|
|
151
|
+
# moves only through transfer_ownership_to!.
|
|
152
|
+
# @param actor_role [Symbol, String] The role of the account making the change
|
|
153
|
+
# @param role [Symbol, String] The role being acted on
|
|
154
|
+
# @return [nil, :outranks, :lacks_permissions]
|
|
155
|
+
#
|
|
156
|
+
# @example
|
|
157
|
+
# Roles.assignment_refusal(:member, :admin) # => :outranks
|
|
158
|
+
# Roles.assignment_refusal(:lead, :finance) # => :lacks_permissions (a sibling with other powers)
|
|
159
|
+
# Roles.assignment_refusal(:lead, :member) # => nil
|
|
160
|
+
#
|
|
161
|
+
def assignment_refusal(actor_role, role)
|
|
162
|
+
return :outranks if actor_role.nil? || role.nil?
|
|
163
|
+
|
|
164
|
+
current = snapshot # one snapshot for the ranks and the permissions
|
|
165
|
+
actor_role = actor_role.to_sym
|
|
166
|
+
role = role.to_sym
|
|
167
|
+
return :outranks unless ranks_at_or_above?(current.hierarchy, actor_role, role)
|
|
168
|
+
return nil if ranks_at_or_above?(current.hierarchy, actor_role, :admin)
|
|
169
|
+
|
|
170
|
+
holds_permissions_of?(current.permission_sets, actor_role, role) ? nil : :lacks_permissions
|
|
171
|
+
end
|
|
172
|
+
|
|
127
173
|
# Get all permissions for a role (pre-computed, O(1) lookup)
|
|
128
174
|
# @param role [Symbol, String] The role
|
|
129
175
|
# @return [Array<Symbol>] All permissions for the role
|
|
@@ -206,6 +252,15 @@ module Organizations
|
|
|
206
252
|
|
|
207
253
|
private
|
|
208
254
|
|
|
255
|
+
# A role missing from order ranks below every configured role; an actor
|
|
256
|
+
# missing from it ranks at or above nothing.
|
|
257
|
+
def ranks_at_or_above?(order, actor_role, role)
|
|
258
|
+
actor_rank = order.index(actor_role)
|
|
259
|
+
!actor_rank.nil? && actor_rank <= (order.index(role) || order.length)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def holds_permissions_of?(sets, actor_role, role) = (sets[role] || Set.new).subset?(sets[actor_role] || Set.new)
|
|
263
|
+
|
|
209
264
|
# The hierarchy and permissions of one roles definition, computed
|
|
210
265
|
# together and published as one immutable value. It belongs to the
|
|
211
266
|
# definition object it was built from: after a reconfiguration (or a
|
|
@@ -95,32 +95,36 @@ module Organizations
|
|
|
95
95
|
|
|
96
96
|
# Returns a human-readable role label
|
|
97
97
|
# @param role [Symbol, String] The role
|
|
98
|
+
# @param organization [Organizations::Organization, nil] pass it to use
|
|
99
|
+
# that organization's vocabulary (config.vocabulary); never inferred
|
|
98
100
|
# @return [String]
|
|
99
101
|
#
|
|
100
102
|
# @example
|
|
101
103
|
# organization_role_label(:admin) # => "Admin"
|
|
104
|
+
# organization_role_label(:member, organization: school) # => "Student"
|
|
102
105
|
#
|
|
103
|
-
def organization_role_label(role)
|
|
106
|
+
def organization_role_label(role, organization: nil)
|
|
104
107
|
# I18n-backed (organizations.roles.*) so hosts localize/retheme labels
|
|
105
108
|
# by overriding locale keys instead of monkey-patching this helper.
|
|
106
109
|
# Custom roles defined via config.roles fall back to humanize unless
|
|
107
110
|
# the host adds a matching organizations.roles.<name> key.
|
|
108
|
-
Organizations.t(:"roles.#{role}", default: role.to_s.humanize)
|
|
111
|
+
Organizations.t(:"roles.#{role}", organization: organization, default: role.to_s.humanize)
|
|
109
112
|
end
|
|
110
113
|
|
|
111
114
|
# Returns a hash of role information for building badges
|
|
112
115
|
# @param role [Symbol, String] The role
|
|
116
|
+
# @param organization [Organizations::Organization, nil] for its vocabulary
|
|
113
117
|
# @return [Hash] Hash with :role, :label, and :color keys
|
|
114
118
|
#
|
|
115
119
|
# @example
|
|
116
120
|
# organization_role_info(:admin)
|
|
117
121
|
# # => { role: :admin, label: "Admin", color: :blue }
|
|
118
122
|
#
|
|
119
|
-
def organization_role_info(role)
|
|
123
|
+
def organization_role_info(role, organization: nil)
|
|
120
124
|
role_sym = role.to_sym
|
|
121
125
|
{
|
|
122
126
|
role: role_sym,
|
|
123
|
-
label:
|
|
127
|
+
label: role_label_for(role_sym, organization),
|
|
124
128
|
color: role_color(role_sym)
|
|
125
129
|
}
|
|
126
130
|
end
|
|
@@ -172,8 +176,8 @@ module Organizations
|
|
|
172
176
|
name: user.respond_to?(:name) && user.name.present? ? user.name : user.email,
|
|
173
177
|
email: user.email,
|
|
174
178
|
role: membership.role.to_sym,
|
|
175
|
-
role_label:
|
|
176
|
-
role_info: organization_role_info(membership.role),
|
|
179
|
+
role_label: role_label_for(membership.role, organization),
|
|
180
|
+
role_info: organization_role_info(membership.role, organization: organization),
|
|
177
181
|
joined_at: membership.created_at,
|
|
178
182
|
is_owner: membership.owner?
|
|
179
183
|
}
|
|
@@ -190,7 +194,7 @@ module Organizations
|
|
|
190
194
|
id: invitation.id,
|
|
191
195
|
email: invitation.email,
|
|
192
196
|
role: invitation.role.to_sym,
|
|
193
|
-
role_label:
|
|
197
|
+
role_label: role_label_for(invitation.role, organization),
|
|
194
198
|
invited_by: inviter,
|
|
195
199
|
invited_by_name: inviter_display_name(inviter),
|
|
196
200
|
status: organization_invitation_status(invitation),
|
|
@@ -240,6 +244,8 @@ module Organizations
|
|
|
240
244
|
end
|
|
241
245
|
|
|
242
246
|
# Check if current user can remove a member
|
|
247
|
+
# Follows the same rule as Organization#remove_member!: the member's role
|
|
248
|
+
# must be one the user may act on (see Roles.assignment_refusal).
|
|
243
249
|
# @param user [User] The user performing the action
|
|
244
250
|
# @param membership [Organizations::Membership] The membership to remove
|
|
245
251
|
# @return [Boolean]
|
|
@@ -247,10 +253,12 @@ module Organizations
|
|
|
247
253
|
return false unless user_has_permission_in_org?(user, membership.organization, :remove_members)
|
|
248
254
|
return false if membership.owner? # Can't remove owner
|
|
249
255
|
|
|
250
|
-
|
|
256
|
+
user_can_act_on_role?(user, membership)
|
|
251
257
|
end
|
|
252
258
|
|
|
253
259
|
# Check if current user can change a member's role
|
|
260
|
+
# Follows the same rule as Organization#change_role_of!: the member's
|
|
261
|
+
# current role must be one the user may act on (see Roles.assignment_refusal).
|
|
254
262
|
# @param user [User] The user performing the action
|
|
255
263
|
# @param membership [Organizations::Membership] The membership to update
|
|
256
264
|
# @return [Boolean]
|
|
@@ -262,7 +270,7 @@ module Organizations
|
|
|
262
270
|
if membership.owner?
|
|
263
271
|
user_has_permission_in_org?(user, membership.organization, :transfer_ownership)
|
|
264
272
|
else
|
|
265
|
-
|
|
273
|
+
user_can_act_on_role?(user, membership)
|
|
266
274
|
end
|
|
267
275
|
end
|
|
268
276
|
|
|
@@ -353,6 +361,24 @@ module Organizations
|
|
|
353
361
|
end
|
|
354
362
|
end
|
|
355
363
|
|
|
364
|
+
# organization_role_label with the organization, unless a host override
|
|
365
|
+
# of it takes only the role (written before 0.7.0).
|
|
366
|
+
def role_label_for(role, organization)
|
|
367
|
+
if role_label_takes_organization?
|
|
368
|
+
organization_role_label(role, organization: organization)
|
|
369
|
+
else
|
|
370
|
+
organization_role_label(role)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def role_label_takes_organization?
|
|
375
|
+
return @_role_label_takes_organization if defined?(@_role_label_takes_organization)
|
|
376
|
+
|
|
377
|
+
@_role_label_takes_organization = method(:organization_role_label).parameters.any? do |kind, name|
|
|
378
|
+
kind == :keyrest || name == :organization
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
356
382
|
def role_color(role)
|
|
357
383
|
case role.to_sym
|
|
358
384
|
when :owner then :purple
|
|
@@ -379,6 +405,11 @@ module Organizations
|
|
|
379
405
|
role && Roles.has_permission?(role, permission)
|
|
380
406
|
end
|
|
381
407
|
|
|
408
|
+
def user_can_act_on_role?(user, membership)
|
|
409
|
+
role = user.role_in(membership.organization)
|
|
410
|
+
role.present? && Roles.can_assign?(role, membership.role)
|
|
411
|
+
end
|
|
412
|
+
|
|
382
413
|
# Returns display name for invitation sender (handles nil inviter)
|
|
383
414
|
def inviter_display_name(inviter)
|
|
384
415
|
return nil unless inviter
|
data/lib/organizations.rb
CHANGED
|
@@ -71,6 +71,20 @@ module Organizations
|
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# Domain authority: why a DNS verification failed. reason is one of
|
|
75
|
+
# :not_started, :expired, :changed (the claim changed during the check),
|
|
76
|
+
# :record_not_found, :lookup_failed.
|
|
77
|
+
class DomainVerificationFailed < Error
|
|
78
|
+
REASONS = %i[not_started expired changed record_not_found lookup_failed].freeze
|
|
79
|
+
|
|
80
|
+
attr_reader :reason
|
|
81
|
+
|
|
82
|
+
def initialize(reason)
|
|
83
|
+
@reason = reason
|
|
84
|
+
super(Organizations.t(:"errors.domain_verification.#{reason}"))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
74
88
|
# Join code errors (verified joining)
|
|
75
89
|
# JoinCodeInvalid covers unknown, revoked, and expired codes — hosts should
|
|
76
90
|
# show the same generic message for all three (don't leak which codes exist).
|
|
@@ -105,6 +119,13 @@ module Organizations
|
|
|
105
119
|
autoload :JoinState, "organizations/join_state"
|
|
106
120
|
autoload :MetadataFlags, "organizations/metadata_flags"
|
|
107
121
|
autoload :OrganizationScoped, "organizations/organization_scoped"
|
|
122
|
+
autoload :PersonalOwnerBackfill, "organizations/personal_owner_backfill"
|
|
123
|
+
autoload :PUBLIC_EMAIL_DOMAINS, "organizations/public_email_domains"
|
|
124
|
+
autoload :Resource, "organizations/resource"
|
|
125
|
+
autoload :GrantHolder, "organizations/grant_holder"
|
|
126
|
+
autoload :GrantAccess, "organizations/grant_access"
|
|
127
|
+
autoload :Grantable, "organizations/grantable"
|
|
128
|
+
autoload :AccountDeletionPlan, "organizations/account_deletion_plan"
|
|
108
129
|
|
|
109
130
|
# Alias for README compatibility: `include Organizations::Controller`
|
|
110
131
|
Controller = ControllerHelpers
|
|
@@ -142,6 +163,7 @@ module Organizations
|
|
|
142
163
|
autoload :JoinCode, "organizations/models/join_code"
|
|
143
164
|
autoload :AllowlistEntry, "organizations/models/allowlist_entry"
|
|
144
165
|
autoload :JoinRequest, "organizations/models/join_request"
|
|
166
|
+
autoload :Grant, "organizations/models/grant"
|
|
145
167
|
|
|
146
168
|
# Non-Rails contexts (plain ActiveRecord, the gem's own test suite) don't
|
|
147
169
|
# get the Rails engine's automatic `config/locales` pickup, so register
|
|
@@ -281,13 +303,86 @@ module Organizations
|
|
|
281
303
|
# Hosts override any key the standard Rails way — app locale files load
|
|
282
304
|
# after engine locale files, so the host's value wins.
|
|
283
305
|
#
|
|
284
|
-
#
|
|
306
|
+
# VOCABULARY (config.vocabulary): pass the organization record as
|
|
307
|
+
# `organization:`. When the selected vocabulary has the key
|
|
308
|
+
# (`organizations.vocabularies.<vocabulary>.<key>`, in the current locale
|
|
309
|
+
# or one of its I18n fallbacks), that translation wins. Otherwise the call
|
|
310
|
+
# is exactly the ordinary one: same key, scope, defaults and options.
|
|
311
|
+
# `%{organization}` interpolates the record's name. A String
|
|
312
|
+
# `organization:` stays a plain interpolation value, as before. Calls
|
|
313
|
+
# with an Array key or a custom `scope:` skip the vocabulary.
|
|
314
|
+
#
|
|
315
|
+
# Organizations.t("roles.member", organization: school) # => "Student"
|
|
316
|
+
#
|
|
317
|
+
# @param key [String, Symbol, Array] key under the `organizations.` scope,
|
|
285
318
|
# e.g. :"errors.join_code_invalid" or "roles.owner"
|
|
286
|
-
# @param options [Hash] I18n options (interpolations, :locale, :
|
|
319
|
+
# @param options [Hash] I18n options (interpolations, :locale, :count,
|
|
320
|
+
# :default…) and :organization (a record or a String)
|
|
287
321
|
# @return [String]
|
|
288
|
-
def translate(key, **)
|
|
289
|
-
|
|
322
|
+
def translate(key, **options)
|
|
323
|
+
# A nil organization is no organization: the ordinary call stays unchanged.
|
|
324
|
+
options = options.except(:organization) if options.key?(:organization) && options[:organization].nil?
|
|
325
|
+
record = options[:organization].is_a?(Organizations::Organization) ? options[:organization] : nil
|
|
326
|
+
options = options.merge(organization: record.name) if record
|
|
327
|
+
vocabulary_key = vocabulary_key_for(key, record, options)
|
|
328
|
+
# The vocabulary key exists, so the caller's default (meant for the
|
|
329
|
+
# ordinary key) does not apply.
|
|
330
|
+
return I18n.t(vocabulary_key, scope: :organizations, **options.except(:default)) if vocabulary_key
|
|
331
|
+
|
|
332
|
+
I18n.t(key, scope: :organizations, **options)
|
|
290
333
|
end
|
|
334
|
+
|
|
335
|
+
# The I18n lookup chain for a key, most specific first: the vocabulary
|
|
336
|
+
# key (when config.vocabulary selects one for this organization), then
|
|
337
|
+
# the ordinary key. Absolute keys, for the Rails `t` view helper (needed
|
|
338
|
+
# for `_html` keys):
|
|
339
|
+
#
|
|
340
|
+
# primary, *fallbacks = Organizations.i18n_keys(:"mailers.invitation.invited_by_html", organization: org)
|
|
341
|
+
# t(primary, default: fallbacks, inviter: name, organization: org.name)
|
|
342
|
+
#
|
|
343
|
+
# @param key [String, Symbol]
|
|
344
|
+
# @param organization [Organizations::Organization, nil]
|
|
345
|
+
# @return [Array<Symbol>]
|
|
346
|
+
def i18n_keys(key, organization: nil)
|
|
347
|
+
ordinary = :"organizations.#{key}"
|
|
348
|
+
vocabulary = vocabulary_for(organization)
|
|
349
|
+
vocabulary ? [:"organizations.vocabularies.#{vocabulary}.#{key}", ordinary] : [ordinary]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# @api private
|
|
353
|
+
# The vocabulary key to use for this call, or nil for the ordinary call.
|
|
354
|
+
def vocabulary_key_for(key, record, options)
|
|
355
|
+
return nil if key.is_a?(Array) || options.key?(:scope)
|
|
356
|
+
|
|
357
|
+
vocabulary = vocabulary_for(record)
|
|
358
|
+
return nil unless vocabulary
|
|
359
|
+
|
|
360
|
+
candidate = :"vocabularies.#{vocabulary}.#{key}"
|
|
361
|
+
I18n.exists?(candidate, scope: :organizations, locale: options[:locale] || I18n.locale) ? candidate : nil
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# The vocabulary config.vocabulary selects: a Symbol/String for the whole
|
|
365
|
+
# app, or a callable that receives the organization (never called
|
|
366
|
+
# without one). nil selects the ordinary translations.
|
|
367
|
+
# @param organization [Organizations::Organization, nil]
|
|
368
|
+
# @return [String, nil]
|
|
369
|
+
# A resolver that raises, or returns a name that is not one plain label
|
|
370
|
+
# (letters, digits, underscores), selects the ordinary translations and
|
|
371
|
+
# logs a warning: wording must never break a page or an email.
|
|
372
|
+
def vocabulary_for(organization)
|
|
373
|
+
setting = configuration.vocabulary
|
|
374
|
+
setting = organization ? setting.call(organization) : nil if setting.respond_to?(:call)
|
|
375
|
+
name = setting.to_s.presence
|
|
376
|
+
return nil unless name
|
|
377
|
+
return name if name.match?(/\A[a-z0-9_]+\z/i)
|
|
378
|
+
|
|
379
|
+
Callbacks.log_warn("[Organizations] Ignored vocabulary #{name.inspect}: use letters, digits and underscores")
|
|
380
|
+
nil
|
|
381
|
+
rescue StandardError => e
|
|
382
|
+
Callbacks.log_warn("[Organizations] The vocabulary resolver failed: #{e.class}: #{e.message}")
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
|
|
291
386
|
alias t translate
|
|
292
387
|
end
|
|
293
388
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: organizations
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-19 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|
|
@@ -101,6 +101,7 @@ files:
|
|
|
101
101
|
- app/mailers/organizations/verification_mailer.rb
|
|
102
102
|
- app/models/organizations/allowlist_entry.rb
|
|
103
103
|
- app/models/organizations/domain.rb
|
|
104
|
+
- app/models/organizations/grant.rb
|
|
104
105
|
- app/models/organizations/invitation.rb
|
|
105
106
|
- app/models/organizations/join_code.rb
|
|
106
107
|
- app/models/organizations/join_request.rb
|
|
@@ -120,9 +121,20 @@ files:
|
|
|
120
121
|
- docs/organizations-team-members.webp
|
|
121
122
|
- gemfiles/rails_7.2.gemfile
|
|
122
123
|
- gemfiles/rails_8.1.gemfile
|
|
124
|
+
- lib/generators/organizations/grants/grants_generator.rb
|
|
125
|
+
- lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb
|
|
123
126
|
- lib/generators/organizations/install/install_generator.rb
|
|
124
127
|
- lib/generators/organizations/install/templates/create_organizations_tables.rb.erb
|
|
125
128
|
- lib/generators/organizations/install/templates/initializer.rb
|
|
129
|
+
- lib/generators/organizations/join/join_generator.rb
|
|
130
|
+
- lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb
|
|
131
|
+
- lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb
|
|
132
|
+
- lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb
|
|
133
|
+
- lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb
|
|
134
|
+
- lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb
|
|
135
|
+
- lib/generators/organizations/join/templates/routes.rb
|
|
136
|
+
- lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb
|
|
137
|
+
- lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb
|
|
126
138
|
- lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb
|
|
127
139
|
- lib/generators/organizations/upgrade/upgrade_generator.rb
|
|
128
140
|
- lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb
|
|
@@ -137,6 +149,7 @@ files:
|
|
|
137
149
|
- lib/generators/organizations/views/templates/organizations/organizations/show.html.erb
|
|
138
150
|
- lib/generators/organizations/views/views_generator.rb
|
|
139
151
|
- lib/organizations.rb
|
|
152
|
+
- lib/organizations/account_deletion_plan.rb
|
|
140
153
|
- lib/organizations/acts_as_tenant_integration.rb
|
|
141
154
|
- lib/organizations/callback_context.rb
|
|
142
155
|
- lib/organizations/callbacks.rb
|
|
@@ -145,6 +158,9 @@ files:
|
|
|
145
158
|
- lib/organizations/current_user_resolution.rb
|
|
146
159
|
- lib/organizations/email_normalizer.rb
|
|
147
160
|
- lib/organizations/engine.rb
|
|
161
|
+
- lib/organizations/grant_access.rb
|
|
162
|
+
- lib/organizations/grant_holder.rb
|
|
163
|
+
- lib/organizations/grantable.rb
|
|
148
164
|
- lib/organizations/invitation_acceptance_failure.rb
|
|
149
165
|
- lib/organizations/invitation_acceptance_result.rb
|
|
150
166
|
- lib/organizations/join_flow.rb
|
|
@@ -153,12 +169,16 @@ files:
|
|
|
153
169
|
- lib/organizations/models/allowlist_entry.rb
|
|
154
170
|
- lib/organizations/models/concerns/has_organizations.rb
|
|
155
171
|
- lib/organizations/models/domain.rb
|
|
172
|
+
- lib/organizations/models/grant.rb
|
|
156
173
|
- lib/organizations/models/invitation.rb
|
|
157
174
|
- lib/organizations/models/join_code.rb
|
|
158
175
|
- lib/organizations/models/join_request.rb
|
|
159
176
|
- lib/organizations/models/membership.rb
|
|
160
177
|
- lib/organizations/models/organization.rb
|
|
161
178
|
- lib/organizations/organization_scoped.rb
|
|
179
|
+
- lib/organizations/personal_owner_backfill.rb
|
|
180
|
+
- lib/organizations/public_email_domains.rb
|
|
181
|
+
- lib/organizations/resource.rb
|
|
162
182
|
- lib/organizations/roles.rb
|
|
163
183
|
- lib/organizations/test_helpers.rb
|
|
164
184
|
- lib/organizations/version.rb
|