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,93 @@
1
+ <div class="mb-8 flex justify-between items-center">
2
+ <div>
3
+ <h1 class="text-2xl font-bold text-gray-900">Organizations</h1>
4
+ <p class="mt-1 text-sm text-gray-500">Organizations you belong to, with your role in each workspace.</p>
5
+ </div>
6
+ <%= link_to new_organization_path,
7
+ class: "inline-flex items-center gap-2 rounded-lg bg-slate-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-500" do %>
8
+ <%= heroicon "plus", variant: :mini, options: { class: "h-4 w-4" } %>
9
+ Create Organization
10
+ <% end %>
11
+ </div>
12
+
13
+ <% if @organizations.blank? %>
14
+ <div class="bg-gray-50 rounded-xl border-2 border-dashed border-gray-200 p-12 text-center">
15
+ <%= heroicon "building-office-2", variant: :outline, options: { class: "mx-auto h-12 w-12 text-gray-400" } %>
16
+ <h3 class="mt-2 text-sm font-semibold text-gray-900">No organizations</h3>
17
+ <p class="mt-1 text-sm text-gray-500">Get started by creating a new organization.</p>
18
+ <div class="mt-6">
19
+ <%= link_to new_organization_path,
20
+ class: "inline-flex items-center gap-2 rounded-lg bg-slate-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-500" do %>
21
+ <%= heroicon "plus", variant: :mini, options: { class: "h-4 w-4" } %>
22
+ New Organization
23
+ <% end %>
24
+ </div>
25
+ </div>
26
+ <% else %>
27
+ <% role_by_org = @memberships.index_by(&:organization_id) %>
28
+
29
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
30
+ <table class="min-w-full divide-y divide-gray-200 bg-white">
31
+ <thead class="bg-gray-50">
32
+ <tr>
33
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Organization</th>
34
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Your Role</th>
35
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Members</th>
36
+ <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
37
+ </tr>
38
+ </thead>
39
+ <tbody class="bg-white divide-y divide-gray-200">
40
+ <% @organizations.each do |organization| %>
41
+ <% membership = role_by_org[organization.id] %>
42
+ <% is_current = current_organization&.id == organization.id %>
43
+ <tr class="<%= is_current ? 'bg-slate-50' : 'bg-white hover:bg-gray-50' %>">
44
+ <td class="px-6 py-4 whitespace-nowrap bg-inherit">
45
+ <div class="flex items-center gap-3">
46
+ <span class="inline-flex h-10 w-10 items-center justify-center rounded-lg <%= is_current ? 'bg-slate-600 text-white' : 'bg-gray-200 text-gray-600' %> font-semibold">
47
+ <%= organization.name.first.upcase %>
48
+ </span>
49
+ <div>
50
+ <div class="text-sm font-medium text-gray-900 flex items-center gap-2">
51
+ <%= organization.name %>
52
+ <% if is_current %>
53
+ <span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">Current</span>
54
+ <% end %>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ </td>
59
+ <td class="px-6 py-4 whitespace-nowrap bg-inherit">
60
+ <span class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
61
+ <%= case membership&.role
62
+ when 'owner' then 'bg-amber-100 text-amber-800'
63
+ when 'admin' then 'bg-purple-100 text-purple-800'
64
+ when 'member' then 'bg-blue-100 text-blue-800'
65
+ else 'bg-gray-100 text-gray-800'
66
+ end %>">
67
+ <%= membership&.role&.capitalize || "Unknown" %>
68
+ </span>
69
+ </td>
70
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 bg-inherit">
71
+ <%= organization.member_count %> <%= "member".pluralize(organization.member_count) %>
72
+ </td>
73
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium bg-inherit">
74
+ <div class="flex items-center justify-end gap-2">
75
+ <% unless is_current %>
76
+ <%= button_to switch_organization_path(organization),
77
+ method: :post,
78
+ class: "inline-flex items-center rounded-md bg-slate-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-slate-500" do %>
79
+ Switch
80
+ <% end %>
81
+ <% end %>
82
+ <%= link_to organization_path(organization),
83
+ class: "inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" do %>
84
+ Open
85
+ <% end %>
86
+ </div>
87
+ </td>
88
+ </tr>
89
+ <% end %>
90
+ </tbody>
91
+ </table>
92
+ </div>
93
+ <% end %>
@@ -0,0 +1,10 @@
1
+ <div class="max-w-lg mx-auto">
2
+ <div class="mb-8">
3
+ <h1 class="text-2xl font-bold text-gray-900">New Organization</h1>
4
+ <p class="mt-1 text-sm text-gray-500">Create a new workspace. You will become its owner.</p>
5
+ </div>
6
+
7
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
8
+ <%= render "form" %>
9
+ </div>
10
+ </div>
@@ -0,0 +1,270 @@
1
+ <%# Permission checks against THIS organization, not current_organization from session %>
2
+ <% is_admin_or_owner = current_user.is_at_least?(:admin, in: @organization) %>
3
+ <% is_owner = current_user.is_owner_of?(@organization) %>
4
+
5
+ <%# Organization header %>
6
+ <div class="mb-8">
7
+ <div class="flex items-start justify-between">
8
+ <div class="flex items-center gap-4">
9
+ <span class="inline-flex h-14 w-14 items-center justify-center rounded-xl bg-slate-600 text-white font-bold text-2xl shadow-lg">
10
+ <%= @organization.name.first.upcase %>
11
+ </span>
12
+ <div>
13
+ <h1 class="text-2xl font-bold text-gray-900"><%= @organization.name %></h1>
14
+ <p class="mt-1 text-gray-500">Manage your organization settings, team members, and invitations.</p>
15
+ </div>
16
+ </div>
17
+ <div class="flex items-center gap-2">
18
+ <% if is_admin_or_owner %>
19
+ <%= link_to organizations.new_organization_invitation_path,
20
+ class: "inline-flex items-center gap-1.5 rounded-lg bg-slate-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-500" do %>
21
+ <%= heroicon "user-plus", variant: :mini, options: { class: "h-4 w-4" } %>
22
+ Invite Member
23
+ <% end %>
24
+ <% end %>
25
+ </div>
26
+ </div>
27
+ </div>
28
+
29
+ <%# Stats grid %>
30
+ <div class="grid grid-cols-1 gap-6 sm:grid-cols-3 mb-8">
31
+ <div class="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
32
+ <div class="flex items-center gap-4">
33
+ <div class="h-12 w-12 rounded-xl bg-slate-100 flex items-center justify-center">
34
+ <%= heroicon "users", variant: :solid, options: { class: "h-6 w-6 text-slate-600" } %>
35
+ </div>
36
+ <div>
37
+ <p class="text-sm text-gray-500">Team Members</p>
38
+ <p class="text-2xl font-bold text-gray-900"><%= @organization.member_count %></p>
39
+ </div>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
44
+ <div class="flex items-center gap-4">
45
+ <div class="h-12 w-12 rounded-xl bg-amber-100 flex items-center justify-center">
46
+ <%= heroicon "envelope", variant: :solid, options: { class: "h-6 w-6 text-amber-600" } %>
47
+ </div>
48
+ <div>
49
+ <p class="text-sm text-gray-500">Pending Invites</p>
50
+ <p class="text-2xl font-bold text-gray-900"><%= @organization.invitations.pending.count %></p>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
56
+ <div class="flex items-center gap-4">
57
+ <div class="h-12 w-12 rounded-xl bg-emerald-100 flex items-center justify-center">
58
+ <%= heroicon "shield-check", variant: :solid, options: { class: "h-6 w-6 text-emerald-600" } %>
59
+ </div>
60
+ <div>
61
+ <p class="text-sm text-gray-500">Your Role</p>
62
+ <p class="text-2xl font-bold text-gray-900"><%= current_user.role_in(@organization)&.capitalize || "N/A" %></p>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ </div>
67
+
68
+ <%# Team Members Card - Full width %>
69
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-8">
70
+ <div class="px-6 py-5 border-b border-gray-200">
71
+ <div class="flex justify-between items-start">
72
+ <div>
73
+ <h2 class="text-lg font-semibold text-gray-900">Team Members</h2>
74
+ <p class="text-sm text-gray-500 mt-1">All users who belong to this organization with their roles.</p>
75
+ </div>
76
+ <% if is_admin_or_owner %>
77
+ <%= link_to organizations.new_organization_invitation_path,
78
+ class: "flex-shrink-0 inline-flex items-center gap-2 rounded-lg bg-slate-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-500" do %>
79
+ <%= heroicon "user-plus", variant: :solid, options: { class: "h-4 w-4" } %>
80
+ Invite
81
+ <% end %>
82
+ <% end %>
83
+ </div>
84
+ <% unless is_admin_or_owner %>
85
+ <p class="mt-3 text-xs text-amber-600 bg-amber-50 rounded-lg px-3 py-2">
86
+ <%= heroicon "lock-closed", variant: :solid, options: { class: "inline h-3 w-3 mr-1" } %>
87
+ <strong>Invite button hidden.</strong> Only <span class="font-medium">admins</span> and <span class="font-medium">owners</span> can invite new members.
88
+ </p>
89
+ <% end %>
90
+ </div>
91
+
92
+ <div class="divide-y divide-gray-100">
93
+ <% @organization.memberships.includes(:user).order(:created_at).each do |membership| %>
94
+ <% is_current_user = membership.user_id == current_user.id %>
95
+ <div class="px-6 py-5 flex items-center justify-between hover:bg-gray-50">
96
+ <div class="flex items-center gap-4">
97
+ <span class="inline-flex h-11 w-11 items-center justify-center rounded-full <%= is_current_user ? 'bg-slate-600 text-white' : 'bg-gray-100 text-gray-600' %> font-semibold text-sm">
98
+ <%= membership.user.email.first.upcase %>
99
+ </span>
100
+ <div>
101
+ <p class="text-sm font-medium text-gray-900">
102
+ <%= membership.user.email %>
103
+ <% if is_current_user %>
104
+ <span class="text-xs text-emerald-600 ml-1">(you)</span>
105
+ <% end %>
106
+ </p>
107
+ <p class="text-xs text-gray-500 mt-0.5">Joined <%= time_ago_in_words(membership.created_at) %> ago</p>
108
+ </div>
109
+ </div>
110
+
111
+ <div class="flex items-center gap-4">
112
+ <div class="text-right">
113
+ <span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold
114
+ <%= case membership.role
115
+ when 'owner' then 'bg-amber-100 text-amber-800'
116
+ when 'admin' then 'bg-purple-100 text-purple-800'
117
+ when 'member' then 'bg-blue-100 text-blue-800'
118
+ else 'bg-gray-100 text-gray-700'
119
+ end %>">
120
+ <%= membership.role.capitalize %>
121
+ </span>
122
+ </div>
123
+
124
+ <%# Role management for admins/owners %>
125
+ <% if is_admin_or_owner && !is_current_user && membership.role != 'owner' %>
126
+ <div class="flex items-center gap-2">
127
+ <%= form_with(model: membership, url: organizations.membership_path(membership), method: :patch, local: true, class: "flex items-center gap-2") do |form| %>
128
+ <%= form.select :role,
129
+ Organizations::Roles::HIERARCHY.reject { |r| r == :owner }.map { |role| [role.to_s.capitalize, role] },
130
+ { selected: membership.role },
131
+ class: "rounded-md border-gray-300 text-xs focus:border-slate-500 focus:ring-slate-500 py-1" %>
132
+ <%= form.submit "Update",
133
+ class: "inline-flex items-center rounded-md bg-white px-2 py-1 text-xs font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 cursor-pointer" %>
134
+ <% end %>
135
+
136
+ <%= button_to organizations.membership_path(membership),
137
+ method: :delete,
138
+ data: { turbo_confirm: "Remove #{membership.user.email} from the organization?" },
139
+ class: "inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-100 cursor-pointer" do %>
140
+ <%= heroicon "trash", variant: :mini, options: { class: "h-3.5 w-3.5" } %>
141
+ <% end %>
142
+ </div>
143
+ <% elsif is_current_user %>
144
+ <span class="text-xs text-gray-400 italic">Current user</span>
145
+ <% elsif membership.role == 'owner' %>
146
+ <span class="text-xs text-gray-400 italic">Organization owner</span>
147
+ <% end %>
148
+
149
+ <%# Demo: Switch to this user %>
150
+ <% if membership.user.id != current_user.id %>
151
+ <%= button_to main_app.switch_user_path(email: membership.user.email),
152
+ method: :post,
153
+ data: { turbo: false },
154
+ class: "inline-flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-200 transition-colors",
155
+ title: "Demo: Switch to this user to see the UI from their perspective" do %>
156
+ <%= heroicon "arrow-path", variant: :solid, options: { class: "h-3.5 w-3.5" } %>
157
+ Impersonate
158
+ <% end %>
159
+ <% end %>
160
+ </div>
161
+ </div>
162
+ <% end %>
163
+ </div>
164
+
165
+ <div class="px-6 py-3 bg-gray-50 border-t border-gray-100">
166
+ <p class="text-xs text-gray-500">
167
+ <strong>Try it:</strong> Click "Impersonate" to switch users and see how the UI changes based on their role permissions.
168
+ </p>
169
+ </div>
170
+
171
+ <%# Pending invitations %>
172
+ <% pending = @organization.invitations.pending %>
173
+ <% if pending.any? && is_admin_or_owner %>
174
+ <div class="border-t border-gray-200 bg-gray-50 px-6 py-5">
175
+ <div class="flex items-center justify-between mb-2">
176
+ <h3 class="text-sm font-semibold text-gray-900">Pending Invitations</h3>
177
+ <span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-0.5 text-xs font-semibold text-gray-700">
178
+ <%= pending.count %>
179
+ </span>
180
+ </div>
181
+ <p class="text-xs text-gray-500 mb-4">These users have been invited but haven't accepted yet.</p>
182
+ <div class="space-y-3">
183
+ <% pending.includes(:invited_by).each do |inv| %>
184
+ <div class="flex items-center justify-between bg-white rounded-lg px-4 py-3 border border-gray-200">
185
+ <div class="flex items-center gap-3">
186
+ <span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-amber-100 text-amber-600 font-medium text-xs">
187
+ <%= inv.email.first.upcase %>
188
+ </span>
189
+ <div>
190
+ <span class="text-sm font-medium text-gray-900"><%= inv.email %></span>
191
+ <span class="ml-2 text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">will be <%= inv.role %></span>
192
+ </div>
193
+ </div>
194
+ <div class="flex items-center gap-2">
195
+ <%= button_to organizations.resend_organization_invitation_path(inv),
196
+ method: :post,
197
+ class: "p-1.5 rounded text-gray-400 hover:text-gray-600 hover:bg-gray-100 cursor-pointer",
198
+ title: "Resend" do %>
199
+ <%= heroicon "arrow-path", variant: :mini, options: { class: "h-4 w-4" } %>
200
+ <% end %>
201
+ <%= button_to organizations.organization_invitation_path(inv),
202
+ method: :delete,
203
+ data: { turbo_confirm: "Revoke this invitation?" },
204
+ class: "p-1.5 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 cursor-pointer",
205
+ title: "Revoke" do %>
206
+ <%= heroicon "x-mark", variant: :mini, options: { class: "h-4 w-4" } %>
207
+ <% end %>
208
+ <%= button_to main_app.switch_user_path(email: inv.email),
209
+ method: :post,
210
+ data: { turbo: false },
211
+ class: "text-xs font-medium text-gray-600 hover:text-gray-900 cursor-pointer" do %>
212
+ Impersonate & Accept →
213
+ <% end %>
214
+ </div>
215
+ </div>
216
+ <% end %>
217
+ </div>
218
+ </div>
219
+ <% end %>
220
+ </div>
221
+
222
+ <%# Bottom row: Organization Settings + Danger Zone side by side %>
223
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
224
+
225
+ <%# Organization Settings %>
226
+ <div>
227
+ <% if is_admin_or_owner %>
228
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden h-full flex flex-col">
229
+ <div class="px-6 py-4 border-b border-gray-200">
230
+ <h2 class="text-base font-semibold text-gray-900">Organization Settings</h2>
231
+ </div>
232
+ <%= form_with model: @organization, url: organizations.organization_path(@organization), method: :patch, class: "p-5 space-y-4 flex-1" do |f| %>
233
+ <div>
234
+ <label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
235
+ <%= f.text_field :name, class: "block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500" %>
236
+ <p class="mt-1 text-xs text-gray-400">Editable because you're an <code class="bg-gray-100 px-1 rounded"><%= current_user.role_in(@organization) %></code></p>
237
+ </div>
238
+ <div>
239
+ <label class="block text-sm font-medium text-gray-400 mb-1">Slug</label>
240
+ <div class="flex rounded-lg shadow-sm">
241
+ <span class="inline-flex items-center px-3 rounded-l-lg border border-r-0 border-gray-200 bg-gray-50 text-gray-400 text-sm">yourapp.com/</span>
242
+ <input type="text" disabled value="<%= @organization.name.parameterize %>" class="block w-full rounded-r-lg border-gray-200 bg-gray-50 text-sm text-gray-400 cursor-not-allowed" />
243
+ </div>
244
+ <p class="mt-1 text-xs text-gray-400">Placeholder field (not functional)</p>
245
+ </div>
246
+ <%= f.submit "Save Changes", class: "w-full inline-flex justify-center items-center rounded-lg bg-slate-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-slate-500 cursor-pointer" %>
247
+ <% end %>
248
+ </div>
249
+ <% else %>
250
+ <div class="bg-gray-50 rounded-xl border-2 border-dashed border-gray-200 p-6 h-full flex flex-col items-center justify-center">
251
+ <%= heroicon "lock-closed", variant: :solid, options: { class: "h-10 w-10 text-gray-300" } %>
252
+ <h3 class="mt-3 text-sm font-semibold text-gray-900 text-center">Organization Settings</h3>
253
+ <p class="mt-2 text-xs text-gray-500 text-center">Requires <code class="bg-gray-200 px-1 rounded">admin</code> role or higher.</p>
254
+ <p class="text-xs text-gray-400 text-center">Impersonate an admin or owner to see this.</p>
255
+ </div>
256
+ <% end %>
257
+ </div>
258
+
259
+ <%# Danger Zone — owner-only destructive actions (transfer/delete). NOT
260
+ part of `rails g organizations:views` (it carries its own Stimulus
261
+ modal + icon dependencies): rendered only when the host app provides
262
+ the partial. Copy the full-featured one from the gem's
263
+ test/dummy/app/views/shared/_danger_zone.html.erb. %>
264
+ <% if lookup_context.exists?("shared/danger_zone", [], true) %>
265
+ <div>
266
+ <%= render "shared/danger_zone", organization: @organization %>
267
+ </div>
268
+ <% end %>
269
+
270
+ </div>
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Organizations
6
+ module Generators
7
+ # Copies the reference views into the host app — the devise-views pattern
8
+ # (https://github.com/heartcombo/devise#configuring-views) for a BYO-UI
9
+ # engine: the engine's controllers render HOST views, so a fresh install
10
+ # hits missing-template errors until these exist. Run:
11
+ #
12
+ # rails generate organizations:views
13
+ #
14
+ # and retheme freely — the copies are yours. Tailwind-styled, matching
15
+ # the gem's test/dummy reference app (the templates here are kept
16
+ # byte-identical to the dummy's views by a sync test in the gem's suite,
17
+ # so the dummy remains the living, boot-able preview of what you get).
18
+ #
19
+ # NOTE: verified joining (join screens, request queues, access
20
+ # management) is deliberately NOT part of this set — those surfaces are
21
+ # host ROUTES + CONTROLLERS too, not just views. Copy the reference
22
+ # implementation from the gem's test/dummy app instead (see README
23
+ # "Verified joining — reference UI").
24
+ class ViewsGenerator < Rails::Generators::Base
25
+ source_root File.expand_path("templates", __dir__)
26
+
27
+ desc "Copies the organizations reference views into app/views/organizations"
28
+
29
+ def copy_views
30
+ directory "organizations", "app/views/organizations"
31
+ end
32
+
33
+ def show_readme
34
+ say ""
35
+ say "Organizations views copied to app/views/organizations/ — they're yours now; retheme freely.", :green
36
+ say "These views use Tailwind classes and the `heroicon` helper: add `gem \"heroicons\"` (or"
37
+ say "swap the icon calls for your own icon set)."
38
+ say "The organization page's owner-only Danger Zone (transfer/delete) renders only if you"
39
+ say "provide app/views/shared/_danger_zone.html.erb — copy the full-featured one (Stimulus"
40
+ say "confirm modal included) from the gem's test/dummy app."
41
+ say "Verified-joining surfaces (join screen, request queue, access management) are host"
42
+ say "controllers + views: copy the reference implementation from the gem's test/dummy app."
43
+ say ""
44
+ end
45
+ end
46
+ end
47
+ end
@@ -11,6 +11,11 @@ module Organizations
11
11
  # - :member_removed => organization, membership, user, removed_by
12
12
  # - :role_changed => organization, membership, old_role, new_role, changed_by
13
13
  # - :ownership_transferred => organization, old_owner, new_owner
14
+ # - :member_joining => organization, user, role, joined_via, invitation/join_request (whichever instrument applies)
15
+ # (STRICT + pre-persist: raise Organizations::MembershipVetoed to veto — see Configuration#on_member_joining)
16
+ # - :join_request_created => organization, user, join_request
17
+ # - :join_request_approved => organization, user, join_request, membership, decided_by (nil for auto-approvals)
18
+ # - :join_request_rejected => organization, user, join_request, decided_by
14
19
  #
15
20
  # @example Accessing context data
16
21
  # config.on_organization_created do |ctx|
@@ -30,6 +35,10 @@ module Organizations
30
35
  :new_role, # Symbol - new role (for role_changed)
31
36
  :old_owner, # User instance - previous owner (for ownership_transferred)
32
37
  :new_owner, # User instance - new owner (for ownership_transferred)
38
+ :join_request, # Organizations::JoinRequest instance (for join_request_* events)
39
+ :decided_by, # User instance - who approved/rejected the join request (nil for auto)
40
+ :role, # String/Symbol - the role the membership will be created with (for member_joining)
41
+ :joined_via, # String - membership provenance: invited|code|domain_email|allowlist|manual (for member_joining)
33
42
  :permission, # Symbol - the permission that was required (for unauthorized)
34
43
  :required_role, # Symbol - the role that was required (for unauthorized)
35
44
  :metadata, # Hash - additional contextual data
@@ -13,10 +13,15 @@ module Organizations
13
13
  EVENTS = %i[
14
14
  organization_created
15
15
  member_invited
16
+ member_joining
16
17
  member_joined
17
18
  member_removed
18
19
  role_changed
19
20
  ownership_transferred
21
+ join_request_created
22
+ join_request_approved
23
+ join_request_rejected
24
+ verification_delivery_failed
20
25
  ].freeze
21
26
 
22
27
  module_function
@@ -35,6 +40,22 @@ module Organizations
35
40
  strict ? execute_strictly(callback, context) : execute_safely(event, callback, context)
36
41
  end
37
42
 
43
+ # Maps each event to its Configuration reader. Adding an event = one
44
+ # entry here + the EVENTS list + a Configuration attr/block method.
45
+ CALLBACK_READERS = {
46
+ organization_created: :on_organization_created_callback,
47
+ member_invited: :on_member_invited_callback,
48
+ member_joining: :on_member_joining_callback,
49
+ member_joined: :on_member_joined_callback,
50
+ member_removed: :on_member_removed_callback,
51
+ role_changed: :on_role_changed_callback,
52
+ ownership_transferred: :on_ownership_transferred_callback,
53
+ join_request_created: :on_join_request_created_callback,
54
+ join_request_approved: :on_join_request_approved_callback,
55
+ join_request_rejected: :on_join_request_rejected_callback,
56
+ verification_delivery_failed: :on_verification_delivery_failed_callback
57
+ }.freeze
58
+
38
59
  # Get the callback proc for an event
39
60
  # @param event [Symbol] The event type
40
61
  # @return [Proc, nil]
@@ -42,20 +63,8 @@ module Organizations
42
63
  config = Organizations.configuration
43
64
  return nil unless config
44
65
 
45
- case event
46
- when :organization_created
47
- config.on_organization_created_callback
48
- when :member_invited
49
- config.on_member_invited_callback
50
- when :member_joined
51
- config.on_member_joined_callback
52
- when :member_removed
53
- config.on_member_removed_callback
54
- when :role_changed
55
- config.on_role_changed_callback
56
- when :ownership_transferred
57
- config.on_ownership_transferred_callback
58
- end
66
+ reader = CALLBACK_READERS[event]
67
+ reader ? config.public_send(reader) : nil
59
68
  end
60
69
 
61
70
  # Execute callback with error isolation
@@ -81,12 +90,11 @@ module Organizations
81
90
  end
82
91
 
83
92
  # Call callback while supporting flexible callback arities.
93
+ # (Review cleanup: the old `when 1, -1, -2 / else` branches were
94
+ # identical — zero-arity is the only special case.)
84
95
  def invoke_callback(callback, context)
85
- case callback.arity
86
- when 0
96
+ if callback.arity.zero?
87
97
  callback.call
88
- when 1, -1, -2
89
- callback.call(context)
90
98
  else
91
99
  callback.call(context)
92
100
  end