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.
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 +46 -0
  5. data/README.md +405 -41
  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 +8 -12
  60. data/lib/organizations.rb +165 -0
  61. 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>