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.
- checksums.yaml +4 -4
- data/.rubocop.yml +8 -9
- data/.rubocop_todo.yml +625 -0
- data/CHANGELOG.md +53 -0
- data/README.md +414 -46
- 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 +27 -19
- data/lib/organizations.rb +165 -0
- 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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|