organizations 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -9
  3. data/.rubocop_todo.yml +625 -0
  4. data/CHANGELOG.md +53 -0
  5. data/README.md +414 -46
  6. data/app/controllers/organizations/application_controller.rb +4 -0
  7. data/app/controllers/organizations/memberships_controller.rb +3 -3
  8. data/app/controllers/organizations/public_controller.rb +26 -0
  9. data/app/mailers/organizations/invitation_mailer.rb +38 -18
  10. data/app/mailers/organizations/verification_mailer.rb +56 -0
  11. data/app/models/organizations/allowlist_entry.rb +6 -0
  12. data/app/models/organizations/domain.rb +6 -0
  13. data/app/models/organizations/join_code.rb +6 -0
  14. data/app/models/organizations/join_request.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
  19. data/config/locales/en.yml +158 -0
  20. data/config/locales/es.yml +126 -0
  21. data/config/routes.rb +46 -28
  22. data/gemfiles/rails_7.2.gemfile +3 -3
  23. data/gemfiles/rails_8.1.gemfile +3 -3
  24. data/lib/generators/organizations/install/install_generator.rb +3 -0
  25. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
  26. data/lib/generators/organizations/install/templates/initializer.rb +53 -0
  27. data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
  28. data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
  29. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
  30. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
  31. data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
  32. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
  33. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
  34. data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
  35. data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
  36. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
  37. data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
  38. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
  39. data/lib/generators/organizations/views/views_generator.rb +47 -0
  40. data/lib/organizations/callback_context.rb +9 -0
  41. data/lib/organizations/callbacks.rb +26 -18
  42. data/lib/organizations/configuration.rb +275 -1
  43. data/lib/organizations/controller_helpers.rb +38 -12
  44. data/lib/organizations/email_normalizer.rb +65 -0
  45. data/lib/organizations/join_flow.rb +227 -0
  46. data/lib/organizations/join_state.rb +117 -0
  47. data/lib/organizations/metadata_flags.rb +79 -0
  48. data/lib/organizations/models/allowlist_entry.rb +88 -0
  49. data/lib/organizations/models/concerns/has_organizations.rb +100 -12
  50. data/lib/organizations/models/domain.rb +85 -0
  51. data/lib/organizations/models/invitation.rb +105 -17
  52. data/lib/organizations/models/join_code.rb +242 -0
  53. data/lib/organizations/models/join_request.rb +627 -0
  54. data/lib/organizations/models/membership.rb +39 -8
  55. data/lib/organizations/models/organization.rb +317 -19
  56. data/lib/organizations/organization_scoped.rb +135 -0
  57. data/lib/organizations/test_helpers.rb +35 -1
  58. data/lib/organizations/version.rb +1 -1
  59. data/lib/organizations/view_helpers.rb +27 -19
  60. data/lib/organizations.rb +165 -0
  61. metadata +34 -2
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Upgrades organizations <= 0.4.x to 0.5.0 ("Verified Joining").
4
+ # Additive only: no existing columns/rows are touched.
5
+ class AddVerifiedJoiningToOrganizations < ActiveRecord::Migration<%= migration_version %>
6
+ def change
7
+ # ⚠️ If your user model is NOT `User` (config.user_class), adjust every
8
+ # `to_table: :users` below (and `foreign_key: true` on user references)
9
+ # to your user table before migrating.
10
+ _primary_key_type, foreign_key_type = primary_and_foreign_key_types
11
+ adapter = connection.adapter_name.downcase
12
+
13
+ # === Membership provenance ===
14
+
15
+ add_column :organizations_memberships, :joined_via, :string
16
+ add_column :organizations_memberships, :verified_email, :string
17
+ add_column :organizations_memberships, :verified_email_normalized, :string
18
+ add_column :organizations_memberships, :verified_at, :datetime
19
+
20
+ # One proven email address => one membership per organization.
21
+ # NULLs never collide in unique indexes on every supported adapter, so
22
+ # existing (unverified) memberships are unconstrained.
23
+ add_index :organizations_memberships, [ :organization_id, :verified_email_normalized ],
24
+ unique: true, name: "index_org_memberships_verified_email_unique"
25
+
26
+ # === Invitation metadata parity ===
27
+
28
+ add_column :organizations_invitations, :metadata, json_column_type,
29
+ null: json_column_null, default: json_column_default
30
+ add_column :organizations_invitations, :membership_metadata, json_column_type,
31
+ null: json_column_null, default: json_column_default
32
+
33
+ # === Email domains (exact-match joining) ===
34
+
35
+ create_table :organizations_domains, id: primary_key_type_setting do |t|
36
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
37
+ t.string :domain, null: false
38
+ t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
39
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
40
+
41
+ t.timestamps
42
+ end
43
+
44
+ add_index :organizations_domains, [ :organization_id, :domain ], unique: true
45
+ add_index :organizations_domains, :domain
46
+
47
+ # === Join codes ("PINs") ===
48
+
49
+ create_table :organizations_join_codes, id: primary_key_type_setting do |t|
50
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
51
+ t.string :code, null: false
52
+ t.string :label
53
+ t.boolean :requires_verified_domain_email, null: false, default: false
54
+ t.boolean :auto_approve, null: false, default: true
55
+ t.datetime :expires_at
56
+ t.integer :max_uses
57
+ t.integer :uses_count, null: false, default: 0
58
+ t.datetime :revoked_at
59
+ t.references :created_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
60
+ t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
61
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
62
+
63
+ t.timestamps
64
+ end
65
+
66
+ add_index :organizations_join_codes, :code, unique: true
67
+
68
+ # === Allowlist / roster entries ===
69
+
70
+ create_table :organizations_allowlist_entries, id: primary_key_type_setting do |t|
71
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
72
+ t.string :email, null: false
73
+ t.string :email_normalized, null: false
74
+ t.string :source
75
+ t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
76
+ t.datetime :claimed_at
77
+ t.references :claimed_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
78
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
79
+
80
+ t.timestamps
81
+ end
82
+
83
+ add_index :organizations_allowlist_entries, [ :organization_id, :email_normalized ], unique: true
84
+
85
+ # === Join requests ===
86
+
87
+ create_table :organizations_join_requests, id: primary_key_type_setting do |t|
88
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
89
+ t.references :user, null: false, type: foreign_key_type, foreign_key: true
90
+ t.string :status, null: false, default: "pending"
91
+ t.string :joined_via
92
+ t.references :join_code, null: true, type: foreign_key_type, foreign_key: { to_table: :organizations_join_codes }
93
+ t.string :message
94
+ t.string :verification_email
95
+ t.string :verification_email_normalized
96
+ t.string :verification_code_digest
97
+ t.datetime :verification_sent_at
98
+ t.datetime :verification_expires_at
99
+ t.integer :verification_attempts, null: false, default: 0
100
+ t.integer :verification_sends_count, null: false, default: 0
101
+ t.datetime :verified_at
102
+ t.references :decided_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
103
+ t.datetime :decided_at
104
+ t.datetime :expires_at
105
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
106
+
107
+ t.timestamps
108
+ end
109
+
110
+ add_index :organizations_join_requests, :status
111
+
112
+ # One OPEN request per (organization, user); decided history never blocks
113
+ if adapter.include?("postgresql") || adapter.include?("sqlite")
114
+ reversible do |dir|
115
+ dir.up do
116
+ # ONE line on purpose: the SQLite schema dumper only recovers a
117
+ # partial index's WHERE clause from single-line index SQL — a
118
+ # multi-line statement dumps as a FULL unique index, and databases
119
+ # provisioned from schema.rb (db:schema:load, test DBs) silently
120
+ # lose the partial-index invariant.
121
+ execute "CREATE UNIQUE INDEX index_org_join_requests_pending_unique " \
122
+ "ON organizations_join_requests (organization_id, user_id) WHERE status = 'pending'"
123
+ end
124
+ dir.down do
125
+ execute "DROP INDEX IF EXISTS index_org_join_requests_pending_unique"
126
+ end
127
+ end
128
+ elsif adapter.include?("mysql")
129
+ reversible do |dir|
130
+ dir.up do
131
+ execute <<-SQL
132
+ ALTER TABLE organizations_join_requests
133
+ ADD COLUMN pending_user_key VARCHAR(255)
134
+ GENERATED ALWAYS AS (
135
+ CASE
136
+ WHEN status = 'pending' THEN CONCAT(organization_id, '-', user_id)
137
+ ELSE NULL
138
+ END
139
+ ) STORED
140
+ SQL
141
+ end
142
+ dir.down do
143
+ execute "ALTER TABLE organizations_join_requests DROP COLUMN pending_user_key"
144
+ end
145
+ end
146
+ add_index :organizations_join_requests, :pending_user_key, unique: true, name: "index_org_join_requests_pending_unique"
147
+ else
148
+ add_index :organizations_join_requests, [ :organization_id, :user_id ], name: "index_org_join_requests_on_org_and_user"
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ def primary_and_foreign_key_types
155
+ config = Rails.configuration.generators
156
+ setting = config.options[config.orm][ :primary_key_type ]
157
+ primary_key_type = setting || :primary_key
158
+ foreign_key_type = setting || :bigint
159
+ [primary_key_type, foreign_key_type]
160
+ end
161
+
162
+ def primary_key_type_setting
163
+ primary_and_foreign_key_types.first
164
+ end
165
+
166
+ def json_column_type
167
+ return :jsonb if connection.adapter_name.downcase.include?('postgresql')
168
+ :json
169
+ end
170
+
171
+ # MySQL 8+ doesn't allow default values on JSON columns.
172
+ def json_column_default
173
+ return nil if connection.adapter_name.downcase.include?('mysql')
174
+ {}
175
+ end
176
+
177
+ def json_column_null
178
+ connection.adapter_name.downcase.include?('mysql')
179
+ end
180
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Organizations
7
+ module Generators
8
+ # Upgrades an existing organizations install (<= 0.4.x) to 0.5.0
9
+ # ("Verified Joining"): new tables for domains, join codes, allowlist
10
+ # entries and join requests, plus provenance columns on memberships and
11
+ # metadata columns on invitations.
12
+ #
13
+ # Fresh installs don't need this — `rails g organizations:install`
14
+ # already includes everything.
15
+ #
16
+ # rails g organizations:upgrade
17
+ # rails db:migrate
18
+ #
19
+ class UpgradeGenerator < Rails::Generators::Base
20
+ include ActiveRecord::Generators::Migration
21
+
22
+ source_root File.expand_path("templates", __dir__)
23
+ desc "Add the verified-joining tables/columns (organizations 0.5.0) to an existing install"
24
+
25
+ def self.next_migration_number(dir)
26
+ ActiveRecord::Generators::Base.next_migration_number(dir)
27
+ end
28
+
29
+ def create_migration_file
30
+ migration_template "add_verified_joining_to_organizations.rb.erb",
31
+ File.join(db_migrate_path, "add_verified_joining_to_organizations.rb"),
32
+ migration_version: migration_version
33
+ end
34
+
35
+ def display_post_upgrade_message
36
+ say "\n✅ organizations verified-joining migration created.", :green
37
+ say "\nNext steps:"
38
+ say " 1. Run 'rails db:migrate'."
39
+ say " 2. See the README's \"Verified joining\" section for the new API"
40
+ say " (domains, join codes, allowlists, join requests)."
41
+ end
42
+
43
+ private
44
+
45
+ def migration_version
46
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -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>