organizations 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +8 -9
- data/.rubocop_todo.yml +625 -0
- data/CHANGELOG.md +46 -0
- data/README.md +405 -41
- data/app/controllers/organizations/application_controller.rb +4 -0
- data/app/controllers/organizations/memberships_controller.rb +3 -3
- data/app/controllers/organizations/public_controller.rb +26 -0
- data/app/mailers/organizations/invitation_mailer.rb +38 -18
- data/app/mailers/organizations/verification_mailer.rb +56 -0
- data/app/models/organizations/allowlist_entry.rb +6 -0
- data/app/models/organizations/domain.rb +6 -0
- data/app/models/organizations/join_code.rb +6 -0
- data/app/models/organizations/join_request.rb +6 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
- data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
- data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
- data/config/locales/en.yml +158 -0
- data/config/locales/es.yml +126 -0
- data/config/routes.rb +46 -28
- data/gemfiles/rails_7.2.gemfile +3 -3
- data/gemfiles/rails_8.1.gemfile +3 -3
- data/lib/generators/organizations/install/install_generator.rb +3 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
- data/lib/generators/organizations/install/templates/initializer.rb +53 -0
- data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
- data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
- data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
- data/lib/generators/organizations/views/views_generator.rb +47 -0
- data/lib/organizations/callback_context.rb +9 -0
- data/lib/organizations/callbacks.rb +26 -18
- data/lib/organizations/configuration.rb +275 -1
- data/lib/organizations/controller_helpers.rb +38 -12
- data/lib/organizations/email_normalizer.rb +65 -0
- data/lib/organizations/join_flow.rb +227 -0
- data/lib/organizations/join_state.rb +117 -0
- data/lib/organizations/metadata_flags.rb +79 -0
- data/lib/organizations/models/allowlist_entry.rb +88 -0
- data/lib/organizations/models/concerns/has_organizations.rb +100 -12
- data/lib/organizations/models/domain.rb +85 -0
- data/lib/organizations/models/invitation.rb +105 -17
- data/lib/organizations/models/join_code.rb +242 -0
- data/lib/organizations/models/join_request.rb +627 -0
- data/lib/organizations/models/membership.rb +39 -8
- data/lib/organizations/models/organization.rb +317 -19
- data/lib/organizations/organization_scoped.rb +135 -0
- data/lib/organizations/test_helpers.rb +35 -1
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +8 -12
- data/lib/organizations.rb +165 -0
- metadata +34 -2
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<% suggested_email = ::Faker::Internet.email(name: ::Faker::Name.name, domain: 'example.com') %>
|
|
2
|
+
<%= form_with(model: @invitation, url: organization_invitations_path, local: true, class: "space-y-6") do |form| %>
|
|
3
|
+
<%= hidden_field_tag :return_to, @return_to %>
|
|
4
|
+
<% if @invitation.errors.any? %>
|
|
5
|
+
<div class="rounded-lg bg-red-50 p-4 border border-red-200">
|
|
6
|
+
<div class="flex">
|
|
7
|
+
<div class="flex-shrink-0">
|
|
8
|
+
<%= heroicon "x-circle", variant: :solid, options: { class: "h-5 w-5 text-red-400" } %>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="ml-3">
|
|
11
|
+
<h3 class="text-sm font-medium text-red-800">Could not send invitation:</h3>
|
|
12
|
+
<div class="mt-2 text-sm text-red-700">
|
|
13
|
+
<ul class="list-disc pl-5 space-y-1">
|
|
14
|
+
<% @invitation.errors.full_messages.each do |message| %>
|
|
15
|
+
<li><%= message %></li>
|
|
16
|
+
<% end %>
|
|
17
|
+
</ul>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<div>
|
|
25
|
+
<%= form.label :email, "Email address", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
|
26
|
+
<%= form.email_field :email,
|
|
27
|
+
required: true,
|
|
28
|
+
autofocus: true,
|
|
29
|
+
value: @invitation.email.presence || suggested_email,
|
|
30
|
+
placeholder: "colleague@company.com",
|
|
31
|
+
class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-slate-500 focus:ring-slate-500 sm:text-sm" %>
|
|
32
|
+
<p class="mt-1 text-xs text-gray-500">They'll receive an email invitation to join this organization.</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div>
|
|
36
|
+
<%= form.label :role, "Role", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
|
37
|
+
<%= form.select :role,
|
|
38
|
+
Organizations::Roles::HIERARCHY.reject { |role| role == :owner }.map { |role| [role.to_s.capitalize, role] },
|
|
39
|
+
{ selected: @invitation.role || :member },
|
|
40
|
+
required: true,
|
|
41
|
+
class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-slate-500 focus:ring-slate-500 sm:text-sm" %>
|
|
42
|
+
<p class="mt-1 text-xs text-gray-500">Choose the permission level for this member.</p>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="flex items-center justify-end gap-3 pt-4">
|
|
46
|
+
<%= link_to "Cancel", "javascript:history.back()",
|
|
47
|
+
class: "inline-flex items-center rounded-lg bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
|
48
|
+
<%= form.submit "Send Invitation",
|
|
49
|
+
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 cursor-pointer" %>
|
|
50
|
+
</div>
|
|
51
|
+
<% end %>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<div class="mb-8 flex justify-between items-center">
|
|
2
|
+
<div>
|
|
3
|
+
<h1 class="text-2xl font-bold text-gray-900">Invitations</h1>
|
|
4
|
+
<p class="mt-1 text-sm text-gray-500">Manage organization invitations and resend or revoke pending invites.</p>
|
|
5
|
+
</div>
|
|
6
|
+
<%= link_to new_organization_invitation_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
|
+
Invite Member
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<% if @invitations.blank? %>
|
|
14
|
+
<div class="bg-gray-50 rounded-xl border-2 border-dashed border-gray-200 p-12 text-center">
|
|
15
|
+
<%= heroicon "envelope", 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 invitations</h3>
|
|
17
|
+
<p class="mt-1 text-sm text-gray-500">Invite members to join your organization.</p>
|
|
18
|
+
<div class="mt-6">
|
|
19
|
+
<%= link_to new_organization_invitation_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
|
+
Send Invitation
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
<% else %>
|
|
27
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
28
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
29
|
+
<thead class="bg-gray-50">
|
|
30
|
+
<tr>
|
|
31
|
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
|
32
|
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
|
|
33
|
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
34
|
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Invited By</th>
|
|
35
|
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Expires</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
|
+
<% @invitations.each do |invitation| %>
|
|
41
|
+
<tr class="hover:bg-gray-50">
|
|
42
|
+
<td class="px-6 py-4 whitespace-nowrap">
|
|
43
|
+
<div class="flex items-center gap-3">
|
|
44
|
+
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gray-200 text-gray-600 font-medium text-sm">
|
|
45
|
+
<%= invitation.email.first.upcase %>
|
|
46
|
+
</span>
|
|
47
|
+
<span class="text-sm font-medium text-gray-900"><%= invitation.email %></span>
|
|
48
|
+
</div>
|
|
49
|
+
</td>
|
|
50
|
+
<td class="px-6 py-4 whitespace-nowrap">
|
|
51
|
+
<span class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
|
|
52
|
+
<%= case invitation.role
|
|
53
|
+
when 'admin' then 'bg-purple-100 text-purple-800'
|
|
54
|
+
when 'member' then 'bg-blue-100 text-blue-800'
|
|
55
|
+
else 'bg-gray-100 text-gray-800'
|
|
56
|
+
end %>">
|
|
57
|
+
<%= invitation.role.capitalize %>
|
|
58
|
+
</span>
|
|
59
|
+
</td>
|
|
60
|
+
<td class="px-6 py-4 whitespace-nowrap">
|
|
61
|
+
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium
|
|
62
|
+
<%= case invitation.status.to_s
|
|
63
|
+
when 'pending' then 'bg-amber-100 text-amber-800'
|
|
64
|
+
when 'accepted' then 'bg-emerald-100 text-emerald-800'
|
|
65
|
+
when 'declined', 'revoked' then 'bg-red-100 text-red-800'
|
|
66
|
+
when 'expired' then 'bg-gray-100 text-gray-800'
|
|
67
|
+
else 'bg-gray-100 text-gray-800'
|
|
68
|
+
end %>">
|
|
69
|
+
<%= invitation.status.to_s.capitalize %>
|
|
70
|
+
</span>
|
|
71
|
+
</td>
|
|
72
|
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
73
|
+
<%= invitation.invited_by&.respond_to?(:email) ? invitation.invited_by.email : "-" %>
|
|
74
|
+
</td>
|
|
75
|
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
76
|
+
<% if invitation.expires_at %>
|
|
77
|
+
<% if invitation.expires_at > Time.current %>
|
|
78
|
+
in <%= distance_of_time_in_words(Time.current, invitation.expires_at) %>
|
|
79
|
+
<% else %>
|
|
80
|
+
<span class="text-red-600">Expired</span>
|
|
81
|
+
<% end %>
|
|
82
|
+
<% else %>
|
|
83
|
+
Never
|
|
84
|
+
<% end %>
|
|
85
|
+
</td>
|
|
86
|
+
<td class="px-6 py-4 whitespace-nowrap text-right">
|
|
87
|
+
<% if invitation.pending? %>
|
|
88
|
+
<div class="flex items-center justify-end gap-2">
|
|
89
|
+
<%= button_to resend_organization_invitation_path(invitation),
|
|
90
|
+
method: :post,
|
|
91
|
+
class: "inline-flex items-center gap-1 rounded-md bg-white px-2.5 py-1.5 text-xs font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" do %>
|
|
92
|
+
<%= heroicon "arrow-path", variant: :mini, options: { class: "h-3.5 w-3.5" } %>
|
|
93
|
+
Resend
|
|
94
|
+
<% end %>
|
|
95
|
+
<%= button_to organization_invitation_path(invitation),
|
|
96
|
+
method: :delete,
|
|
97
|
+
data: { turbo_confirm: "Revoke this invitation?" },
|
|
98
|
+
class: "inline-flex items-center gap-1 rounded-md bg-red-50 px-2.5 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100" do %>
|
|
99
|
+
<%= heroicon "x-mark", variant: :mini, options: { class: "h-3.5 w-3.5" } %>
|
|
100
|
+
Revoke
|
|
101
|
+
<% end %>
|
|
102
|
+
</div>
|
|
103
|
+
<% else %>
|
|
104
|
+
<span class="text-sm text-gray-400 italic">No actions</span>
|
|
105
|
+
<% end %>
|
|
106
|
+
</td>
|
|
107
|
+
</tr>
|
|
108
|
+
<% end %>
|
|
109
|
+
</tbody>
|
|
110
|
+
</table>
|
|
111
|
+
</div>
|
|
112
|
+
<% 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">Invite a Member</h1>
|
|
4
|
+
<p class="mt-1 text-sm text-gray-500">Send an invitation by email. The member will join after accepting the link.</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,99 @@
|
|
|
1
|
+
<div class="flex min-h-[60vh] items-center justify-center px-4">
|
|
2
|
+
<div class="max-w-lg w-full">
|
|
3
|
+
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden">
|
|
4
|
+
<%# Header with organization branding %>
|
|
5
|
+
<div class="px-8 py-10 text-center bg-gradient-to-br from-emerald-500 to-emerald-600">
|
|
6
|
+
<div class="mx-auto h-20 w-20 rounded-2xl bg-white/20 backdrop-blur flex items-center justify-center mb-5 shadow-lg">
|
|
7
|
+
<span class="text-3xl font-bold text-white"><%= @invitation.organization.name.first.upcase %></span>
|
|
8
|
+
</div>
|
|
9
|
+
<h1 class="text-2xl font-bold text-white mb-2">You're Invited!</h1>
|
|
10
|
+
<p class="text-emerald-100">Join <strong class="text-white"><%= @invitation.organization.name %></strong></p>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div class="p-8">
|
|
14
|
+
<%# Role badge %>
|
|
15
|
+
<div class="flex justify-center mb-6">
|
|
16
|
+
<span class="inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold
|
|
17
|
+
<%= case @invitation.role
|
|
18
|
+
when 'owner' then 'bg-amber-100 text-amber-800'
|
|
19
|
+
when 'admin' then 'bg-purple-100 text-purple-800'
|
|
20
|
+
when 'member' then 'bg-blue-100 text-blue-800'
|
|
21
|
+
else 'bg-gray-100 text-gray-800'
|
|
22
|
+
end %>">
|
|
23
|
+
<%= heroicon "user-circle", variant: :solid, options: { class: "h-5 w-5" } %>
|
|
24
|
+
You'll join as <%= @invitation.role.capitalize %>
|
|
25
|
+
</span>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<%# Invited by info %>
|
|
29
|
+
<div class="text-center mb-8">
|
|
30
|
+
<p class="text-sm text-gray-500">
|
|
31
|
+
Invited by
|
|
32
|
+
<span class="font-medium text-gray-700"><%= @invitation.invited_by&.respond_to?(:email) ? @invitation.invited_by.email : "a team member" %></span>
|
|
33
|
+
</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<% if @invitation.expired? %>
|
|
37
|
+
<div class="rounded-xl bg-red-50 p-5 border border-red-200">
|
|
38
|
+
<div class="flex items-center justify-center gap-3 text-red-800">
|
|
39
|
+
<%= heroicon "clock", variant: :solid, options: { class: "h-6 w-6 text-red-500" } %>
|
|
40
|
+
<div>
|
|
41
|
+
<p class="font-semibold">Invitation Expired</p>
|
|
42
|
+
<p class="text-sm text-red-600">Ask the sender to send a new invitation.</p>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<% elsif @invitation.accepted? %>
|
|
47
|
+
<div class="rounded-xl bg-emerald-50 p-5 border border-emerald-200">
|
|
48
|
+
<div class="flex items-center justify-center gap-3 text-emerald-800">
|
|
49
|
+
<%= heroicon "check-circle", variant: :solid, options: { class: "h-6 w-6 text-emerald-500" } %>
|
|
50
|
+
<div>
|
|
51
|
+
<p class="font-semibold">Already Accepted</p>
|
|
52
|
+
<p class="text-sm text-emerald-600">This invitation has already been accepted.</p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
<% else %>
|
|
57
|
+
<% if @user_is_logged_in && @user_email_matches %>
|
|
58
|
+
<%= button_to accept_invitation_path(@invitation.token),
|
|
59
|
+
method: :post,
|
|
60
|
+
class: "w-full inline-flex items-center justify-center gap-3 rounded-xl bg-emerald-600 px-6 py-4 text-base font-bold text-white shadow-lg hover:bg-emerald-500 hover:shadow-xl transition-all cursor-pointer" do %>
|
|
61
|
+
<%= heroicon "check-circle", variant: :solid, options: { class: "h-6 w-6" } %>
|
|
62
|
+
Accept & Join Team
|
|
63
|
+
<% end %>
|
|
64
|
+
<p class="mt-4 text-center text-xs text-gray-400">
|
|
65
|
+
You'll be redirected to the organization dashboard
|
|
66
|
+
</p>
|
|
67
|
+
<% elsif @user_is_logged_in %>
|
|
68
|
+
<div class="rounded-xl bg-amber-50 p-5 border border-amber-200 mb-6">
|
|
69
|
+
<div class="flex items-start gap-3">
|
|
70
|
+
<%= heroicon "exclamation-triangle", variant: :solid, options: { class: "h-6 w-6 text-amber-500 flex-shrink-0" } %>
|
|
71
|
+
<div>
|
|
72
|
+
<p class="font-semibold text-amber-800">Email Mismatch</p>
|
|
73
|
+
<p class="text-sm text-amber-700 mt-1">
|
|
74
|
+
This invitation was sent to <strong><%= @invitation.email %></strong>,
|
|
75
|
+
but you're signed in as a different user.
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<% else %>
|
|
81
|
+
<p class="text-center text-sm text-gray-600 mb-6">
|
|
82
|
+
Sign in or register with <strong class="text-gray-900"><%= @invitation.email %></strong> to accept.
|
|
83
|
+
</p>
|
|
84
|
+
<%= button_to accept_invitation_path(@invitation.token),
|
|
85
|
+
method: :post,
|
|
86
|
+
class: "w-full inline-flex items-center justify-center gap-3 rounded-xl bg-emerald-600 px-6 py-4 text-base font-bold text-white shadow-lg hover:bg-emerald-500 hover:shadow-xl transition-all" do %>
|
|
87
|
+
Continue
|
|
88
|
+
<%= heroicon "arrow-right", variant: :solid, options: { class: "h-6 w-6" } %>
|
|
89
|
+
<% end %>
|
|
90
|
+
<% end %>
|
|
91
|
+
<% end %>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<p class="mt-8 text-center text-sm text-gray-400">
|
|
96
|
+
<%= link_to "← Back to home", main_app.root_path, class: "text-gray-500 hover:text-gray-700 transition-colors" %>
|
|
97
|
+
</p>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<div class="mb-8">
|
|
2
|
+
<h1 class="text-2xl font-bold text-gray-900">Members</h1>
|
|
3
|
+
<p class="mt-1 text-sm text-gray-500">Manage organization member roles and remove members.</p>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<% if @memberships.blank? %>
|
|
7
|
+
<div class="bg-gray-50 rounded-xl border-2 border-dashed border-gray-200 p-12 text-center">
|
|
8
|
+
<%= heroicon "users", variant: :outline, options: { class: "mx-auto h-12 w-12 text-gray-400" } %>
|
|
9
|
+
<h3 class="mt-2 text-sm font-semibold text-gray-900">No members</h3>
|
|
10
|
+
<p class="mt-1 text-sm text-gray-500">Invite members to your organization.</p>
|
|
11
|
+
</div>
|
|
12
|
+
<% else %>
|
|
13
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
14
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
15
|
+
<thead class="bg-gray-50">
|
|
16
|
+
<tr>
|
|
17
|
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Member</th>
|
|
18
|
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
|
|
19
|
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
|
|
20
|
+
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
21
|
+
</tr>
|
|
22
|
+
</thead>
|
|
23
|
+
<tbody class="bg-white divide-y divide-gray-200">
|
|
24
|
+
<% @memberships.each do |membership| %>
|
|
25
|
+
<% is_current_user = membership.user_id == current_user.id %>
|
|
26
|
+
<tr class="<%= is_current_user ? 'bg-slate-50' : 'hover:bg-gray-50' %>">
|
|
27
|
+
<td class="px-6 py-4 whitespace-nowrap">
|
|
28
|
+
<div class="flex items-center gap-3">
|
|
29
|
+
<span class="inline-flex h-10 w-10 items-center justify-center rounded-full <%= is_current_user ? 'bg-slate-600 text-white' : 'bg-gray-200 text-gray-600' %> font-medium">
|
|
30
|
+
<%= membership.user.email.first.upcase %>
|
|
31
|
+
</span>
|
|
32
|
+
<div>
|
|
33
|
+
<div class="text-sm font-medium text-gray-900 flex items-center gap-2">
|
|
34
|
+
<%= membership.user.respond_to?(:name) && membership.user.name.present? ? membership.user.name : membership.user.email %>
|
|
35
|
+
<% if is_current_user %>
|
|
36
|
+
<span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">You</span>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="text-sm text-gray-500"><%= membership.user.email %></div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</td>
|
|
43
|
+
<td class="px-6 py-4 whitespace-nowrap">
|
|
44
|
+
<span class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
|
|
45
|
+
<%= case membership.role
|
|
46
|
+
when 'owner' then 'bg-amber-100 text-amber-800'
|
|
47
|
+
when 'admin' then 'bg-purple-100 text-purple-800'
|
|
48
|
+
when 'member' then 'bg-blue-100 text-blue-800'
|
|
49
|
+
else 'bg-gray-100 text-gray-800'
|
|
50
|
+
end %>">
|
|
51
|
+
<%= membership.role.capitalize %>
|
|
52
|
+
</span>
|
|
53
|
+
</td>
|
|
54
|
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
55
|
+
<%= time_ago_in_words(membership.created_at) %> ago
|
|
56
|
+
</td>
|
|
57
|
+
<td class="px-6 py-4 whitespace-nowrap text-right">
|
|
58
|
+
<% if is_current_user %>
|
|
59
|
+
<span class="text-sm text-gray-400 italic">Current user</span>
|
|
60
|
+
<% elsif membership.role == 'owner' %>
|
|
61
|
+
<span class="text-sm text-gray-400 italic">Organization owner</span>
|
|
62
|
+
<% else %>
|
|
63
|
+
<div class="flex items-center justify-end gap-2">
|
|
64
|
+
<%= form_with(model: membership, url: membership_path(membership), method: :patch, local: true, class: "flex items-center gap-2") do |form| %>
|
|
65
|
+
<%= form.select :role,
|
|
66
|
+
Organizations::Roles::HIERARCHY.reject { |r| r == :owner }.map { |role| [role.to_s.capitalize, role] },
|
|
67
|
+
{ selected: membership.role },
|
|
68
|
+
class: "rounded-md border-gray-300 text-sm focus:border-slate-500 focus:ring-slate-500" %>
|
|
69
|
+
<%= form.submit "Update",
|
|
70
|
+
class: "inline-flex items-center rounded-md bg-white px-2.5 py-1.5 text-xs font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 cursor-pointer" %>
|
|
71
|
+
<% end %>
|
|
72
|
+
|
|
73
|
+
<%= button_to membership_path(membership),
|
|
74
|
+
method: :delete,
|
|
75
|
+
data: { turbo_confirm: "Remove #{membership.user.email} from the organization?" },
|
|
76
|
+
class: "inline-flex items-center rounded-md bg-red-50 px-2 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100" do %>
|
|
77
|
+
<%= heroicon "trash", variant: :mini, options: { class: "h-4 w-4" } %>
|
|
78
|
+
<% end %>
|
|
79
|
+
</div>
|
|
80
|
+
<% end %>
|
|
81
|
+
</td>
|
|
82
|
+
</tr>
|
|
83
|
+
<% end %>
|
|
84
|
+
</tbody>
|
|
85
|
+
</table>
|
|
86
|
+
</div>
|
|
87
|
+
<% end %>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<%# Shared organization form used by new/edit. %>
|
|
2
|
+
<% suggested_company_name = @organization.persisted? ? nil : "#{::Faker::Company.name} #{::Faker::Company.suffix}" %>
|
|
3
|
+
<%= form_with(model: @organization, local: true, class: "space-y-6") do |form| %>
|
|
4
|
+
<% if @organization.errors.any? %>
|
|
5
|
+
<div class="rounded-lg bg-red-50 p-4 border border-red-200">
|
|
6
|
+
<div class="flex">
|
|
7
|
+
<div class="flex-shrink-0">
|
|
8
|
+
<%= heroicon "x-circle", variant: :solid, options: { class: "h-5 w-5 text-red-400" } %>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="ml-3">
|
|
11
|
+
<h3 class="text-sm font-medium text-red-800">Could not save organization:</h3>
|
|
12
|
+
<div class="mt-2 text-sm text-red-700">
|
|
13
|
+
<ul class="list-disc pl-5 space-y-1">
|
|
14
|
+
<% @organization.errors.full_messages.each do |message| %>
|
|
15
|
+
<li><%= message %></li>
|
|
16
|
+
<% end %>
|
|
17
|
+
</ul>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<div>
|
|
25
|
+
<%= form.label :name, "Organization name", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
|
26
|
+
<%= form.text_field :name,
|
|
27
|
+
required: true,
|
|
28
|
+
autofocus: true,
|
|
29
|
+
value: @organization.name.presence || suggested_company_name,
|
|
30
|
+
placeholder: "Acme Inc.",
|
|
31
|
+
class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-slate-500 focus:ring-slate-500 sm:text-sm" %>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="flex items-center justify-end gap-3 pt-4">
|
|
35
|
+
<%= link_to "Cancel", (@organization.persisted? ? organization_path(@organization) : organizations_path),
|
|
36
|
+
class: "inline-flex items-center rounded-lg bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
|
37
|
+
<%= form.submit class: "inline-flex items-center rounded-lg bg-slate-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-500 cursor-pointer" %>
|
|
38
|
+
</div>
|
|
39
|
+
<% 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">Edit Organization</h1>
|
|
4
|
+
<p class="mt-1 text-sm text-gray-500">Update organization details.</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,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>
|