organizations 0.4.3 → 0.5.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 +8 -9
- data/.rubocop_todo.yml +625 -0
- data/CHANGELOG.md +46 -0
- data/README.md +405 -41
- data/app/controllers/organizations/application_controller.rb +4 -0
- data/app/controllers/organizations/memberships_controller.rb +3 -3
- data/app/controllers/organizations/public_controller.rb +26 -0
- data/app/mailers/organizations/invitation_mailer.rb +38 -18
- data/app/mailers/organizations/verification_mailer.rb +56 -0
- data/app/models/organizations/allowlist_entry.rb +6 -0
- data/app/models/organizations/domain.rb +6 -0
- data/app/models/organizations/join_code.rb +6 -0
- data/app/models/organizations/join_request.rb +6 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
- data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
- data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
- data/config/locales/en.yml +158 -0
- data/config/locales/es.yml +126 -0
- data/config/routes.rb +46 -28
- data/gemfiles/rails_7.2.gemfile +3 -3
- data/gemfiles/rails_8.1.gemfile +3 -3
- data/lib/generators/organizations/install/install_generator.rb +3 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
- data/lib/generators/organizations/install/templates/initializer.rb +53 -0
- data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
- data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
- data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
- data/lib/generators/organizations/views/views_generator.rb +47 -0
- data/lib/organizations/callback_context.rb +9 -0
- data/lib/organizations/callbacks.rb +26 -18
- data/lib/organizations/configuration.rb +275 -1
- data/lib/organizations/controller_helpers.rb +38 -12
- data/lib/organizations/email_normalizer.rb +65 -0
- data/lib/organizations/join_flow.rb +227 -0
- data/lib/organizations/join_state.rb +117 -0
- data/lib/organizations/metadata_flags.rb +79 -0
- data/lib/organizations/models/allowlist_entry.rb +88 -0
- data/lib/organizations/models/concerns/has_organizations.rb +100 -12
- data/lib/organizations/models/domain.rb +85 -0
- data/lib/organizations/models/invitation.rb +105 -17
- data/lib/organizations/models/join_code.rb +242 -0
- data/lib/organizations/models/join_request.rb +627 -0
- data/lib/organizations/models/membership.rb +39 -8
- data/lib/organizations/models/organization.rb +317 -19
- data/lib/organizations/organization_scoped.rb +135 -0
- data/lib/organizations/test_helpers.rb +35 -1
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +8 -12
- data/lib/organizations.rb +165 -0
- metadata +34 -2
data/config/routes.rb
CHANGED
|
@@ -1,40 +1,58 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rubocop:disable Metrics/BlockLength -- a routes file is one declarative block by nature
|
|
3
4
|
Organizations::Engine.routes.draw do
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
|
|
5
|
+
# Route groups are drawn per config.engine_routes (devise_for-style
|
|
6
|
+
# only:/except:) so hosts declare what they want instead of shadowing
|
|
7
|
+
# engine routes with order-load-bearing redirects before the mount.
|
|
8
|
+
# Routes re-draw on every reload_routes!, so config is always respected.
|
|
9
|
+
groups = Organizations.configuration.engine_route_groups
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
if groups.include?(:switching)
|
|
12
|
+
# Organization switching
|
|
13
|
+
# POST /organizations/switch/:id
|
|
14
|
+
post "organizations/switch/:id", to: "switch#create", as: :switch_organization
|
|
15
|
+
end
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
if groups.include?(:organizations)
|
|
18
|
+
# Organization management
|
|
19
|
+
# All operations are scoped to current_organization (from session)
|
|
20
|
+
resources :organizations, only: [:index, :show, :new, :create, :edit, :update, :destroy]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if groups.include?(:memberships)
|
|
24
|
+
# Membership management (scoped to current_organization)
|
|
25
|
+
# These are flat routes - the organization is determined by session, not URL
|
|
26
|
+
resources :memberships, only: [:index, :update, :destroy] do
|
|
27
|
+
member do
|
|
28
|
+
post :transfer_ownership
|
|
29
|
+
end
|
|
30
|
+
collection do
|
|
31
|
+
delete :leave
|
|
32
|
+
end
|
|
20
33
|
end
|
|
21
34
|
end
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
if groups.include?(:invitations)
|
|
37
|
+
# Invitation management (scoped to current_organization)
|
|
38
|
+
# These are flat routes - the organization is determined by session, not URL
|
|
39
|
+
# NOTE: Must come BEFORE token-based routes so /invitations/new doesn't match /:token
|
|
40
|
+
resources :invitations, only: [:index, :new, :create, :destroy], as: :organization_invitations do
|
|
41
|
+
member do
|
|
42
|
+
post :resend
|
|
43
|
+
end
|
|
29
44
|
end
|
|
30
45
|
end
|
|
31
46
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
if groups.include?(:public_invitations)
|
|
48
|
+
# Invitation acceptance (public routes with token)
|
|
49
|
+
# These use PublicInvitationsController which inherits from a minimal base controller
|
|
50
|
+
# to avoid host app filters that might enforce authentication.
|
|
51
|
+
# GET /invitations/:token → View invitation details
|
|
52
|
+
# POST /invitations/:token/accept → Accept the invitation
|
|
53
|
+
# NOTE: These must come AFTER resourceful routes to avoid matching "new" as a token
|
|
54
|
+
get "invitations/:token", to: "public_invitations#show", as: :invitation
|
|
55
|
+
post "invitations/:token/accept", to: "public_invitations#accept", as: :accept_invitation
|
|
56
|
+
end
|
|
40
57
|
end
|
|
58
|
+
# rubocop:enable Metrics/BlockLength
|
data/gemfiles/rails_7.2.gemfile
CHANGED
|
@@ -7,9 +7,9 @@ gem "rails", "~> 7.2.3"
|
|
|
7
7
|
|
|
8
8
|
group :development do
|
|
9
9
|
gem "irb"
|
|
10
|
-
gem "rubocop", "~> 1.
|
|
11
|
-
gem "rubocop-minitest", "~> 0.
|
|
12
|
-
gem "rubocop-performance", "~> 1.
|
|
10
|
+
gem "rubocop", "~> 1.88"
|
|
11
|
+
gem "rubocop-minitest", "~> 0.39"
|
|
12
|
+
gem "rubocop-performance", "~> 1.26"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
group :development, :test do
|
data/gemfiles/rails_8.1.gemfile
CHANGED
|
@@ -7,9 +7,9 @@ gem "rails", "~> 8.1.2"
|
|
|
7
7
|
|
|
8
8
|
group :development do
|
|
9
9
|
gem "irb"
|
|
10
|
-
gem "rubocop", "~> 1.
|
|
11
|
-
gem "rubocop-minitest", "~> 0.
|
|
12
|
-
gem "rubocop-performance", "~> 1.
|
|
10
|
+
gem "rubocop", "~> 1.88"
|
|
11
|
+
gem "rubocop-minitest", "~> 0.39"
|
|
12
|
+
gem "rubocop-performance", "~> 1.26"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
group :development, :test do
|
|
@@ -30,6 +30,9 @@ module Organizations
|
|
|
30
30
|
say " 2. Review and customize 'config/initializers/organizations.rb'."
|
|
31
31
|
say " 3. Add 'has_organizations' to your User model."
|
|
32
32
|
say " 4. Mount the engine in your routes: mount Organizations::Engine => '/'"
|
|
33
|
+
say " 5. Run 'rails g organizations:views' to copy the reference views"
|
|
34
|
+
say " (the engine renders HOST views — without them you'll hit"
|
|
35
|
+
say " missing-template errors on the org/membership/invitation pages)."
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
private
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %>
|
|
4
4
|
def change
|
|
5
|
+
# ⚠️ If your user model is NOT `User` (config.user_class), adjust every
|
|
6
|
+
# `to_table: :users` below (and `foreign_key: true` on user references)
|
|
7
|
+
# to your user table before migrating.
|
|
5
8
|
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
|
6
9
|
adapter = connection.adapter_name.downcase
|
|
7
10
|
|
|
@@ -22,20 +25,42 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
|
|
|
22
25
|
t.string :role, null: false, default: "member"
|
|
23
26
|
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
24
27
|
|
|
28
|
+
# Verified-joining provenance: how this member joined and which email
|
|
29
|
+
# address they proved control of (if any).
|
|
30
|
+
t.string :joined_via
|
|
31
|
+
t.string :verified_email
|
|
32
|
+
t.string :verified_email_normalized
|
|
33
|
+
t.datetime :verified_at
|
|
34
|
+
|
|
25
35
|
t.timestamps
|
|
26
36
|
end
|
|
27
37
|
|
|
28
|
-
add_index :organizations_memberships, [:user_id, :organization_id], unique: true
|
|
38
|
+
add_index :organizations_memberships, [ :user_id, :organization_id ], unique: true
|
|
29
39
|
add_index :organizations_memberships, :role
|
|
30
40
|
|
|
41
|
+
# One proven email address => one membership per organization.
|
|
42
|
+
# NULLs never collide, so unverified memberships are unconstrained —
|
|
43
|
+
# a plain unique index gives identical semantics on every adapter.
|
|
44
|
+
add_index :organizations_memberships, [ :organization_id, :verified_email_normalized ],
|
|
45
|
+
unique: true, name: "index_org_memberships_verified_email_unique"
|
|
46
|
+
|
|
31
47
|
# Enforce "at most one owner membership per organization" at DB level where possible.
|
|
32
48
|
# Both PostgreSQL and SQLite support partial indexes with identical syntax.
|
|
49
|
+
# ⚠️ Partial-index SQL stays on ONE line on purpose: the SQLite schema
|
|
50
|
+
# dumper only recovers the WHERE clause from single-line index SQL — a
|
|
51
|
+
# multi-line statement dumps as a FULL unique index, and databases
|
|
52
|
+
# provisioned from schema.rb (db:schema:load, test DBs) silently lose
|
|
53
|
+
# the partial-index invariant.
|
|
54
|
+
# `reversible` + up-only: raw execute in `def change` would raise
|
|
55
|
+
# IrreversibleMigration on rollback; the indexes/columns die with their
|
|
56
|
+
# tables when the reversed create_table drops them, so down is a no-op.
|
|
33
57
|
if adapter.include?("postgresql") || adapter.include?("sqlite")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
58
|
+
reversible do |dir|
|
|
59
|
+
dir.up do
|
|
60
|
+
execute "CREATE UNIQUE INDEX index_organizations_memberships_single_owner " \
|
|
61
|
+
"ON organizations_memberships (organization_id) WHERE role = 'owner'"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
39
64
|
end
|
|
40
65
|
|
|
41
66
|
# Invitations table
|
|
@@ -48,6 +73,10 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
|
|
|
48
73
|
t.string :role, null: false, default: "member"
|
|
49
74
|
t.datetime :accepted_at
|
|
50
75
|
t.datetime :expires_at
|
|
76
|
+
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
77
|
+
# Copied onto the membership created when this invitation is accepted
|
|
78
|
+
# (cohort tags etc. — the gem never interprets the contents)
|
|
79
|
+
t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
|
|
51
80
|
|
|
52
81
|
t.timestamps
|
|
53
82
|
end
|
|
@@ -57,30 +86,146 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
|
|
|
57
86
|
|
|
58
87
|
# Unique partial index: only one pending (non-accepted) invitation per email per org
|
|
59
88
|
# Both PostgreSQL and SQLite (3.8.0+) support partial indexes with identical syntax.
|
|
89
|
+
# (One line on purpose — see the single_owner index note above.)
|
|
60
90
|
if adapter.include?("postgresql") || adapter.include?("sqlite")
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
91
|
+
reversible do |dir|
|
|
92
|
+
dir.up do
|
|
93
|
+
execute "CREATE UNIQUE INDEX index_organizations_invitations_pending_unique " \
|
|
94
|
+
"ON organizations_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
66
97
|
elsif adapter.include?("mysql")
|
|
67
98
|
# MySQL doesn't support partial indexes, so we use a generated column that is
|
|
68
99
|
# only non-NULL for pending invitations and enforce uniqueness on that value.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
100
|
+
reversible do |dir|
|
|
101
|
+
dir.up do
|
|
102
|
+
execute <<-SQL
|
|
103
|
+
ALTER TABLE organizations_invitations
|
|
104
|
+
ADD COLUMN pending_email VARCHAR(255)
|
|
105
|
+
GENERATED ALWAYS AS (
|
|
106
|
+
CASE
|
|
107
|
+
WHEN accepted_at IS NULL THEN LOWER(email)
|
|
108
|
+
ELSE NULL
|
|
109
|
+
END
|
|
110
|
+
) STORED
|
|
111
|
+
SQL
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
add_index :organizations_invitations, [ :organization_id, :pending_email ], unique: true, name: "index_organizations_invitations_pending_unique"
|
|
81
116
|
else
|
|
82
117
|
# For other adapters, fall back to app-level validation.
|
|
83
|
-
add_index :organizations_invitations, [:organization_id, :email], name: "index_organizations_invitations_on_org_and_email"
|
|
118
|
+
add_index :organizations_invitations, [ :organization_id, :email ], name: "index_organizations_invitations_on_org_and_email"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# === Verified joining ===
|
|
122
|
+
|
|
123
|
+
# Email domains owned by an organization (exact-match joining)
|
|
124
|
+
create_table :organizations_domains, id: primary_key_type do |t|
|
|
125
|
+
t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
|
|
126
|
+
t.string :domain, null: false
|
|
127
|
+
t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
|
|
128
|
+
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
129
|
+
|
|
130
|
+
t.timestamps
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
add_index :organizations_domains, [ :organization_id, :domain ], unique: true
|
|
134
|
+
add_index :organizations_domains, :domain
|
|
135
|
+
|
|
136
|
+
# Shareable join codes ("PINs") — globally unique, rotatable, cappable
|
|
137
|
+
create_table :organizations_join_codes, id: primary_key_type do |t|
|
|
138
|
+
t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
|
|
139
|
+
t.string :code, null: false
|
|
140
|
+
t.string :label
|
|
141
|
+
t.boolean :requires_verified_domain_email, null: false, default: false
|
|
142
|
+
t.boolean :auto_approve, null: false, default: true
|
|
143
|
+
t.datetime :expires_at
|
|
144
|
+
t.integer :max_uses
|
|
145
|
+
t.integer :uses_count, null: false, default: 0
|
|
146
|
+
t.datetime :revoked_at
|
|
147
|
+
t.references :created_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
|
|
148
|
+
t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
|
|
149
|
+
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
150
|
+
|
|
151
|
+
t.timestamps
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
add_index :organizations_join_codes, :code, unique: true
|
|
155
|
+
|
|
156
|
+
# Pre-approved roster emails (allowlist joining)
|
|
157
|
+
create_table :organizations_allowlist_entries, id: primary_key_type do |t|
|
|
158
|
+
t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
|
|
159
|
+
t.string :email, null: false
|
|
160
|
+
t.string :email_normalized, null: false
|
|
161
|
+
t.string :source
|
|
162
|
+
t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
|
|
163
|
+
t.datetime :claimed_at
|
|
164
|
+
t.references :claimed_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
|
|
165
|
+
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
166
|
+
|
|
167
|
+
t.timestamps
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
add_index :organizations_allowlist_entries, [ :organization_id, :email_normalized ], unique: true
|
|
171
|
+
|
|
172
|
+
# Join requests (request-to-join workflow + email-verification challenges)
|
|
173
|
+
create_table :organizations_join_requests, id: primary_key_type do |t|
|
|
174
|
+
t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
|
|
175
|
+
t.references :user, null: false, type: foreign_key_type, foreign_key: true
|
|
176
|
+
t.string :status, null: false, default: "pending"
|
|
177
|
+
t.string :joined_via
|
|
178
|
+
t.references :join_code, null: true, type: foreign_key_type, foreign_key: { to_table: :organizations_join_codes }
|
|
179
|
+
t.string :message
|
|
180
|
+
t.string :verification_email
|
|
181
|
+
t.string :verification_email_normalized
|
|
182
|
+
t.string :verification_code_digest
|
|
183
|
+
t.datetime :verification_sent_at
|
|
184
|
+
t.datetime :verification_expires_at
|
|
185
|
+
t.integer :verification_attempts, null: false, default: 0
|
|
186
|
+
t.integer :verification_sends_count, null: false, default: 0
|
|
187
|
+
t.datetime :verified_at
|
|
188
|
+
t.references :decided_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
|
|
189
|
+
t.datetime :decided_at
|
|
190
|
+
t.datetime :expires_at
|
|
191
|
+
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
192
|
+
|
|
193
|
+
t.timestamps
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
add_index :organizations_join_requests, :status
|
|
197
|
+
|
|
198
|
+
# One OPEN request per (organization, user). Decided requests keep their
|
|
199
|
+
# history without blocking a fresh request.
|
|
200
|
+
# (One line on purpose — see the single_owner index note above.)
|
|
201
|
+
if adapter.include?("postgresql") || adapter.include?("sqlite")
|
|
202
|
+
reversible do |dir|
|
|
203
|
+
dir.up do
|
|
204
|
+
execute "CREATE UNIQUE INDEX index_org_join_requests_pending_unique " \
|
|
205
|
+
"ON organizations_join_requests (organization_id, user_id) WHERE status = 'pending'"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
elsif adapter.include?("mysql")
|
|
209
|
+
# MySQL doesn't support partial indexes: use a generated column that is
|
|
210
|
+
# only non-NULL for pending requests (NULLs never collide in unique indexes).
|
|
211
|
+
reversible do |dir|
|
|
212
|
+
dir.up do
|
|
213
|
+
execute <<-SQL
|
|
214
|
+
ALTER TABLE organizations_join_requests
|
|
215
|
+
ADD COLUMN pending_user_key VARCHAR(255)
|
|
216
|
+
GENERATED ALWAYS AS (
|
|
217
|
+
CASE
|
|
218
|
+
WHEN status = 'pending' THEN CONCAT(organization_id, '-', user_id)
|
|
219
|
+
ELSE NULL
|
|
220
|
+
END
|
|
221
|
+
) STORED
|
|
222
|
+
SQL
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
add_index :organizations_join_requests, :pending_user_key, unique: true, name: "index_org_join_requests_pending_unique"
|
|
227
|
+
else
|
|
228
|
+
add_index :organizations_join_requests, [ :organization_id, :user_id ], name: "index_org_join_requests_on_org_and_user"
|
|
84
229
|
end
|
|
85
230
|
end
|
|
86
231
|
|
|
@@ -88,7 +233,7 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
|
|
|
88
233
|
|
|
89
234
|
def primary_and_foreign_key_types
|
|
90
235
|
config = Rails.configuration.generators
|
|
91
|
-
setting = config.options[config.orm][:primary_key_type]
|
|
236
|
+
setting = config.options[config.orm][ :primary_key_type ]
|
|
92
237
|
primary_key_type = setting || :primary_key
|
|
93
238
|
foreign_key_type = setting || :bigint
|
|
94
239
|
[primary_key_type, foreign_key_type]
|
|
@@ -55,6 +55,59 @@ Organizations.configure do |config|
|
|
|
55
55
|
# Default: :member
|
|
56
56
|
# config.default_invitation_role = :member
|
|
57
57
|
|
|
58
|
+
# ============================================================================
|
|
59
|
+
# VERIFIED JOINING (join requests, domains, join codes, allowlists)
|
|
60
|
+
# ============================================================================
|
|
61
|
+
#
|
|
62
|
+
# Users can join organizations by proving they belong:
|
|
63
|
+
# - request-to-join: user.request_to_join!(org) → org.approve_join_request!(...)
|
|
64
|
+
# - email domain: org.add_domain!("corp.com") + emailed 6-digit code
|
|
65
|
+
# - join code (PIN): org.generate_join_code!(...) + JoinCode.redeem(code, user:)
|
|
66
|
+
# - allowlist/roster: org.import_allowlist!(emails) + emailed 6-digit code
|
|
67
|
+
#
|
|
68
|
+
# The gem ships models and APIs only (bring your own UI + rate limiting on
|
|
69
|
+
# your join endpoints). See the README's "Verified joining" section.
|
|
70
|
+
|
|
71
|
+
# Mailer that sends verification codes. Custom mailers must implement
|
|
72
|
+
# code_email(join_request, code).
|
|
73
|
+
# Default: "Organizations::VerificationMailer"
|
|
74
|
+
# config.verification_mailer = "Organizations::VerificationMailer"
|
|
75
|
+
|
|
76
|
+
# How long an emailed code stays valid (codes always expire).
|
|
77
|
+
# Default: 15.minutes
|
|
78
|
+
# config.verification_code_ttl = 15.minutes
|
|
79
|
+
|
|
80
|
+
# Wrong-attempt cap per challenge, and send throttles per join request.
|
|
81
|
+
# Defaults: 5 attempts, 60.seconds between sends, 5 sends max
|
|
82
|
+
# config.verification_max_attempts = 5
|
|
83
|
+
# config.verification_resend_interval = 60.seconds
|
|
84
|
+
# config.verification_max_sends = 5
|
|
85
|
+
|
|
86
|
+
# Email normalizer used for the "one proven email = one membership per org"
|
|
87
|
+
# invariant. Default collapses case, whitespace, and +tags (plus-addressing).
|
|
88
|
+
# config.verification_email_normalizer = ->(email) { ... }
|
|
89
|
+
|
|
90
|
+
# Allow org.join_with_account_email!(user) to trust a CONFIRMED account
|
|
91
|
+
# email (e.g. Devise :confirmable) as inbox proof — no emailed code needed
|
|
92
|
+
# when the account email's domain is enrolled.
|
|
93
|
+
# Default: true
|
|
94
|
+
# config.trust_confirmed_account_email = true
|
|
95
|
+
|
|
96
|
+
# How long join requests stay open before reading as expired (nil = never).
|
|
97
|
+
# Default: 30.days
|
|
98
|
+
# config.join_request_expiry = 30.days
|
|
99
|
+
|
|
100
|
+
# Custom join-code generator (default: 8 chars, ambiguity-free alphabet).
|
|
101
|
+
# config.join_code_generator = -> { SecureRandom.alphanumeric(10).upcase }
|
|
102
|
+
|
|
103
|
+
# Lifecycle callbacks:
|
|
104
|
+
# config.on_join_request_created { |ctx| ... } # ctx: organization, user, join_request
|
|
105
|
+
# config.on_join_request_approved { |ctx| ... } # + membership, decided_by (nil = auto)
|
|
106
|
+
# config.on_join_request_rejected { |ctx| ... } # + decided_by
|
|
107
|
+
#
|
|
108
|
+
# NOTE: like all after-callbacks these are error-isolated — enforce hard
|
|
109
|
+
# caps (member limits etc.) BEFORE calling approve/redeem, in your own code.
|
|
110
|
+
|
|
58
111
|
# ============================================================================
|
|
59
112
|
# ROLES & PERMISSIONS
|
|
60
113
|
# ============================================================================
|
data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Upgrades organizations <= 0.4.x to 0.5.0 ("Verified Joining").
|
|
4
|
+
# Additive only: no existing columns/rows are touched.
|
|
5
|
+
class AddVerifiedJoiningToOrganizations < ActiveRecord::Migration<%= migration_version %>
|
|
6
|
+
def change
|
|
7
|
+
# ⚠️ If your user model is NOT `User` (config.user_class), adjust every
|
|
8
|
+
# `to_table: :users` below (and `foreign_key: true` on user references)
|
|
9
|
+
# to your user table before migrating.
|
|
10
|
+
_primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
|
11
|
+
adapter = connection.adapter_name.downcase
|
|
12
|
+
|
|
13
|
+
# === Membership provenance ===
|
|
14
|
+
|
|
15
|
+
add_column :organizations_memberships, :joined_via, :string
|
|
16
|
+
add_column :organizations_memberships, :verified_email, :string
|
|
17
|
+
add_column :organizations_memberships, :verified_email_normalized, :string
|
|
18
|
+
add_column :organizations_memberships, :verified_at, :datetime
|
|
19
|
+
|
|
20
|
+
# One proven email address => one membership per organization.
|
|
21
|
+
# NULLs never collide in unique indexes on every supported adapter, so
|
|
22
|
+
# existing (unverified) memberships are unconstrained.
|
|
23
|
+
add_index :organizations_memberships, [ :organization_id, :verified_email_normalized ],
|
|
24
|
+
unique: true, name: "index_org_memberships_verified_email_unique"
|
|
25
|
+
|
|
26
|
+
# === Invitation metadata parity ===
|
|
27
|
+
|
|
28
|
+
add_column :organizations_invitations, :metadata, json_column_type,
|
|
29
|
+
null: json_column_null, default: json_column_default
|
|
30
|
+
add_column :organizations_invitations, :membership_metadata, json_column_type,
|
|
31
|
+
null: json_column_null, default: json_column_default
|
|
32
|
+
|
|
33
|
+
# === Email domains (exact-match joining) ===
|
|
34
|
+
|
|
35
|
+
create_table :organizations_domains, id: primary_key_type_setting do |t|
|
|
36
|
+
t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
|
|
37
|
+
t.string :domain, null: false
|
|
38
|
+
t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
|
|
39
|
+
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
40
|
+
|
|
41
|
+
t.timestamps
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
add_index :organizations_domains, [ :organization_id, :domain ], unique: true
|
|
45
|
+
add_index :organizations_domains, :domain
|
|
46
|
+
|
|
47
|
+
# === Join codes ("PINs") ===
|
|
48
|
+
|
|
49
|
+
create_table :organizations_join_codes, id: primary_key_type_setting do |t|
|
|
50
|
+
t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
|
|
51
|
+
t.string :code, null: false
|
|
52
|
+
t.string :label
|
|
53
|
+
t.boolean :requires_verified_domain_email, null: false, default: false
|
|
54
|
+
t.boolean :auto_approve, null: false, default: true
|
|
55
|
+
t.datetime :expires_at
|
|
56
|
+
t.integer :max_uses
|
|
57
|
+
t.integer :uses_count, null: false, default: 0
|
|
58
|
+
t.datetime :revoked_at
|
|
59
|
+
t.references :created_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
|
|
60
|
+
t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
|
|
61
|
+
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
62
|
+
|
|
63
|
+
t.timestamps
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
add_index :organizations_join_codes, :code, unique: true
|
|
67
|
+
|
|
68
|
+
# === Allowlist / roster entries ===
|
|
69
|
+
|
|
70
|
+
create_table :organizations_allowlist_entries, id: primary_key_type_setting do |t|
|
|
71
|
+
t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
|
|
72
|
+
t.string :email, null: false
|
|
73
|
+
t.string :email_normalized, null: false
|
|
74
|
+
t.string :source
|
|
75
|
+
t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
|
|
76
|
+
t.datetime :claimed_at
|
|
77
|
+
t.references :claimed_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
|
|
78
|
+
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
79
|
+
|
|
80
|
+
t.timestamps
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
add_index :organizations_allowlist_entries, [ :organization_id, :email_normalized ], unique: true
|
|
84
|
+
|
|
85
|
+
# === Join requests ===
|
|
86
|
+
|
|
87
|
+
create_table :organizations_join_requests, id: primary_key_type_setting do |t|
|
|
88
|
+
t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
|
|
89
|
+
t.references :user, null: false, type: foreign_key_type, foreign_key: true
|
|
90
|
+
t.string :status, null: false, default: "pending"
|
|
91
|
+
t.string :joined_via
|
|
92
|
+
t.references :join_code, null: true, type: foreign_key_type, foreign_key: { to_table: :organizations_join_codes }
|
|
93
|
+
t.string :message
|
|
94
|
+
t.string :verification_email
|
|
95
|
+
t.string :verification_email_normalized
|
|
96
|
+
t.string :verification_code_digest
|
|
97
|
+
t.datetime :verification_sent_at
|
|
98
|
+
t.datetime :verification_expires_at
|
|
99
|
+
t.integer :verification_attempts, null: false, default: 0
|
|
100
|
+
t.integer :verification_sends_count, null: false, default: 0
|
|
101
|
+
t.datetime :verified_at
|
|
102
|
+
t.references :decided_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
|
|
103
|
+
t.datetime :decided_at
|
|
104
|
+
t.datetime :expires_at
|
|
105
|
+
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
106
|
+
|
|
107
|
+
t.timestamps
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
add_index :organizations_join_requests, :status
|
|
111
|
+
|
|
112
|
+
# One OPEN request per (organization, user); decided history never blocks
|
|
113
|
+
if adapter.include?("postgresql") || adapter.include?("sqlite")
|
|
114
|
+
reversible do |dir|
|
|
115
|
+
dir.up do
|
|
116
|
+
# ONE line on purpose: the SQLite schema dumper only recovers a
|
|
117
|
+
# partial index's WHERE clause from single-line index SQL — a
|
|
118
|
+
# multi-line statement dumps as a FULL unique index, and databases
|
|
119
|
+
# provisioned from schema.rb (db:schema:load, test DBs) silently
|
|
120
|
+
# lose the partial-index invariant.
|
|
121
|
+
execute "CREATE UNIQUE INDEX index_org_join_requests_pending_unique " \
|
|
122
|
+
"ON organizations_join_requests (organization_id, user_id) WHERE status = 'pending'"
|
|
123
|
+
end
|
|
124
|
+
dir.down do
|
|
125
|
+
execute "DROP INDEX IF EXISTS index_org_join_requests_pending_unique"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
elsif adapter.include?("mysql")
|
|
129
|
+
reversible do |dir|
|
|
130
|
+
dir.up do
|
|
131
|
+
execute <<-SQL
|
|
132
|
+
ALTER TABLE organizations_join_requests
|
|
133
|
+
ADD COLUMN pending_user_key VARCHAR(255)
|
|
134
|
+
GENERATED ALWAYS AS (
|
|
135
|
+
CASE
|
|
136
|
+
WHEN status = 'pending' THEN CONCAT(organization_id, '-', user_id)
|
|
137
|
+
ELSE NULL
|
|
138
|
+
END
|
|
139
|
+
) STORED
|
|
140
|
+
SQL
|
|
141
|
+
end
|
|
142
|
+
dir.down do
|
|
143
|
+
execute "ALTER TABLE organizations_join_requests DROP COLUMN pending_user_key"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
add_index :organizations_join_requests, :pending_user_key, unique: true, name: "index_org_join_requests_pending_unique"
|
|
147
|
+
else
|
|
148
|
+
add_index :organizations_join_requests, [ :organization_id, :user_id ], name: "index_org_join_requests_on_org_and_user"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def primary_and_foreign_key_types
|
|
155
|
+
config = Rails.configuration.generators
|
|
156
|
+
setting = config.options[config.orm][ :primary_key_type ]
|
|
157
|
+
primary_key_type = setting || :primary_key
|
|
158
|
+
foreign_key_type = setting || :bigint
|
|
159
|
+
[primary_key_type, foreign_key_type]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def primary_key_type_setting
|
|
163
|
+
primary_and_foreign_key_types.first
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def json_column_type
|
|
167
|
+
return :jsonb if connection.adapter_name.downcase.include?('postgresql')
|
|
168
|
+
:json
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# MySQL 8+ doesn't allow default values on JSON columns.
|
|
172
|
+
def json_column_default
|
|
173
|
+
return nil if connection.adapter_name.downcase.include?('mysql')
|
|
174
|
+
{}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def json_column_null
|
|
178
|
+
connection.adapter_name.downcase.include?('mysql')
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Organizations
|
|
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.
|
|
12
|
+
#
|
|
13
|
+
# Fresh installs don't need this — `rails g organizations:install`
|
|
14
|
+
# already includes everything.
|
|
15
|
+
#
|
|
16
|
+
# rails g organizations:upgrade
|
|
17
|
+
# rails db:migrate
|
|
18
|
+
#
|
|
19
|
+
class UpgradeGenerator < Rails::Generators::Base
|
|
20
|
+
include ActiveRecord::Generators::Migration
|
|
21
|
+
|
|
22
|
+
source_root File.expand_path("templates", __dir__)
|
|
23
|
+
desc "Add the verified-joining tables/columns (organizations 0.5.0) to an existing install"
|
|
24
|
+
|
|
25
|
+
def self.next_migration_number(dir)
|
|
26
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
27
|
+
end
|
|
28
|
+
|
|
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
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def display_post_upgrade_message
|
|
36
|
+
say "\n✅ organizations verified-joining migration created.", :green
|
|
37
|
+
say "\nNext steps:"
|
|
38
|
+
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)."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def migration_version
|
|
46
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|