organizations 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +8 -9
- data/.rubocop_todo.yml +625 -0
- data/CHANGELOG.md +53 -0
- data/README.md +414 -46
- data/app/controllers/organizations/application_controller.rb +4 -0
- data/app/controllers/organizations/memberships_controller.rb +3 -3
- data/app/controllers/organizations/public_controller.rb +26 -0
- data/app/mailers/organizations/invitation_mailer.rb +38 -18
- data/app/mailers/organizations/verification_mailer.rb +56 -0
- data/app/models/organizations/allowlist_entry.rb +6 -0
- data/app/models/organizations/domain.rb +6 -0
- data/app/models/organizations/join_code.rb +6 -0
- data/app/models/organizations/join_request.rb +6 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
- data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
- data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
- data/config/locales/en.yml +158 -0
- data/config/locales/es.yml +126 -0
- data/config/routes.rb +46 -28
- data/gemfiles/rails_7.2.gemfile +3 -3
- data/gemfiles/rails_8.1.gemfile +3 -3
- data/lib/generators/organizations/install/install_generator.rb +3 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
- data/lib/generators/organizations/install/templates/initializer.rb +53 -0
- data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
- data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
- data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
- data/lib/generators/organizations/views/views_generator.rb +47 -0
- data/lib/organizations/callback_context.rb +9 -0
- data/lib/organizations/callbacks.rb +26 -18
- data/lib/organizations/configuration.rb +275 -1
- data/lib/organizations/controller_helpers.rb +38 -12
- data/lib/organizations/email_normalizer.rb +65 -0
- data/lib/organizations/join_flow.rb +227 -0
- data/lib/organizations/join_state.rb +117 -0
- data/lib/organizations/metadata_flags.rb +79 -0
- data/lib/organizations/models/allowlist_entry.rb +88 -0
- data/lib/organizations/models/concerns/has_organizations.rb +100 -12
- data/lib/organizations/models/domain.rb +85 -0
- data/lib/organizations/models/invitation.rb +105 -17
- data/lib/organizations/models/join_code.rb +242 -0
- data/lib/organizations/models/join_request.rb +627 -0
- data/lib/organizations/models/membership.rb +39 -8
- data/lib/organizations/models/organization.rb +317 -19
- data/lib/organizations/organization_scoped.rb +135 -0
- data/lib/organizations/test_helpers.rb +35 -1
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +27 -19
- data/lib/organizations.rb +165 -0
- metadata +34 -2
data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb
ADDED
|
@@ -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>
|