organizations 0.4.2 → 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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -9
  3. data/.rubocop_todo.yml +625 -0
  4. data/CHANGELOG.md +53 -0
  5. data/README.md +414 -46
  6. data/app/controllers/organizations/application_controller.rb +4 -0
  7. data/app/controllers/organizations/memberships_controller.rb +3 -3
  8. data/app/controllers/organizations/public_controller.rb +26 -0
  9. data/app/mailers/organizations/invitation_mailer.rb +38 -18
  10. data/app/mailers/organizations/verification_mailer.rb +56 -0
  11. data/app/models/organizations/allowlist_entry.rb +6 -0
  12. data/app/models/organizations/domain.rb +6 -0
  13. data/app/models/organizations/join_code.rb +6 -0
  14. data/app/models/organizations/join_request.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
  19. data/config/locales/en.yml +158 -0
  20. data/config/locales/es.yml +126 -0
  21. data/config/routes.rb +46 -28
  22. data/gemfiles/rails_7.2.gemfile +3 -3
  23. data/gemfiles/rails_8.1.gemfile +3 -3
  24. data/lib/generators/organizations/install/install_generator.rb +3 -0
  25. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
  26. data/lib/generators/organizations/install/templates/initializer.rb +53 -0
  27. data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
  28. data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
  29. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
  30. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
  31. data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
  32. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
  33. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
  34. data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
  35. data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
  36. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
  37. data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
  38. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
  39. data/lib/generators/organizations/views/views_generator.rb +47 -0
  40. data/lib/organizations/callback_context.rb +9 -0
  41. data/lib/organizations/callbacks.rb +26 -18
  42. data/lib/organizations/configuration.rb +275 -1
  43. data/lib/organizations/controller_helpers.rb +38 -12
  44. data/lib/organizations/email_normalizer.rb +65 -0
  45. data/lib/organizations/join_flow.rb +227 -0
  46. data/lib/organizations/join_state.rb +117 -0
  47. data/lib/organizations/metadata_flags.rb +79 -0
  48. data/lib/organizations/models/allowlist_entry.rb +88 -0
  49. data/lib/organizations/models/concerns/has_organizations.rb +100 -12
  50. data/lib/organizations/models/domain.rb +85 -0
  51. data/lib/organizations/models/invitation.rb +105 -17
  52. data/lib/organizations/models/join_code.rb +242 -0
  53. data/lib/organizations/models/join_request.rb +627 -0
  54. data/lib/organizations/models/membership.rb +39 -8
  55. data/lib/organizations/models/organization.rb +317 -19
  56. data/lib/organizations/organization_scoped.rb +135 -0
  57. data/lib/organizations/test_helpers.rb +35 -1
  58. data/lib/organizations/version.rb +1 -1
  59. data/lib/organizations/view_helpers.rb +27 -19
  60. data/lib/organizations.rb +165 -0
  61. metadata +34 -2
@@ -0,0 +1,126 @@
1
+ # Organizations gem — Spanish locale.
2
+ #
3
+ # Ships with the gem so Spanish-market hosts get correct end-user copy out of
4
+ # the box (the first two production hosts of this gem are Spanish products).
5
+ # Keep key parity with en.yml — en.yml is the catalog SSOT; this file must
6
+ # always answer the same keys. Tone: neutral "tú", consistent with typical
7
+ # Spanish SaaS copy.
8
+ es:
9
+ organizations:
10
+ roles:
11
+ owner: "Propietario"
12
+ admin: "Administrador"
13
+ member: "Miembro"
14
+ viewer: "Observador"
15
+
16
+ invitation_status:
17
+ pending: "Pendiente"
18
+ accepted: "Aceptada"
19
+ expired: "Caducada"
20
+
21
+ # En minúsculas a propósito: se interpolan a mitad de frase en
22
+ # errors.join_request_already_decided.
23
+ join_request_status:
24
+ pending: "pendiente"
25
+ approved: "aprobada"
26
+ rejected: "rechazada"
27
+ withdrawn: "retirada"
28
+ expired: "caducada"
29
+
30
+ errors:
31
+ join_code_invalid: "Este código no es válido"
32
+ join_code_exhausted: "Este código ya ha alcanzado su límite de usos"
33
+
34
+ join_request_expired: "Esta solicitud ha caducado"
35
+ join_request_already_decided: "Esta solicitud ya está %{status}"
36
+ join_request_already_member: "Ya eres miembro de esta organización"
37
+ join_request_create_failed: "No se ha podido crear la solicitud"
38
+ join_request_membership_gone: "La solicitud se aprobó pero la membresía ya no existe"
39
+
40
+ membership_vetoed: "Ahora mismo no puedes unirte a esta organización"
41
+
42
+ verification_email_invalid: "Esta dirección de email no es válida"
43
+ verification_email_not_eligible: "Ese email no pertenece a esta organización"
44
+ verification_email_already_claimed: "Ese email ya está asociado a un miembro de esta organización"
45
+ verification_code_invalid: "El código no es correcto"
46
+ verification_code_missing: "No hay ningún código de verificación activo para esta solicitud"
47
+ verification_code_expired: "El código ha caducado — pide uno nuevo"
48
+ verification_attempts_exceeded: "Demasiados intentos — pide un código nuevo"
49
+ verification_sends_exceeded: "Se han pedido demasiados códigos para esta solicitud"
50
+ verification_resend_throttled: "Espera un momento antes de pedir otro código"
51
+ account_email_trust_disabled: "La verificación por email de cuenta está desactivada (config.trust_confirmed_account_email)"
52
+ account_email_unconfirmed: "El email de la cuenta no está confirmado"
53
+ not_accepting_requests: "Esta organización no acepta solicitudes ahora mismo"
54
+
55
+ invitation_expired: "Esta invitación ha caducado"
56
+ invitation_already_accepted: "Esta invitación ya ha sido aceptada"
57
+ invitation_cannot_resend_accepted: "No se puede reenviar una invitación ya aceptada"
58
+ invitation_email_mismatch: "Esta invitación se envió a otra dirección de email"
59
+ invitation_accept_as_owner: "No se puede aceptar una invitación como propietario. Invita como administrador y transfiere la propiedad después."
60
+ invitation_already_member: "Esa persona ya es miembro de esta organización"
61
+ invite_as_owner: "No se puede invitar como propietario. Invita como administrador y transfiere la propiedad después."
62
+ invite_not_a_member: "Solo los miembros de la organización pueden enviar invitaciones"
63
+ invite_not_authorized: "No tienes permiso para invitar miembros"
64
+
65
+ cannot_remove_owner: "No se puede eliminar al propietario de la organización. Transfiere la propiedad primero."
66
+ cannot_add_as_owner: "No se puede añadir un miembro como propietario. Usa la transferencia de propiedad."
67
+ cannot_promote_to_owner: "No se puede ascender a propietario. Usa la transferencia de propiedad."
68
+ cannot_demote_owner_directly: "No se puede degradar al propietario directamente. Usa la transferencia de propiedad."
69
+ cannot_demote_owner: "No se puede degradar al propietario. Transfiere la propiedad primero."
70
+ promote_not_higher: "No se puede ascender a %{new_role}: no es un rol superior a %{role}"
71
+ demote_not_lower: "No se puede degradar a %{new_role}: no es un rol inferior a %{role}"
72
+ transfer_no_owner: "No se puede transferir la propiedad porque la organización no tiene propietario"
73
+ transfer_to_non_member: "No se puede transferir la propiedad a alguien que no es miembro"
74
+ transfer_to_non_admin: "No se puede transferir la propiedad a alguien que no es administrador. Asciéndele primero."
75
+
76
+ cannot_leave_as_last_owner: "No puedes salir de la organización siendo su único propietario. Transfiere la propiedad primero."
77
+ cannot_leave_last_organization: "No puedes salir de tu única organización"
78
+ organization_limit_reached: "Has alcanzado el máximo de organizaciones (%{max})"
79
+ no_current_organization_to_leave: "No hay ninguna organización activa de la que salir"
80
+ cannot_delete_user_owns_organizations: "No se puede eliminar un usuario que aún es propietario de organizaciones. Transfiere la propiedad o elimina esas organizaciones primero."
81
+
82
+ unauthorized_role: "Necesitas acceso de %{role} para hacer esto"
83
+ unauthorized_permission: "No tienes permiso para %{permission}"
84
+ unauthorized: "No estás autorizado para hacer esto"
85
+ not_a_member: "No eres miembro de esta organización"
86
+ organization_required: "Se requiere una organización"
87
+ cannot_change_own_role: "No puedes cambiar tu propio rol"
88
+ invalid_role: "Rol no válido: %{role}"
89
+
90
+ notices:
91
+ welcome: "¡Ya formas parte de %{organization}!"
92
+ already_member: "Ya eres miembro de %{organization}."
93
+ select_or_create_organization: "Selecciona o crea una organización."
94
+
95
+ attributes:
96
+ membership_taken: "ya es miembro de esta organización"
97
+ owner_taken: "ya existe un propietario para esta organización"
98
+ pending_request_taken: "ya tiene una solicitud pendiente para esta organización"
99
+ invitation_taken: "ya ha sido invitado a esta organización"
100
+ allowlist_taken: "ya está en la lista de esta organización"
101
+ domain_invalid: "no es un nombre de dominio válido"
102
+
103
+ mailers:
104
+ from_team: "El equipo"
105
+ invitation:
106
+ subject: "%{inviter} te ha invitado a unirte a %{organization}"
107
+ header: "¡Te han invitado!"
108
+ title: "¡Te han invitado a unirte a %{organization}!"
109
+ greeting: "Hola,"
110
+ invited_by: "%{inviter} te ha invitado a unirte a %{organization}."
111
+ invited_by_html: "<strong>%{inviter}</strong> te ha invitado a unirte a <strong>%{organization}</strong>."
112
+ invited_generic: "Te han invitado a unirte a %{organization}."
113
+ invited_generic_html: "Te han invitado a unirte a <strong>%{organization}</strong>."
114
+ joining_as: "Te unirás como: %{role}"
115
+ joining_as_label: "Te unirás como:"
116
+ accept_cta: "Acepta tu invitación aquí:"
117
+ accept_button: "Aceptar invitación"
118
+ copy_link: "O copia y pega este enlace en tu navegador:"
119
+ expires_on: "Esta invitación caducará el %{date}."
120
+ ignore: "Si no esperabas esta invitación, puedes ignorar este email."
121
+ verification:
122
+ subject: "%{code} es tu código de verificación de %{organization}"
123
+ lead: "Usa este código para verificar tu email y unirte a %{organization}:"
124
+ lead_html: "Usa este código para verificar tu email y unirte a <strong>%{organization}</strong>:"
125
+ expires: "El código caduca en %{minutes} minutos."
126
+ ignore: "Si no has pedido este código, puedes ignorar este email — nadie puede unirse a %{organization} con tu dirección sin él."
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
- # Organization switching
5
- # POST /organizations/switch/:id
6
- post "organizations/switch/:id", to: "switch#create", as: :switch_organization
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
- # Organization management
9
- # All operations are scoped to current_organization (from session)
10
- resources :organizations, only: [:index, :show, :new, :create, :edit, :update, :destroy]
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
- # Membership management (scoped to current_organization)
13
- # These are flat routes - the organization is determined by session, not URL
14
- resources :memberships, only: [:index, :update, :destroy] do
15
- member do
16
- post :transfer_ownership
17
- end
18
- collection do
19
- delete :leave
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
- # Invitation management (scoped to current_organization)
24
- # These are flat routes - the organization is determined by session, not URL
25
- # NOTE: Must come BEFORE token-based routes so /invitations/new doesn't match /:token
26
- resources :invitations, only: [:index, :new, :create, :destroy], as: :organization_invitations do
27
- member do
28
- post :resend
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
- # Invitation acceptance (public routes with token)
33
- # These use PublicInvitationsController which inherits from a minimal base controller
34
- # to avoid host app filters that might enforce authentication.
35
- # GET /invitations/:token → View invitation details
36
- # POST /invitations/:token/accept Accept the invitation
37
- # NOTE: These must come AFTER resourceful routes to avoid matching "new" as a token
38
- get "invitations/:token", to: "public_invitations#show", as: :invitation
39
- post "invitations/:token/accept", to: "public_invitations#accept", as: :accept_invitation
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
@@ -7,9 +7,9 @@ gem "rails", "~> 7.2.3"
7
7
 
8
8
  group :development do
9
9
  gem "irb"
10
- gem "rubocop", "~> 1.0"
11
- gem "rubocop-minitest", "~> 0.35"
12
- gem "rubocop-performance", "~> 1.0"
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
@@ -7,9 +7,9 @@ gem "rails", "~> 8.1.2"
7
7
 
8
8
  group :development do
9
9
  gem "irb"
10
- gem "rubocop", "~> 1.0"
11
- gem "rubocop-minitest", "~> 0.35"
12
- gem "rubocop-performance", "~> 1.0"
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
- execute <<-SQL
35
- CREATE UNIQUE INDEX index_organizations_memberships_single_owner
36
- ON organizations_memberships (organization_id)
37
- WHERE role = 'owner'
38
- SQL
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
- execute <<-SQL
62
- CREATE UNIQUE INDEX index_organizations_invitations_pending_unique
63
- ON organizations_invitations (organization_id, LOWER(email))
64
- WHERE accepted_at IS NULL
65
- SQL
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
- execute <<-SQL
70
- ALTER TABLE organizations_invitations
71
- ADD COLUMN pending_email VARCHAR(255)
72
- GENERATED ALWAYS AS (
73
- CASE
74
- WHEN accepted_at IS NULL THEN LOWER(email)
75
- ELSE NULL
76
- END
77
- ) STORED
78
- SQL
79
-
80
- add_index :organizations_invitations, [:organization_id, :pending_email], unique: true, name: "index_organizations_invitations_pending_unique"
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
  # ============================================================================