plutonium 0.43.2 → 0.44.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/CHANGELOG.md +19 -0
- data/Rakefile +24 -6
- data/app/assets/plutonium.css +1 -1
- data/config/initializers/pagy.rb +1 -4
- data/gemfiles/rails_7.gemfile.lock +8 -3
- data/gemfiles/rails_8.0.gemfile.lock +7 -3
- data/gemfiles/rails_8.1.gemfile.lock +7 -3
- data/lib/generators/pu/invites/install_generator.rb +69 -11
- data/lib/generators/pu/invites/templates/INSTRUCTIONS +4 -1
- data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +1 -1
- data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +5 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +14 -24
- data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +2 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +3 -2
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +3 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/logger.rb +2 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -6
- data/lib/generators/pu/lib/plutonium_generators/concerns/rodauth_redirects.rb +41 -0
- data/lib/generators/pu/lib/plutonium_generators/generator.rb +5 -1
- data/lib/generators/pu/lib/plutonium_generators/non_interactive_prompt.rb +27 -0
- data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +4 -0
- data/lib/generators/pu/saas/entity_generator.rb +16 -0
- data/lib/generators/pu/saas/membership_generator.rb +4 -3
- data/lib/generators/pu/saas/portal/USAGE +15 -0
- data/lib/generators/pu/saas/portal_generator.rb +122 -0
- data/lib/generators/pu/saas/setup/USAGE +17 -22
- data/lib/generators/pu/saas/setup_generator.rb +62 -9
- data/lib/generators/pu/saas/welcome/USAGE +27 -0
- data/lib/generators/pu/saas/welcome/templates/app/controllers/authenticated_controller.rb.tt +18 -0
- data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +69 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +33 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/welcome/onboarding.html.erb.tt +51 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/welcome/select_entity.html.erb.tt +22 -0
- data/lib/generators/pu/saas/welcome_generator.rb +197 -0
- data/lib/plutonium/auth/sequel_adapter.rb +1 -2
- data/lib/plutonium/core/controller.rb +18 -8
- data/lib/plutonium/core/controllers/association_resolver.rb +19 -6
- data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -1
- data/lib/plutonium/invites/concerns/invite_user.rb +1 -1
- data/lib/plutonium/resource/controller.rb +2 -3
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +1 -1
- data/lib/plutonium/resource/controllers/presentable.rb +2 -0
- data/lib/plutonium/ui/table/components/pagy_info.rb +1 -7
- data/lib/plutonium/ui/table/components/pagy_pagination.rb +4 -6
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +1 -1
- metadata +16 -8
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
plutonium (0.43.
|
|
4
|
+
plutonium (0.43.2)
|
|
5
5
|
action_policy (~> 0.7.0)
|
|
6
6
|
listen (~> 3.8)
|
|
7
|
-
pagy (~>
|
|
7
|
+
pagy (~> 43.0)
|
|
8
8
|
phlex (~> 2.0)
|
|
9
9
|
phlex-rails
|
|
10
10
|
phlex-slotable (>= 1.0.0)
|
|
@@ -187,7 +187,10 @@ GEM
|
|
|
187
187
|
nio4r (2.7.5)
|
|
188
188
|
nokogiri (1.19.1-arm64-darwin)
|
|
189
189
|
racc (~> 1.4)
|
|
190
|
-
pagy (
|
|
190
|
+
pagy (43.3.3)
|
|
191
|
+
json
|
|
192
|
+
uri
|
|
193
|
+
yaml
|
|
191
194
|
parallel (1.27.0)
|
|
192
195
|
parser (3.3.10.2)
|
|
193
196
|
ast (~> 2.4.1)
|
|
@@ -391,6 +394,7 @@ GEM
|
|
|
391
394
|
websocket-extensions (>= 0.1.0)
|
|
392
395
|
websocket-extensions (0.1.5)
|
|
393
396
|
wisper (2.0.1)
|
|
397
|
+
yaml (0.4.0)
|
|
394
398
|
zeitwerk (2.7.5)
|
|
395
399
|
|
|
396
400
|
PLATFORMS
|
|
@@ -9,6 +9,7 @@ module Pu
|
|
|
9
9
|
class InstallGenerator < ::Rails::Generators::Base
|
|
10
10
|
include ::ActiveRecord::Generators::Migration
|
|
11
11
|
include PlutoniumGenerators::Generator
|
|
12
|
+
include PlutoniumGenerators::Concerns::RodauthRedirects
|
|
12
13
|
|
|
13
14
|
source_root File.expand_path("templates", __dir__)
|
|
14
15
|
|
|
@@ -21,7 +22,7 @@ module Pu
|
|
|
21
22
|
desc: "The user model name"
|
|
22
23
|
|
|
23
24
|
class_option :membership_model, type: :string,
|
|
24
|
-
desc: "The membership model name (defaults to <Entity>
|
|
25
|
+
desc: "The membership model name (defaults to <Entity><User>)"
|
|
25
26
|
|
|
26
27
|
class_option :rodauth, type: :string, default: "user",
|
|
27
28
|
desc: "Rodauth configuration name for signup integration"
|
|
@@ -153,7 +154,7 @@ module Pu
|
|
|
153
154
|
|
|
154
155
|
def add_entity_policy
|
|
155
156
|
inject_into_file entity_policy_path,
|
|
156
|
-
"def invite_user?\n
|
|
157
|
+
"def invite_user?\n current_membership&.owner?\n end\n\n ",
|
|
157
158
|
before: "# Core attributes"
|
|
158
159
|
end
|
|
159
160
|
|
|
@@ -170,7 +171,7 @@ module Pu
|
|
|
170
171
|
|
|
171
172
|
def add_user_policy
|
|
172
173
|
inject_into_file user_policy_path,
|
|
173
|
-
"def invite_user?\n
|
|
174
|
+
"def invite_user?\n current_membership&.owner?\n end\n\n ",
|
|
174
175
|
before: "# Core attributes"
|
|
175
176
|
end
|
|
176
177
|
|
|
@@ -201,11 +202,18 @@ module Pu
|
|
|
201
202
|
end
|
|
202
203
|
|
|
203
204
|
def add_routes
|
|
205
|
+
routes_content = File.read(Rails.root.join("config/routes.rb"))
|
|
206
|
+
if routes_content.include?("# User invitation routes")
|
|
207
|
+
say_status :skip, "Invitation routes already present", :yellow
|
|
208
|
+
return
|
|
209
|
+
end
|
|
210
|
+
|
|
204
211
|
route_code = <<-RUBY
|
|
205
212
|
|
|
206
|
-
# User invitation routes
|
|
213
|
+
# User invitation routes
|
|
207
214
|
scope module: :invites do
|
|
208
|
-
get "welcome", to: "welcome#index", as: :
|
|
215
|
+
get "invitations/welcome", to: "welcome#index", as: :invites_welcome_check
|
|
216
|
+
delete "invitations/welcome", to: "welcome#skip", as: :invites_welcome_skip
|
|
209
217
|
get "invitations/:token", to: "user_invitations#show", as: :invitation
|
|
210
218
|
post "invitations/:token/accept", to: "user_invitations#accept", as: :accept_invitation
|
|
211
219
|
get "invitations/:token/signup", to: "user_invitations#signup", as: :invitation_signup
|
|
@@ -216,6 +224,20 @@ module Pu
|
|
|
216
224
|
inject_into_file "config/routes.rb",
|
|
217
225
|
route_code,
|
|
218
226
|
before: /^end\s*\z/
|
|
227
|
+
|
|
228
|
+
# If no main WelcomeController exists, add /welcome route pointing to
|
|
229
|
+
# Invites::WelcomeController so Rodauth's login_redirect "/welcome" works.
|
|
230
|
+
unless File.exist?(Rails.root.join("app/controllers/welcome_controller.rb"))
|
|
231
|
+
welcome_route = <<-RUBY
|
|
232
|
+
|
|
233
|
+
# Welcome route (handled by invites package — replace with pu:saas:welcome for full onboarding)
|
|
234
|
+
get "welcome", to: "invites/welcome#index"
|
|
235
|
+
RUBY
|
|
236
|
+
|
|
237
|
+
inject_into_file "config/routes.rb",
|
|
238
|
+
welcome_route,
|
|
239
|
+
before: /^\s*# User invitation routes/
|
|
240
|
+
end
|
|
219
241
|
end
|
|
220
242
|
|
|
221
243
|
def configure_rodauth
|
|
@@ -297,10 +319,46 @@ module Pu
|
|
|
297
319
|
after: /login_redirect.*\n/
|
|
298
320
|
end
|
|
299
321
|
|
|
300
|
-
# Update login_redirect to /welcome
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
322
|
+
# Update login_redirect and create_account_redirect to /welcome
|
|
323
|
+
update_rodauth_redirects(rodauth_file)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def integrate_with_welcome_controller
|
|
327
|
+
welcome_controller_path = Rails.root.join("app/controllers/welcome_controller.rb")
|
|
328
|
+
return unless File.exist?(welcome_controller_path)
|
|
329
|
+
|
|
330
|
+
file_content = File.read(welcome_controller_path)
|
|
331
|
+
return if file_content.include?("PendingInviteCheck")
|
|
332
|
+
|
|
333
|
+
relative_path = "app/controllers/welcome_controller.rb"
|
|
334
|
+
|
|
335
|
+
# Add PendingInviteCheck concern
|
|
336
|
+
inject_into_file relative_path,
|
|
337
|
+
" include Plutonium::Invites::PendingInviteCheck\n",
|
|
338
|
+
after: /class WelcomeController.*\n/
|
|
339
|
+
|
|
340
|
+
# Add invite check as first step in index
|
|
341
|
+
inject_into_file relative_path,
|
|
342
|
+
" return redirect_to(invites_welcome_check_path) if pending_invite\n\n",
|
|
343
|
+
after: /def index\n/
|
|
344
|
+
|
|
345
|
+
# Add invite_class method if not present
|
|
346
|
+
unless file_content.include?("def invite_class")
|
|
347
|
+
inject_into_file relative_path,
|
|
348
|
+
"\n def invite_class\n ::Invites::UserInvite\n end\n",
|
|
349
|
+
before: /^end\s*\z/
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Update Invites::WelcomeController to redirect to /welcome (the main hub)
|
|
353
|
+
# instead of / (the app root)
|
|
354
|
+
invites_welcome_path = "packages/invites/app/controllers/invites/welcome_controller.rb"
|
|
355
|
+
if File.exist?(Rails.root.join(invites_welcome_path))
|
|
356
|
+
gsub_file invites_welcome_path,
|
|
357
|
+
/def default_redirect_path\n\s*"\/"\n\s*end/,
|
|
358
|
+
"def default_redirect_path\n \"/welcome\"\n end"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
say_status :info, "Integrated invite check into WelcomeController", :green
|
|
304
362
|
end
|
|
305
363
|
|
|
306
364
|
def show_instructions
|
|
@@ -373,13 +431,13 @@ module Pu
|
|
|
373
431
|
end
|
|
374
432
|
|
|
375
433
|
def membership_model
|
|
376
|
-
options[:membership_model] || "#{entity_model}
|
|
434
|
+
options[:membership_model] || "#{entity_model}#{user_model}"
|
|
377
435
|
end
|
|
378
436
|
|
|
379
437
|
def membership_model_file
|
|
380
438
|
model_path = "#{membership_model.underscore}.rb"
|
|
381
439
|
if entity_in_package?
|
|
382
|
-
Rails.root.join("packages",
|
|
440
|
+
Rails.root.join("packages", entity_package, "app/models", model_path)
|
|
383
441
|
else
|
|
384
442
|
Rails.root.join("app/models", model_path)
|
|
385
443
|
end
|
|
@@ -12,7 +12,10 @@ Next steps:
|
|
|
12
12
|
# packages/<portal>/config/routes.rb
|
|
13
13
|
register_resource ::Invites::UserInvite
|
|
14
14
|
|
|
15
|
-
3. (Optional)
|
|
15
|
+
3. (Optional) Set up the welcome/onboarding flow if you haven't already:
|
|
16
|
+
rails g pu:saas:welcome --user-model=User --entity-model=Organization --portal=CustomerPortal
|
|
17
|
+
|
|
18
|
+
4. (Optional) Connect invitable models that trigger invites:
|
|
16
19
|
rails g pu:invites:invitable Tenant
|
|
17
20
|
rails g pu:invites:invitable TeamMember --role=member
|
|
18
21
|
|
|
@@ -6,7 +6,7 @@ class <%= entity_model %>::InviteUserInteraction < Plutonium::Resource::Interact
|
|
|
6
6
|
presents label: "Invite <%= user_model.underscore.humanize.titleize %>", icon: Phlex::TablerIcons::Mail
|
|
7
7
|
|
|
8
8
|
attribute :role
|
|
9
|
-
input :role, as: :select, choices: Invites::UserInvite.roles.keys
|
|
9
|
+
input :role, as: :select, choices: Invites::UserInvite.roles.keys.excluding("owner")
|
|
10
10
|
<% if membership_model != "EntityUser" || user_model != "User" -%>
|
|
11
11
|
|
|
12
12
|
private
|
data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt
CHANGED
|
@@ -6,7 +6,7 @@ class <%= user_model %>::InviteUserInteraction < Plutonium::Resource::Interactio
|
|
|
6
6
|
presents label: "Invite <%= user_model.underscore.humanize.titleize %>", icon: Phlex::TablerIcons::Mail
|
|
7
7
|
|
|
8
8
|
attribute :role
|
|
9
|
-
input :role, as: :select, choices: Invites::UserInvite.roles.keys
|
|
9
|
+
input :role, as: :select, choices: Invites::UserInvite.roles.keys.excluding("owner")
|
|
10
10
|
|
|
11
11
|
private
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ module Invites
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
16
|
def invite_class
|
|
17
|
-
Invites::UserInvite
|
|
17
|
+
::Invites::UserInvite
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def user_class
|
|
@@ -22,7 +22,11 @@ module Invites
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def after_accept_path
|
|
25
|
+
<% if rodauth? -%>
|
|
26
|
+
rodauth.login_redirect
|
|
27
|
+
<% else -%>
|
|
25
28
|
"/"
|
|
29
|
+
<% end -%>
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
def login_path
|
|
@@ -4,16 +4,17 @@ module Invites
|
|
|
4
4
|
class WelcomeController < ApplicationController
|
|
5
5
|
<% if rodauth? -%>
|
|
6
6
|
include Plutonium::Auth::Rodauth(:<%= rodauth_config %>)
|
|
7
|
+
|
|
8
|
+
before_action { rodauth.require_authentication }
|
|
9
|
+
<% else -%>
|
|
10
|
+
before_action :require_authentication
|
|
11
|
+
|
|
7
12
|
<% end -%>
|
|
8
13
|
include Plutonium::Invites::PendingInviteCheck
|
|
9
14
|
|
|
10
15
|
prepend_view_path Invites::Engine.root.join("app/views")
|
|
11
16
|
layout "invites/invitation"
|
|
12
17
|
|
|
13
|
-
<% if rodauth? -%>
|
|
14
|
-
before_action :require_authentication
|
|
15
|
-
<% end -%>
|
|
16
|
-
|
|
17
18
|
def index
|
|
18
19
|
@invite = pending_invite
|
|
19
20
|
|
|
@@ -24,10 +25,15 @@ module Invites
|
|
|
24
25
|
end
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
def skip
|
|
29
|
+
cookies.delete(:pending_invitation)
|
|
30
|
+
redirect_to after_welcome_path, allow_other_host: false
|
|
31
|
+
end
|
|
32
|
+
|
|
27
33
|
private
|
|
28
34
|
|
|
29
35
|
def invite_class
|
|
30
|
-
Invites::UserInvite
|
|
36
|
+
::Invites::UserInvite
|
|
31
37
|
end
|
|
32
38
|
|
|
33
39
|
# Returns the path to redirect to after the welcome flow completes.
|
|
@@ -40,30 +46,14 @@ module Invites
|
|
|
40
46
|
def default_redirect_path
|
|
41
47
|
"/"
|
|
42
48
|
end
|
|
49
|
+
<% unless rodauth? -%>
|
|
43
50
|
|
|
44
|
-
<% if rodauth? -%>
|
|
45
|
-
def require_authentication
|
|
46
|
-
redirect_to login_path unless current_user
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Override: the default current_user raises when not logged in,
|
|
50
|
-
# but this controller needs a nil return for the authentication check.
|
|
51
|
-
def current_user
|
|
52
|
-
rodauth.rails_account if rodauth.logged_in?
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def login_path
|
|
56
|
-
rodauth.login_path
|
|
57
|
-
end
|
|
58
|
-
<% else -%>
|
|
59
51
|
def require_authentication
|
|
60
|
-
#
|
|
61
|
-
redirect_to "/login" unless current_user
|
|
52
|
+
raise NotImplementedError, "#{self.class}#require_authentication must be implemented for your authentication system"
|
|
62
53
|
end
|
|
63
54
|
|
|
64
55
|
def current_user
|
|
65
|
-
#
|
|
66
|
-
nil
|
|
56
|
+
raise NotImplementedError, "#{self.class}#current_user must be implemented for your authentication system"
|
|
67
57
|
end
|
|
68
58
|
<% end -%>
|
|
69
59
|
end
|
|
@@ -32,8 +32,9 @@
|
|
|
32
32
|
<div class="space-y-3 pt-4">
|
|
33
33
|
<%%= form.submit "Accept Invitation",
|
|
34
34
|
class: "w-full pu-btn pu-btn-md pu-btn-primary cursor-pointer" %>
|
|
35
|
-
<%%=
|
|
36
|
-
|
|
35
|
+
<%%= button_to "Skip for Now", invites_welcome_skip_path,
|
|
36
|
+
method: :delete,
|
|
37
|
+
class: "w-full pu-btn pu-btn-md pu-btn-outline text-center" %>
|
|
37
38
|
</div>
|
|
38
39
|
<%% end %>
|
|
39
40
|
</div>
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
<%%= link_to "View Invitation", invitation_path(token: @invite.token),
|
|
19
19
|
class: "w-full block pu-btn pu-btn-md pu-btn-primary text-center" %>
|
|
20
20
|
|
|
21
|
-
<%%=
|
|
22
|
-
|
|
21
|
+
<%%= button_to "Skip for Now", invites_welcome_skip_path,
|
|
22
|
+
method: :delete,
|
|
23
|
+
class: "w-full pu-btn pu-btn-md pu-btn-outline text-center" %>
|
|
23
24
|
</div>
|
|
@@ -21,18 +21,16 @@ module PlutoniumGenerators
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def available_packages
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
packages - reserved_packages
|
|
27
|
-
end
|
|
24
|
+
packages = Dir[Rails.root.join("packages", "*")].map { |dir| File.basename(dir) }
|
|
25
|
+
packages - reserved_packages
|
|
28
26
|
end
|
|
29
27
|
|
|
30
28
|
def available_portals
|
|
31
|
-
|
|
29
|
+
["main_app"] + available_packages.select { |pkg| pkg.ends_with?("_app") || pkg.ends_with?("_portal") }.sort
|
|
32
30
|
end
|
|
33
31
|
|
|
34
32
|
def available_features
|
|
35
|
-
|
|
33
|
+
["main_app"] + available_packages.select { |pkg| !(pkg.ends_with?("_app") || pkg.ends_with?("_portal")) }.sort
|
|
36
34
|
end
|
|
37
35
|
|
|
38
36
|
def select_package(selected_package = nil, msg: "Select package", pkgs: nil)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlutoniumGenerators
|
|
4
|
+
module Concerns
|
|
5
|
+
# Shared logic for updating Rodauth redirect configuration.
|
|
6
|
+
# Used by generators that need to point login/create_account redirects to /welcome.
|
|
7
|
+
module RodauthRedirects
|
|
8
|
+
# Updates login_redirect and create_account_redirect in a Rodauth plugin file
|
|
9
|
+
# to point to the given path (typically "/welcome").
|
|
10
|
+
#
|
|
11
|
+
# @param rodauth_file [String] relative path to the rodauth plugin file
|
|
12
|
+
# @param redirect_path [String] the path to redirect to (default: "/welcome")
|
|
13
|
+
def update_rodauth_redirects(rodauth_file, redirect_path: "/welcome")
|
|
14
|
+
unless File.exist?(Rails.root.join(rodauth_file))
|
|
15
|
+
say_status :skip, "Rodauth plugin not found: #{rodauth_file}", :yellow
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
file_content = File.read(Rails.root.join(rodauth_file))
|
|
20
|
+
|
|
21
|
+
# Update login_redirect
|
|
22
|
+
if file_content.match?(/login_redirect\s+["']\/["']/)
|
|
23
|
+
gsub_file rodauth_file,
|
|
24
|
+
/login_redirect\s+["']\/["']/,
|
|
25
|
+
"login_redirect \"#{redirect_path}\""
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Update or add create_account_redirect
|
|
29
|
+
if file_content.include?("create_account_redirect")
|
|
30
|
+
gsub_file rodauth_file,
|
|
31
|
+
/create_account_redirect\s+["']\/["']/,
|
|
32
|
+
"create_account_redirect \"#{redirect_path}\""
|
|
33
|
+
elsif file_content.include?("login_redirect")
|
|
34
|
+
inject_into_file rodauth_file,
|
|
35
|
+
"\n create_account_redirect \"#{redirect_path}\"\n",
|
|
36
|
+
after: /login_redirect.*\n/
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -52,7 +52,11 @@ module PlutoniumGenerators
|
|
|
52
52
|
# ####################
|
|
53
53
|
|
|
54
54
|
def prompt
|
|
55
|
-
@prompt ||=
|
|
55
|
+
@prompt ||= if options[:interactive] == false || Rails.env.test?
|
|
56
|
+
NonInteractivePrompt.new
|
|
57
|
+
else
|
|
58
|
+
TTY::Prompt.new
|
|
59
|
+
end
|
|
56
60
|
end
|
|
57
61
|
|
|
58
62
|
# def appname
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlutoniumGenerators
|
|
4
|
+
# A drop-in replacement for TTY::Prompt that raises on any interactive method.
|
|
5
|
+
# Used when --no-interactive is passed or when there's no TTY (e.g., tests, CI).
|
|
6
|
+
class NonInteractivePrompt
|
|
7
|
+
def select(question, choices = nil, **)
|
|
8
|
+
raise Thor::Error, "Interactive prompt not available: #{question}. Provide the required option explicitly."
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def ask(question, **)
|
|
12
|
+
raise Thor::Error, "Interactive prompt not available: #{question}. Provide the required option explicitly."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def yes?(question, **)
|
|
16
|
+
raise Thor::Error, "Interactive prompt not available: #{question}. Provide the required option explicitly."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def no?(question, **)
|
|
20
|
+
raise Thor::Error, "Interactive prompt not available: #{question}. Provide the required option explicitly."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def multi_select(question, **)
|
|
24
|
+
raise Thor::Error, "Interactive prompt not available: #{question}. Provide the required option explicitly."
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -16,6 +16,7 @@ module Pu
|
|
|
16
16
|
def start
|
|
17
17
|
generate_entity_resource
|
|
18
18
|
add_unique_index_to_migration
|
|
19
|
+
add_dynamic_path_parameter
|
|
19
20
|
rescue => e
|
|
20
21
|
exception "#{self.class} failed:", e
|
|
21
22
|
end
|
|
@@ -43,6 +44,21 @@ module Pu
|
|
|
43
44
|
before: /^ end\s*$/
|
|
44
45
|
end
|
|
45
46
|
|
|
47
|
+
def add_dynamic_path_parameter
|
|
48
|
+
dest = selected_destination_feature
|
|
49
|
+
model_path = if dest == "main_app"
|
|
50
|
+
"app/models/#{normalized_name}.rb"
|
|
51
|
+
else
|
|
52
|
+
"packages/#{dest}/app/models/#{normalized_name}.rb"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
return unless File.exist?(Rails.root.join(model_path))
|
|
56
|
+
|
|
57
|
+
inject_into_file model_path,
|
|
58
|
+
" dynamic_path_parameter :name\n",
|
|
59
|
+
before: /^\s*# add model configurations above\./
|
|
60
|
+
end
|
|
61
|
+
|
|
46
62
|
def entity_attributes
|
|
47
63
|
["name:string", *Array(options[:extra_attributes])]
|
|
48
64
|
end
|
|
@@ -16,8 +16,8 @@ module Pu
|
|
|
16
16
|
class_option :entity, type: :string, required: true,
|
|
17
17
|
desc: "The entity model name (e.g., Organization)"
|
|
18
18
|
|
|
19
|
-
class_option :roles, type: :array, default: %w[member
|
|
20
|
-
desc: "
|
|
19
|
+
class_option :roles, type: :array, default: %w[admin member],
|
|
20
|
+
desc: "Additional roles for memberships (owner is always included as the first role)"
|
|
21
21
|
|
|
22
22
|
class_option :extra_attributes, type: :array, default: [],
|
|
23
23
|
desc: "Additional attributes for the membership model"
|
|
@@ -211,7 +211,8 @@ module Pu
|
|
|
211
211
|
end
|
|
212
212
|
|
|
213
213
|
def roles
|
|
214
|
-
Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
|
|
214
|
+
additional = Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
|
|
215
|
+
["owner", *additional.excluding("owner")]
|
|
215
216
|
end
|
|
216
217
|
|
|
217
218
|
def roles_enum
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Generates a SaaS portal with entity scoping, entity self-management,
|
|
3
|
+
and navigation helpers.
|
|
4
|
+
|
|
5
|
+
Wraps pu:pkg:portal and adds:
|
|
6
|
+
- Entity registered as a singular resource
|
|
7
|
+
- Portal-scoped EntityPolicy (update=owner, no destroy)
|
|
8
|
+
- entity_url and user_entities helpers in portal concerns
|
|
9
|
+
- Entity controller for the portal
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
rails g pu:saas:portal --entity-model=Organization --portal-name=Dashboard
|
|
13
|
+
|
|
14
|
+
This generates a DashboardPortal scoped to Organization, with
|
|
15
|
+
entity management and navigation helpers built in.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require_relative "../lib/plutonium_generators"
|
|
5
|
+
|
|
6
|
+
module Pu
|
|
7
|
+
module Saas
|
|
8
|
+
class PortalGenerator < ::Rails::Generators::Base
|
|
9
|
+
include PlutoniumGenerators::Generator
|
|
10
|
+
|
|
11
|
+
desc "Generate a SaaS portal with entity scoping, entity management, and navigation helpers"
|
|
12
|
+
|
|
13
|
+
class_option :entity_model, type: :string, required: true,
|
|
14
|
+
desc: "The entity model name (e.g., Organization)"
|
|
15
|
+
|
|
16
|
+
class_option :user_model, type: :string, default: "User",
|
|
17
|
+
desc: "The user model name"
|
|
18
|
+
|
|
19
|
+
class_option :portal_name, type: :string, default: "Dashboard",
|
|
20
|
+
desc: "Portal name (e.g., Dashboard generates DashboardPortal)"
|
|
21
|
+
|
|
22
|
+
class_option :rodauth, type: :string, default: "user",
|
|
23
|
+
desc: "Rodauth configuration name"
|
|
24
|
+
|
|
25
|
+
def start
|
|
26
|
+
create_portal
|
|
27
|
+
connect_entity_to_portal
|
|
28
|
+
customize_entity_policy
|
|
29
|
+
add_entity_url_helper
|
|
30
|
+
rescue => e
|
|
31
|
+
exception "#{self.class} failed:", e
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def create_portal
|
|
37
|
+
generate "pu:pkg:portal", "#{options[:portal_name]} --auth=#{rodauth_config} --scope=#{entity_model}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def connect_entity_to_portal
|
|
41
|
+
# Shell out so the subprocess can load the newly created entity model
|
|
42
|
+
generate "pu:res:conn", "#{entity_model} --dest=#{portal_package} --singular --policy"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def customize_entity_policy
|
|
46
|
+
content = <<-RUBY
|
|
47
|
+
|
|
48
|
+
def update?
|
|
49
|
+
current_membership&.owner?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def destroy?
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def permitted_attributes_for_read
|
|
57
|
+
[:name]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def permitted_attributes_for_update
|
|
61
|
+
[:name]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def permitted_associations
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
RUBY
|
|
68
|
+
inject_into_file entity_policy_path, content, after: /include #{portal_engine}::ResourcePolicy\n/
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def add_entity_url_helper
|
|
72
|
+
content = <<-RUBY
|
|
73
|
+
|
|
74
|
+
included do
|
|
75
|
+
helper_method :entity_url, :user_entities
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Returns the URL to the current entity's show page.
|
|
81
|
+
def entity_url
|
|
82
|
+
resource_url_for(current_scoped_entity)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Returns all entities the current user belongs to (for the entity switcher).
|
|
86
|
+
def user_entities
|
|
87
|
+
@user_entities ||= current_user.#{entity_table.pluralize}
|
|
88
|
+
end
|
|
89
|
+
RUBY
|
|
90
|
+
inject_into_file concerns_controller_path, content, after: /# add concerns above\.\n/
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def entity_model
|
|
94
|
+
options[:entity_model].camelize
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def entity_table
|
|
98
|
+
options[:entity_model].underscore
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def rodauth_config
|
|
102
|
+
options[:rodauth]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def portal_engine
|
|
106
|
+
"#{options[:portal_name].camelize}Portal"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def portal_package
|
|
110
|
+
portal_engine.underscore
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def concerns_controller_path
|
|
114
|
+
"packages/#{portal_package}/app/controllers/#{portal_package}/concerns/controller.rb"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def entity_policy_path
|
|
118
|
+
"packages/#{portal_package}/app/policies/#{portal_package}/#{entity_table}_policy.rb"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|