plutonium 0.39.2 → 0.40.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/.claude/skills/plutonium-connect-resource/SKILL.md +19 -1
- data/.claude/skills/plutonium-controller/SKILL.md +5 -9
- data/.claude/skills/plutonium-definition-query/SKILL.md +10 -2
- data/.claude/skills/plutonium-installation/SKILL.md +9 -7
- data/.claude/skills/plutonium-invites/SKILL.md +363 -0
- data/.claude/skills/plutonium-package/SKILL.md +2 -1
- data/.claude/skills/plutonium-portal/SKILL.md +30 -16
- data/.claude/skills/plutonium-rodauth/SKILL.md +111 -18
- data/CHANGELOG.md +43 -0
- data/app/assets/plutonium.css +1 -1
- data/config/initializers/sqlite_alias.rb +8 -8
- data/docs/.vitepress/config.ts +1 -0
- data/docs/getting-started/tutorial/07-author-portal.md +1 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +5 -2
- data/docs/guides/adding-resources.md +10 -0
- data/docs/guides/authentication.md +15 -8
- data/docs/guides/creating-packages.md +13 -8
- data/docs/guides/index.md +2 -0
- data/docs/guides/search-filtering.md +8 -3
- data/docs/guides/user-invites.md +497 -0
- data/docs/public/templates/base.rb +5 -1
- data/docs/public/templates/lite.rb +42 -0
- data/docs/public/templates/pluton8.rb +7 -2
- data/docs/reference/controller/index.md +12 -7
- data/docs/reference/definition/query.md +12 -3
- data/docs/reference/generators/index.md +70 -10
- data/docs/reference/portal/index.md +22 -11
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +31 -0
- data/lib/generators/pu/gem/active_shrine/templates/config/initializers/shrine.rb.tt +58 -0
- data/lib/generators/pu/gem/annotated/templates/lib/tasks/auto_annotate_models.rake +6 -1
- data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -0
- data/lib/generators/pu/invites/USAGE +27 -0
- data/lib/generators/pu/invites/install_generator.rb +364 -0
- data/lib/generators/pu/invites/invitable/USAGE +31 -0
- data/lib/generators/pu/invites/invitable_generator.rb +143 -0
- data/lib/generators/pu/invites/templates/INSTRUCTIONS +22 -0
- data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +24 -0
- data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +26 -0
- data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +47 -0
- data/lib/generators/pu/invites/templates/invitable/invitation.html.erb.tt +45 -0
- data/lib/generators/pu/invites/templates/invitable/invitation.text.erb.tt +15 -0
- data/lib/generators/pu/invites/templates/invitable/invite_user_interaction.rb.tt +33 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +77 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +68 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +23 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/cancel_invite_interaction.rb.tt +7 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/resend_invite_interaction.rb.tt +7 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +34 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +41 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +33 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/error.html.erb.tt +24 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +40 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +39 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +49 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +45 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +15 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +23 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +33 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +23 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +130 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +72 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -2
- data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +7 -1
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +105 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +88 -0
- data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +14 -0
- data/lib/generators/pu/lite/setup/setup_generator.rb +54 -0
- data/lib/generators/pu/lite/solid_cable/solid_cable_generator.rb +65 -0
- data/lib/generators/pu/lite/solid_cache/solid_cache_generator.rb +66 -0
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +61 -0
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +107 -0
- data/lib/generators/pu/pkg/portal/USAGE +8 -2
- data/lib/generators/pu/pkg/portal/portal_generator.rb +11 -1
- data/lib/generators/pu/pkg/portal/templates/app/controllers/concerns/controller.rb.tt +2 -0
- data/lib/generators/pu/pkg/portal/templates/app/controllers/plutonium_controller.rb.tt +1 -0
- data/lib/generators/pu/pkg/portal/templates/app/controllers/resource_controller.rb.tt +7 -0
- data/lib/generators/pu/pkg/portal/templates/lib/engine.rb.tt +3 -0
- data/lib/generators/pu/res/conn/USAGE +5 -0
- data/lib/generators/pu/res/conn/conn_generator.rb +30 -4
- data/lib/generators/pu/res/scaffold/scaffold_generator.rb +6 -3
- data/lib/generators/pu/res/scaffold/templates/policy.rb.tt +6 -6
- data/lib/generators/pu/rodauth/account_generator.rb +36 -11
- data/lib/generators/pu/rodauth/admin_generator.rb +55 -0
- data/lib/generators/pu/rodauth/install_generator.rb +1 -8
- data/lib/generators/pu/rodauth/templates/app/interactions/invite_admin_interaction.rb.tt +25 -0
- data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +6 -2
- data/lib/generators/pu/saas/USAGE +22 -0
- data/lib/generators/pu/saas/entity/USAGE +19 -0
- data/lib/generators/pu/saas/entity_generator.rb +55 -0
- data/lib/generators/pu/saas/membership/USAGE +25 -0
- data/lib/generators/pu/saas/membership_generator.rb +165 -0
- data/lib/generators/pu/saas/setup/USAGE +27 -0
- data/lib/generators/pu/saas/setup_generator.rb +98 -0
- data/lib/generators/pu/saas/user/USAGE +21 -0
- data/lib/generators/pu/saas/user_generator.rb +66 -0
- data/lib/plutonium/definition/base.rb +3 -1
- data/lib/plutonium/definition/scoping.rb +20 -0
- data/lib/plutonium/invites/concerns/cancel_invite.rb +44 -0
- data/lib/plutonium/invites/concerns/invitable.rb +98 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +186 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +147 -0
- data/lib/plutonium/invites/concerns/resend_invite.rb +66 -0
- data/lib/plutonium/invites/controller.rb +226 -0
- data/lib/plutonium/invites/pending_invite_check.rb +76 -0
- data/lib/plutonium/invites.rb +6 -0
- data/lib/plutonium/resource/controllers/queryable.rb +4 -0
- data/lib/plutonium/resource/query_object.rb +3 -5
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- metadata +64 -7
- data/lib/generators/pu/res/entity/entity_generator.rb +0 -158
- data/lib/generators/pu/rodauth/customer_generator.rb +0 -101
- data/public/plutonium-assets/plutonium-logo-original.png +0 -0
- data/public/plutonium-assets/plutonium-logo-white.png +0 -0
- data/public/plutonium-assets/plutonium-logo.png +0 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record/migration"
|
|
5
|
+
require_relative "../lib/plutonium_generators"
|
|
6
|
+
|
|
7
|
+
module Pu
|
|
8
|
+
module Invites
|
|
9
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
10
|
+
include ::ActiveRecord::Generators::Migration
|
|
11
|
+
include PlutoniumGenerators::Generator
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Install user invites package"
|
|
16
|
+
|
|
17
|
+
class_option :entity_model, type: :string, default: "Entity",
|
|
18
|
+
desc: "The entity model name for scoping invites"
|
|
19
|
+
|
|
20
|
+
class_option :user_model, type: :string, default: "User",
|
|
21
|
+
desc: "The user model name"
|
|
22
|
+
|
|
23
|
+
class_option :membership_model, type: :string,
|
|
24
|
+
desc: "The membership model name (defaults to <Entity>User)"
|
|
25
|
+
|
|
26
|
+
class_option :roles, type: :array, default: %w[member admin],
|
|
27
|
+
desc: "Available roles for invitations"
|
|
28
|
+
|
|
29
|
+
class_option :rodauth, type: :string, default: "user",
|
|
30
|
+
desc: "Rodauth configuration name for signup integration"
|
|
31
|
+
|
|
32
|
+
class_option :enforce_domain, type: :boolean, default: false,
|
|
33
|
+
desc: "Require invited user email to match entity domain"
|
|
34
|
+
|
|
35
|
+
def validate_requirements
|
|
36
|
+
errors = []
|
|
37
|
+
|
|
38
|
+
unless File.exist?(Rails.root.join(entity_model_path))
|
|
39
|
+
errors << "Entity model not found: #{entity_model_path}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
unless File.exist?(Rails.root.join(entity_definition_path))
|
|
43
|
+
errors << "Entity definition not found: #{entity_definition_path}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
unless File.exist?(Rails.root.join(entity_policy_path))
|
|
47
|
+
errors << "Entity policy not found: #{entity_policy_path}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
unless File.exist?(Rails.root.join(user_definition_path))
|
|
51
|
+
errors << "User definition not found: #{user_definition_path}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
unless File.exist?(Rails.root.join(user_policy_path))
|
|
55
|
+
errors << "User policy not found: #{user_policy_path}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if errors.any?
|
|
59
|
+
errors.each { |e| say_status :error, e, :red }
|
|
60
|
+
raise Thor::Error, "Required files missing. Ensure #{entity_model} and #{user_model} resources exist."
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def create_package
|
|
65
|
+
generate "pu:pkg:package", "invites"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def create_user_invites_migration
|
|
69
|
+
migration_template "db/migrate/create_user_invites.rb",
|
|
70
|
+
"db/migrate/create_user_invites.rb"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def create_model
|
|
74
|
+
template "packages/invites/app/models/invites/user_invite.rb",
|
|
75
|
+
"packages/invites/app/models/invites/user_invite.rb"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def create_mailer
|
|
79
|
+
template "packages/invites/app/mailers/invites/user_invite_mailer.rb",
|
|
80
|
+
"packages/invites/app/mailers/invites/user_invite_mailer.rb"
|
|
81
|
+
|
|
82
|
+
template "packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb",
|
|
83
|
+
"packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb"
|
|
84
|
+
|
|
85
|
+
template "packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb",
|
|
86
|
+
"packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def create_controllers
|
|
90
|
+
template "packages/invites/app/controllers/invites/user_invitations_controller.rb",
|
|
91
|
+
"packages/invites/app/controllers/invites/user_invitations_controller.rb"
|
|
92
|
+
|
|
93
|
+
template "packages/invites/app/controllers/invites/welcome_controller.rb",
|
|
94
|
+
"packages/invites/app/controllers/invites/welcome_controller.rb"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def create_views
|
|
98
|
+
%w[landing show signup error].each do |view|
|
|
99
|
+
template "packages/invites/app/views/invites/user_invitations/#{view}.html.erb",
|
|
100
|
+
"packages/invites/app/views/invites/user_invitations/#{view}.html.erb"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
template "packages/invites/app/views/invites/welcome/pending_invitation.html.erb",
|
|
104
|
+
"packages/invites/app/views/invites/welcome/pending_invitation.html.erb"
|
|
105
|
+
|
|
106
|
+
template "packages/invites/app/views/layouts/invites/invitation.html.erb",
|
|
107
|
+
"packages/invites/app/views/layouts/invites/invitation.html.erb"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def create_interactions
|
|
111
|
+
template "packages/invites/app/interactions/invites/resend_invite_interaction.rb",
|
|
112
|
+
"packages/invites/app/interactions/invites/resend_invite_interaction.rb"
|
|
113
|
+
|
|
114
|
+
template "packages/invites/app/interactions/invites/cancel_invite_interaction.rb",
|
|
115
|
+
"packages/invites/app/interactions/invites/cancel_invite_interaction.rb"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def create_definition
|
|
119
|
+
template "packages/invites/app/definitions/invites/user_invite_definition.rb",
|
|
120
|
+
"packages/invites/app/definitions/invites/user_invite_definition.rb"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def create_policy
|
|
124
|
+
template "packages/invites/app/policies/invites/user_invite_policy.rb",
|
|
125
|
+
"packages/invites/app/policies/invites/user_invite_policy.rb"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def add_entity_association
|
|
129
|
+
inject_into_file entity_model_path,
|
|
130
|
+
"has_many :user_invites, class_name: \"Invites::UserInvite\", dependent: :destroy\n ",
|
|
131
|
+
before: "# add has_many associations above."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def create_entity_interaction
|
|
135
|
+
template "app/interactions/invite_user_interaction.rb",
|
|
136
|
+
"app/interactions/#{entity_table}/invite_user_interaction.rb"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def add_entity_action
|
|
140
|
+
inject_into_file entity_definition_path,
|
|
141
|
+
" action :invite_user, interaction: #{entity_model}::InviteUserInteraction, category: :secondary\n",
|
|
142
|
+
after: /class #{entity_model}Definition < .+\n/
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def add_entity_policy
|
|
146
|
+
inject_into_file entity_policy_path,
|
|
147
|
+
"def invite_user?\n user.is_a?(Admin) || current_membership&.admin?\n end\n\n ",
|
|
148
|
+
before: "# Core attributes"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def create_user_interaction
|
|
152
|
+
template "app/interactions/user_invite_user_interaction.rb",
|
|
153
|
+
"app/interactions/#{user_table}/invite_user_interaction.rb"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def add_user_action
|
|
157
|
+
inject_into_file user_definition_path,
|
|
158
|
+
" action :invite_user, interaction: #{user_model}::InviteUserInteraction, category: :primary\n",
|
|
159
|
+
after: /class #{user_model}Definition < .+\n/
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def add_user_policy
|
|
163
|
+
inject_into_file user_policy_path,
|
|
164
|
+
"def invite_user?\n user.is_a?(Admin) || current_membership&.admin?\n end\n\n ",
|
|
165
|
+
before: "# Core attributes"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def add_resource_policy_helper
|
|
169
|
+
resource_policy_path = "app/policies/resource_policy.rb"
|
|
170
|
+
|
|
171
|
+
return unless File.exist?(Rails.root.join(resource_policy_path))
|
|
172
|
+
|
|
173
|
+
file_content = File.read(Rails.root.join(resource_policy_path))
|
|
174
|
+
|
|
175
|
+
helper_code = <<-RUBY
|
|
176
|
+
def current_membership
|
|
177
|
+
return unless entity_scope && user
|
|
178
|
+
|
|
179
|
+
cache(entity_scope, user, :current_membership) { #{membership_model}.find_by(#{entity_table}: entity_scope, user: user) }
|
|
180
|
+
end
|
|
181
|
+
RUBY
|
|
182
|
+
|
|
183
|
+
if file_content.include?("private\n")
|
|
184
|
+
inject_into_file resource_policy_path,
|
|
185
|
+
"\n#{helper_code}",
|
|
186
|
+
after: "private\n"
|
|
187
|
+
else
|
|
188
|
+
inject_into_file resource_policy_path,
|
|
189
|
+
"\n private\n\n#{helper_code}",
|
|
190
|
+
before: /^end\s*\z/
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def add_routes
|
|
195
|
+
route_code = <<-RUBY
|
|
196
|
+
|
|
197
|
+
# User invitation routes (public, unauthenticated)
|
|
198
|
+
scope module: :invites do
|
|
199
|
+
get "welcome", to: "welcome#index", as: :invites_welcome
|
|
200
|
+
get "invitations/:token", to: "user_invitations#show", as: :invitation
|
|
201
|
+
post "invitations/:token/accept", to: "user_invitations#accept", as: :accept_invitation
|
|
202
|
+
get "invitations/:token/signup", to: "user_invitations#signup", as: :invitation_signup
|
|
203
|
+
post "invitations/:token/signup", to: "user_invitations#signup"
|
|
204
|
+
end
|
|
205
|
+
RUBY
|
|
206
|
+
|
|
207
|
+
inject_into_file "config/routes.rb",
|
|
208
|
+
route_code,
|
|
209
|
+
before: /^end\s*\z/
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def configure_rodauth
|
|
213
|
+
return unless rodauth?
|
|
214
|
+
|
|
215
|
+
rodauth_file = "app/rodauth/#{rodauth_config}_rodauth_plugin.rb"
|
|
216
|
+
|
|
217
|
+
unless File.exist?(Rails.root.join(rodauth_file))
|
|
218
|
+
say_status :skip, "Rodauth plugin not found: #{rodauth_file}", :yellow
|
|
219
|
+
return
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
file_content = File.read(Rails.root.join(rodauth_file))
|
|
223
|
+
|
|
224
|
+
# Check if already configured
|
|
225
|
+
if file_content.include?("after_welcome_redirect")
|
|
226
|
+
say_status :skip, "Rodauth already configured for invites", :yellow
|
|
227
|
+
return
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Check for existing after_login block (non-commented)
|
|
231
|
+
# Single-line: after_login { remember_login }
|
|
232
|
+
single_line_match = file_content.match(/^(\s*)after_login\s*\{\s*(.+?)\s*\}/)
|
|
233
|
+
# Multi-line: after_login do ... end
|
|
234
|
+
multi_line_match = file_content.match(/^(\s*)after_login\s+do\s*\n(.*?)\n(\s*)end/m)
|
|
235
|
+
|
|
236
|
+
if single_line_match
|
|
237
|
+
# Convert single-line block to multi-line with our code added
|
|
238
|
+
indent = single_line_match[1]
|
|
239
|
+
existing_code = single_line_match[2]
|
|
240
|
+
original_line = single_line_match[0]
|
|
241
|
+
|
|
242
|
+
new_block = <<~RUBY.chomp
|
|
243
|
+
#{indent}after_login do
|
|
244
|
+
#{indent} #{existing_code}
|
|
245
|
+
#{indent} session[:after_welcome_redirect] = session.delete(:login_redirect)
|
|
246
|
+
#{indent}end
|
|
247
|
+
RUBY
|
|
248
|
+
|
|
249
|
+
gsub_file rodauth_file, original_line, new_block
|
|
250
|
+
say_status :info, "Added session redirect to existing after_login block", :green
|
|
251
|
+
elsif multi_line_match
|
|
252
|
+
# Multi-line block - add our line before the end
|
|
253
|
+
indent = multi_line_match[1]
|
|
254
|
+
block_content = multi_line_match[2]
|
|
255
|
+
end_indent = multi_line_match[3]
|
|
256
|
+
original_block = multi_line_match[0]
|
|
257
|
+
|
|
258
|
+
new_block = <<~RUBY.chomp
|
|
259
|
+
#{indent}after_login do
|
|
260
|
+
#{block_content}
|
|
261
|
+
#{indent} session[:after_welcome_redirect] = session.delete(:login_redirect)
|
|
262
|
+
#{end_indent}end
|
|
263
|
+
RUBY
|
|
264
|
+
|
|
265
|
+
gsub_file rodauth_file, original_block, new_block
|
|
266
|
+
say_status :info, "Added session redirect to existing after_login block", :green
|
|
267
|
+
else
|
|
268
|
+
# Add new after_login block
|
|
269
|
+
after_login_code = <<-RUBY
|
|
270
|
+
|
|
271
|
+
# ==> User Invites - Move captured path to session for WelcomeController
|
|
272
|
+
after_login do
|
|
273
|
+
session[:after_welcome_redirect] = session.delete(:login_redirect)
|
|
274
|
+
end
|
|
275
|
+
RUBY
|
|
276
|
+
|
|
277
|
+
inject_into_file rodauth_file,
|
|
278
|
+
after_login_code,
|
|
279
|
+
before: /^ end\s*\n/
|
|
280
|
+
|
|
281
|
+
say_status :info, "Added after_login block for invites", :green
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Add other config options if not present
|
|
285
|
+
unless file_content.include?("login_return_to_requested_location?")
|
|
286
|
+
inject_into_file rodauth_file,
|
|
287
|
+
"\n # Enable path capture so Rodauth stores the originally requested URL\n login_return_to_requested_location? true\n",
|
|
288
|
+
after: /login_redirect.*\n/
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Update login_redirect to /welcome
|
|
292
|
+
gsub_file rodauth_file,
|
|
293
|
+
/login_redirect\s+["']\/["']/,
|
|
294
|
+
'login_redirect "/welcome"'
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def show_instructions
|
|
298
|
+
readme "INSTRUCTIONS" if behavior == :invoke
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private
|
|
302
|
+
|
|
303
|
+
def entity_model
|
|
304
|
+
options[:entity_model].camelize
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def entity_table
|
|
308
|
+
options[:entity_model].underscore
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def entity_model_path
|
|
312
|
+
"app/models/#{entity_table}.rb"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def entity_definition_path
|
|
316
|
+
"app/definitions/#{entity_table}_definition.rb"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def entity_policy_path
|
|
320
|
+
"app/policies/#{entity_table}_policy.rb"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def user_model
|
|
324
|
+
options[:user_model].camelize
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def user_table
|
|
328
|
+
options[:user_model].underscore
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def user_definition_path
|
|
332
|
+
"app/definitions/#{user_table}_definition.rb"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def user_policy_path
|
|
336
|
+
"app/policies/#{user_table}_policy.rb"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def membership_model
|
|
340
|
+
options[:membership_model] || "#{entity_model}User"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def roles
|
|
344
|
+
Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def roles_enum
|
|
348
|
+
roles.each_with_index.map { |r, i| "#{r}: #{i}" }.join(", ")
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def rodauth_config
|
|
352
|
+
options[:rodauth]
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def rodauth?
|
|
356
|
+
rodauth_config.present?
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def enforce_domain?
|
|
360
|
+
options[:enforce_domain]
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Connect an invitable model to send user invites.
|
|
3
|
+
The invitable is a model that triggers invites and gets notified on acceptance.
|
|
4
|
+
|
|
5
|
+
Example:
|
|
6
|
+
rails g pu:invites:invitable Tenant
|
|
7
|
+
rails g pu:invites:invitable Tenant --role=tenant
|
|
8
|
+
rails g pu:invites:invitable TeamMember --role=member
|
|
9
|
+
rails g pu:invites:invitable Tenant --no-email-templates
|
|
10
|
+
|
|
11
|
+
This will create:
|
|
12
|
+
app/interactions/tenant/invite_user_interaction.rb
|
|
13
|
+
packages/invites/app/views/invites/user_invite_mailer/invitation_tenant.html.erb
|
|
14
|
+
packages/invites/app/views/invites/user_invite_mailer/invitation_tenant.text.erb
|
|
15
|
+
|
|
16
|
+
And inject:
|
|
17
|
+
- include Plutonium::Invites::Concerns::Invitable in the model
|
|
18
|
+
- action :invite_user in the definition
|
|
19
|
+
- invite_user? policy method
|
|
20
|
+
|
|
21
|
+
After generation, implement Tenant#on_invite_accepted(user):
|
|
22
|
+
def on_invite_accepted(user)
|
|
23
|
+
update!(user: user)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--role=ROLE Role to assign to invited users (default: member)
|
|
28
|
+
--user-model=NAME User model name (default: User)
|
|
29
|
+
--membership-model=NAME Membership model (default: EntityUser)
|
|
30
|
+
--dest=PACKAGE Destination package (default: main_app)
|
|
31
|
+
--[no-]email-templates Generate custom email templates (default: true)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require_relative "../lib/plutonium_generators"
|
|
5
|
+
|
|
6
|
+
module Pu
|
|
7
|
+
module Invites
|
|
8
|
+
class InvitableGenerator < ::Rails::Generators::Base
|
|
9
|
+
include PlutoniumGenerators::Generator
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Connect an invitable model to send user invites"
|
|
14
|
+
|
|
15
|
+
argument :model, type: :string,
|
|
16
|
+
desc: "The invitable model (e.g., Tenant, TeamMember)"
|
|
17
|
+
|
|
18
|
+
class_option :role, type: :string, default: "member",
|
|
19
|
+
desc: "Role to assign to invited users"
|
|
20
|
+
|
|
21
|
+
class_option :user_model, type: :string, default: "User",
|
|
22
|
+
desc: "The user model name"
|
|
23
|
+
|
|
24
|
+
class_option :membership_model, type: :string,
|
|
25
|
+
desc: "The membership model name (defaults to EntityUser)"
|
|
26
|
+
|
|
27
|
+
class_option :dest, type: :string, default: "main_app",
|
|
28
|
+
desc: "Destination package for the interaction"
|
|
29
|
+
|
|
30
|
+
class_option :email_templates, type: :boolean, default: true,
|
|
31
|
+
desc: "Generate custom email templates for this invitable"
|
|
32
|
+
|
|
33
|
+
def validate_requirements
|
|
34
|
+
errors = []
|
|
35
|
+
|
|
36
|
+
unless File.exist?(Rails.root.join(model_file_path))
|
|
37
|
+
errors << "Model file not found: #{model_file_path}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
unless File.exist?(Rails.root.join(definition_file_path))
|
|
41
|
+
errors << "Definition file not found: #{definition_file_path}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
unless File.exist?(Rails.root.join(policy_file_path))
|
|
45
|
+
errors << "Policy file not found: #{policy_file_path}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if errors.any?
|
|
49
|
+
errors.each { |e| say_status :error, e, :red }
|
|
50
|
+
raise Thor::Error, "Required files missing. Ensure #{model_class} resource exists."
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def create_interaction
|
|
55
|
+
template "invitable/invite_user_interaction.rb",
|
|
56
|
+
interaction_path
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def add_invitable_concern
|
|
60
|
+
inject_into_file model_file_path,
|
|
61
|
+
"include Plutonium::Invites::Concerns::Invitable\n ",
|
|
62
|
+
before: "# add concerns above."
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def add_definition_action
|
|
66
|
+
inject_into_file definition_file_path,
|
|
67
|
+
" action :invite_user, interaction: #{model_class}::InviteUserInteraction, category: :secondary\n",
|
|
68
|
+
after: /class #{model_class}Definition < .+\n/
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def add_policy_method
|
|
72
|
+
inject_into_file policy_file_path,
|
|
73
|
+
"def invite_user?\n record.can_invite_user?\n end\n\n ",
|
|
74
|
+
before: "# Core attributes"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def create_email_templates
|
|
78
|
+
return unless options[:email_templates]
|
|
79
|
+
|
|
80
|
+
template "invitable/invitation.html.erb",
|
|
81
|
+
"packages/invites/app/views/invites/user_invite_mailer/invitation_#{model_table}.html.erb"
|
|
82
|
+
|
|
83
|
+
template "invitable/invitation.text.erb",
|
|
84
|
+
"packages/invites/app/views/invites/user_invite_mailer/invitation_#{model_table}.text.erb"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def show_instructions
|
|
88
|
+
say "\n"
|
|
89
|
+
say "Connected #{model_class} as invitable!", :green
|
|
90
|
+
say "\n"
|
|
91
|
+
say "Implement #{model_class}#on_invite_accepted(user) to handle post-acceptance logic."
|
|
92
|
+
say "\n"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def model_class
|
|
98
|
+
model.camelize
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def model_table
|
|
102
|
+
model.underscore
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def user_model
|
|
106
|
+
options[:user_model].camelize
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def user_table
|
|
110
|
+
options[:user_model].underscore
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def model_file_path
|
|
114
|
+
"app/models/#{model_table}.rb"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def definition_file_path
|
|
118
|
+
"app/definitions/#{model_table}_definition.rb"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def policy_file_path
|
|
122
|
+
"app/policies/#{model_table}_policy.rb"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def role
|
|
126
|
+
options[:role]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def membership_model
|
|
130
|
+
options[:membership_model] || "EntityUser"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def interaction_path
|
|
134
|
+
dest = options[:dest]&.underscore
|
|
135
|
+
if dest == "main_app"
|
|
136
|
+
"app/interactions/#{model_table}/invite_user_interaction.rb"
|
|
137
|
+
else
|
|
138
|
+
"packages/#{dest}/app/interactions/#{dest}/#{model_table}/invite_user_interaction.rb"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
===============================================================================
|
|
3
|
+
|
|
4
|
+
User invites package installed successfully!
|
|
5
|
+
|
|
6
|
+
Next steps:
|
|
7
|
+
|
|
8
|
+
1. Run migrations:
|
|
9
|
+
rails db:migrate
|
|
10
|
+
|
|
11
|
+
2. Register Invites::UserInvite in your portal routes:
|
|
12
|
+
# packages/<portal>/config/routes.rb
|
|
13
|
+
register_resource ::Invites::UserInvite
|
|
14
|
+
|
|
15
|
+
3. (Optional) Connect invitable models that trigger invites:
|
|
16
|
+
rails g pu:invites:invitable Tenant
|
|
17
|
+
rails g pu:invites:invitable TeamMember --role=member
|
|
18
|
+
|
|
19
|
+
Then implement on_invite_accepted(user) in your invitable model.
|
|
20
|
+
|
|
21
|
+
===============================================================================
|
|
22
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= entity_model %>::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
4
|
+
include Plutonium::Invites::Concerns::InviteUser
|
|
5
|
+
|
|
6
|
+
attribute :role
|
|
7
|
+
input :role, as: :select, choices: Invites::UserInvite.roles.keys
|
|
8
|
+
<% if membership_model != "EntityUser" || user_model != "User" -%>
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
<% end -%>
|
|
12
|
+
<% if membership_model != "EntityUser" -%>
|
|
13
|
+
|
|
14
|
+
def membership_class
|
|
15
|
+
<%= membership_model %>
|
|
16
|
+
end
|
|
17
|
+
<% end -%>
|
|
18
|
+
<% if user_model != "User" -%>
|
|
19
|
+
|
|
20
|
+
def user_class
|
|
21
|
+
<%= user_model %>
|
|
22
|
+
end
|
|
23
|
+
<% end -%>
|
|
24
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= user_model %>::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
4
|
+
include Plutonium::Invites::Concerns::InviteUser
|
|
5
|
+
|
|
6
|
+
attribute :role
|
|
7
|
+
input :role, as: :select, choices: Invites::UserInvite.roles.keys
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def entity
|
|
12
|
+
current_entity
|
|
13
|
+
end
|
|
14
|
+
<% if membership_model != "EntityUser" -%>
|
|
15
|
+
|
|
16
|
+
def membership_class
|
|
17
|
+
<%= membership_model %>
|
|
18
|
+
end
|
|
19
|
+
<% end -%>
|
|
20
|
+
<% if user_model != "User" -%>
|
|
21
|
+
|
|
22
|
+
def user_class
|
|
23
|
+
<%= user_model %>
|
|
24
|
+
end
|
|
25
|
+
<% end -%>
|
|
26
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateUserInvites < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
create_table :user_invites do |t|
|
|
6
|
+
# Entity association
|
|
7
|
+
t.belongs_to :<%= entity_table %>, null: false, foreign_key: true
|
|
8
|
+
|
|
9
|
+
# Invitation details
|
|
10
|
+
t.string :email, null: false
|
|
11
|
+
t.text :token, null: false
|
|
12
|
+
t.integer :role, null: false, default: 0
|
|
13
|
+
t.integer :state, null: false, default: 0
|
|
14
|
+
|
|
15
|
+
# Timestamps
|
|
16
|
+
t.datetime :expires_at
|
|
17
|
+
t.datetime :accepted_at
|
|
18
|
+
|
|
19
|
+
# Who sent the invite (polymorphic for flexibility)
|
|
20
|
+
t.belongs_to :invited_by, null: false, polymorphic: true
|
|
21
|
+
|
|
22
|
+
# User who accepted (filled after acceptance)
|
|
23
|
+
t.belongs_to :<%= user_table %>, null: true, foreign_key: true
|
|
24
|
+
|
|
25
|
+
# Invitable: model that triggered this invite (optional, polymorphic)
|
|
26
|
+
t.belongs_to :invitable, null: true, polymorphic: true
|
|
27
|
+
|
|
28
|
+
# Additional data
|
|
29
|
+
t.jsonb :metadata, default: {}
|
|
30
|
+
|
|
31
|
+
t.timestamps
|
|
32
|
+
|
|
33
|
+
# Unique token index
|
|
34
|
+
t.index :token, unique: true
|
|
35
|
+
|
|
36
|
+
# Only one pending invite per email per entity
|
|
37
|
+
t.index [:<%= entity_table %>_id, :email], unique: true, where: "state = 0",
|
|
38
|
+
name: "index_user_invites_on_entity_email_pending"
|
|
39
|
+
|
|
40
|
+
# Only one pending invite per invitable (when invitable is present)
|
|
41
|
+
t.index [:invitable_type, :invitable_id],
|
|
42
|
+
unique: true,
|
|
43
|
+
where: "state = 0 AND invitable_id IS NOT NULL",
|
|
44
|
+
name: "index_user_invites_on_invitable_pending"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|