plutonium 0.39.1 → 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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-connect-resource/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-controller/SKILL.md +5 -9
  4. data/.claude/skills/plutonium-definition-query/SKILL.md +10 -2
  5. data/.claude/skills/plutonium-installation/SKILL.md +9 -7
  6. data/.claude/skills/plutonium-invites/SKILL.md +363 -0
  7. data/.claude/skills/plutonium-package/SKILL.md +2 -1
  8. data/.claude/skills/plutonium-portal/SKILL.md +30 -16
  9. data/.claude/skills/plutonium-rodauth/SKILL.md +111 -18
  10. data/CHANGELOG.md +48 -0
  11. data/app/assets/plutonium.css +1 -1
  12. data/config/initializers/sqlite_alias.rb +8 -8
  13. data/docs/.vitepress/config.ts +1 -0
  14. data/docs/getting-started/tutorial/07-author-portal.md +1 -0
  15. data/docs/getting-started/tutorial/08-customizing-ui.md +5 -2
  16. data/docs/guides/adding-resources.md +10 -0
  17. data/docs/guides/authentication.md +15 -8
  18. data/docs/guides/creating-packages.md +13 -8
  19. data/docs/guides/index.md +2 -0
  20. data/docs/guides/search-filtering.md +8 -3
  21. data/docs/guides/user-invites.md +497 -0
  22. data/docs/public/templates/base.rb +5 -1
  23. data/docs/public/templates/lite.rb +42 -0
  24. data/docs/public/templates/pluton8.rb +7 -2
  25. data/docs/reference/controller/index.md +12 -7
  26. data/docs/reference/definition/query.md +12 -3
  27. data/docs/reference/generators/index.md +70 -10
  28. data/docs/reference/portal/index.md +22 -11
  29. data/gemfiles/rails_7.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  31. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  32. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +31 -0
  33. data/lib/generators/pu/gem/active_shrine/templates/config/initializers/shrine.rb.tt +58 -0
  34. data/lib/generators/pu/gem/annotated/templates/lib/tasks/auto_annotate_models.rake +6 -1
  35. data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -0
  36. data/lib/generators/pu/invites/USAGE +27 -0
  37. data/lib/generators/pu/invites/install_generator.rb +364 -0
  38. data/lib/generators/pu/invites/invitable/USAGE +31 -0
  39. data/lib/generators/pu/invites/invitable_generator.rb +143 -0
  40. data/lib/generators/pu/invites/templates/INSTRUCTIONS +22 -0
  41. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +24 -0
  42. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +26 -0
  43. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +47 -0
  44. data/lib/generators/pu/invites/templates/invitable/invitation.html.erb.tt +45 -0
  45. data/lib/generators/pu/invites/templates/invitable/invitation.text.erb.tt +15 -0
  46. data/lib/generators/pu/invites/templates/invitable/invite_user_interaction.rb.tt +33 -0
  47. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +77 -0
  48. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +68 -0
  49. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +23 -0
  50. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/cancel_invite_interaction.rb.tt +7 -0
  51. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/resend_invite_interaction.rb.tt +7 -0
  52. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +34 -0
  53. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +41 -0
  54. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +33 -0
  55. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/error.html.erb.tt +24 -0
  56. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +40 -0
  57. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +39 -0
  58. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +49 -0
  59. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +45 -0
  60. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +15 -0
  61. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +23 -0
  62. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +33 -0
  63. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +23 -2
  64. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +130 -0
  65. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +72 -0
  66. data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -2
  67. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +7 -1
  68. data/lib/generators/pu/lite/litestream/litestream_generator.rb +105 -0
  69. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +88 -0
  70. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +14 -0
  71. data/lib/generators/pu/lite/setup/setup_generator.rb +54 -0
  72. data/lib/generators/pu/lite/solid_cable/solid_cable_generator.rb +65 -0
  73. data/lib/generators/pu/lite/solid_cache/solid_cache_generator.rb +66 -0
  74. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +61 -0
  75. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +107 -0
  76. data/lib/generators/pu/pkg/portal/USAGE +8 -2
  77. data/lib/generators/pu/pkg/portal/portal_generator.rb +11 -1
  78. data/lib/generators/pu/pkg/portal/templates/app/controllers/concerns/controller.rb.tt +2 -0
  79. data/lib/generators/pu/pkg/portal/templates/app/controllers/plutonium_controller.rb.tt +1 -0
  80. data/lib/generators/pu/pkg/portal/templates/app/controllers/resource_controller.rb.tt +7 -0
  81. data/lib/generators/pu/pkg/portal/templates/lib/engine.rb.tt +3 -0
  82. data/lib/generators/pu/res/conn/USAGE +5 -0
  83. data/lib/generators/pu/res/conn/conn_generator.rb +30 -4
  84. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +6 -3
  85. data/lib/generators/pu/res/scaffold/templates/policy.rb.tt +6 -6
  86. data/lib/generators/pu/rodauth/account_generator.rb +36 -11
  87. data/lib/generators/pu/rodauth/admin_generator.rb +55 -0
  88. data/lib/generators/pu/rodauth/install_generator.rb +1 -8
  89. data/lib/generators/pu/rodauth/templates/app/interactions/invite_admin_interaction.rb.tt +25 -0
  90. data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +6 -2
  91. data/lib/generators/pu/saas/USAGE +22 -0
  92. data/lib/generators/pu/saas/entity/USAGE +19 -0
  93. data/lib/generators/pu/saas/entity_generator.rb +55 -0
  94. data/lib/generators/pu/saas/membership/USAGE +25 -0
  95. data/lib/generators/pu/saas/membership_generator.rb +165 -0
  96. data/lib/generators/pu/saas/setup/USAGE +27 -0
  97. data/lib/generators/pu/saas/setup_generator.rb +98 -0
  98. data/lib/generators/pu/saas/user/USAGE +21 -0
  99. data/lib/generators/pu/saas/user_generator.rb +66 -0
  100. data/lib/plutonium/core/controller.rb +9 -5
  101. data/lib/plutonium/definition/base.rb +3 -1
  102. data/lib/plutonium/definition/scoping.rb +20 -0
  103. data/lib/plutonium/invites/concerns/cancel_invite.rb +44 -0
  104. data/lib/plutonium/invites/concerns/invitable.rb +98 -0
  105. data/lib/plutonium/invites/concerns/invite_token.rb +186 -0
  106. data/lib/plutonium/invites/concerns/invite_user.rb +147 -0
  107. data/lib/plutonium/invites/concerns/resend_invite.rb +66 -0
  108. data/lib/plutonium/invites/controller.rb +226 -0
  109. data/lib/plutonium/invites/pending_invite_check.rb +76 -0
  110. data/lib/plutonium/invites.rb +6 -0
  111. data/lib/plutonium/resource/controllers/queryable.rb +4 -0
  112. data/lib/plutonium/resource/query_object.rb +3 -5
  113. data/lib/plutonium/version.rb +1 -1
  114. data/package.json +1 -1
  115. metadata +64 -7
  116. data/lib/generators/pu/res/entity/entity_generator.rb +0 -158
  117. data/lib/generators/pu/rodauth/customer_generator.rb +0 -101
  118. data/public/plutonium-assets/plutonium-logo-original.png +0 -0
  119. data/public/plutonium-assets/plutonium-logo-white.png +0 -0
  120. data/public/plutonium-assets/plutonium-logo.png +0 -0
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Lite
7
+ class SolidErrorsGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+ include PlutoniumGenerators::Concerns::ConfiguresSqlite
10
+ include PlutoniumGenerators::Concerns::MountsEngines
11
+
12
+ desc "Set up Solid Errors for error tracking with SQLite"
13
+
14
+ class_option :database, type: :string, default: "errors",
15
+ desc: "Database name for Solid Errors"
16
+ class_option :route, type: :string, default: "/manage/errors",
17
+ desc: "Route path for Solid Errors UI"
18
+
19
+ def start
20
+ @db_name = options[:database]
21
+
22
+ bundle "solid_errors"
23
+ add_sqlite_database(@db_name)
24
+ run_solid_errors_install
25
+ configure_application
26
+ prepare_database(@db_name)
27
+ mount_solid_errors_engine
28
+ rescue => e
29
+ exception "#{self.class} failed:", e
30
+ end
31
+
32
+ private
33
+
34
+ def run_solid_errors_install
35
+ Bundler.with_unbundled_env do
36
+ run "bin/rails generate solid_errors:install", env: {"DATABASE" => @db_name}
37
+ end
38
+ run "git checkout -- config/environments/production.rb 2>/dev/null || true"
39
+ end
40
+
41
+ def configure_application
42
+ create_file "config/initializers/solid_errors.rb", <<~RUBY
43
+ # frozen_string_literal: true
44
+
45
+ Rails.application.configure do
46
+ config.solid_errors.connects_to = {database: {writing: :#{@db_name}}}
47
+ config.solid_errors.send_emails = ENV["SOLID_ERRORS_SEND_EMAILS"].present?
48
+ config.solid_errors.email_from = ENV["SOLID_ERRORS_EMAIL_FROM"]
49
+ config.solid_errors.email_to = ENV["SOLID_ERRORS_EMAIL_TO"]
50
+ config.solid_errors.username = ENV.fetch("SOLID_ERRORS_USERNAME", nil)
51
+ config.solid_errors.password = ENV.fetch("SOLID_ERRORS_PASSWORD", nil)
52
+ end
53
+ RUBY
54
+ end
55
+
56
+ def mount_solid_errors_engine
57
+ mount_engine %(mount SolidErrors::Engine, at: "#{options[:route]}"), authenticated: true
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Lite
7
+ class SolidQueueGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+ include PlutoniumGenerators::Concerns::ConfiguresSqlite
10
+ include PlutoniumGenerators::Concerns::MountsEngines
11
+
12
+ desc "Set up Solid Queue for background jobs with SQLite"
13
+
14
+ class_option :database, type: :string, default: "queue",
15
+ desc: "Database name for Solid Queue"
16
+ class_option :route, type: :string, default: "/manage/jobs",
17
+ desc: "Route path for Mission Control Jobs UI"
18
+ class_option :skip_mission_control, type: :boolean, default: false,
19
+ desc: "Skip Mission Control Jobs UI"
20
+
21
+ def start
22
+ @db_name = options[:database]
23
+
24
+ bundle "solid_queue"
25
+ add_sqlite_database(@db_name)
26
+ run_solid_queue_install
27
+ configure_application
28
+ prepare_database(@db_name)
29
+ create_jobs_script
30
+ configure_procfile
31
+ configure_kamal
32
+ setup_mission_control unless options[:skip_mission_control]
33
+ rescue => e
34
+ exception "#{self.class} failed:", e
35
+ end
36
+
37
+ private
38
+
39
+ def run_solid_queue_install
40
+ Bundler.with_unbundled_env do
41
+ run "bin/rails generate solid_queue:install", env: {"DATABASE" => @db_name}
42
+ end
43
+ # Restore files modified by solid_queue:install
44
+ run "git checkout -- config/environments/production.rb 2>/dev/null || true"
45
+ run "git checkout -- config/puma.rb 2>/dev/null || true"
46
+ end
47
+
48
+ def configure_application
49
+ create_file "config/initializers/solid_queue.rb", <<~RUBY
50
+ # frozen_string_literal: true
51
+
52
+ Rails.application.configure do
53
+ config.active_job.queue_adapter = :solid_queue
54
+ config.solid_queue.connects_to = {database: {writing: :#{@db_name}}}
55
+ config.solid_queue.silence_polling = true
56
+ end
57
+ RUBY
58
+ end
59
+
60
+ def create_jobs_script
61
+ jobs_script = "bin/jobs"
62
+ return if File.exist?(File.expand_path(jobs_script, destination_root))
63
+
64
+ create_file jobs_script, <<~BASH
65
+ #!/usr/bin/env bash
66
+ set -e
67
+ exec bundle exec rake solid_queue:start
68
+ BASH
69
+ chmod jobs_script, 0o755
70
+ end
71
+
72
+ def configure_kamal
73
+ deploy_file = "config/deploy.yml"
74
+ return unless File.exist?(File.expand_path(deploy_file, destination_root))
75
+ return if file_includes?(deploy_file, "job:")
76
+
77
+ insert_into_file deploy_file, after: /^servers:.*\n/ do
78
+ <<~YAML
79
+ job:
80
+ hosts:
81
+ - <%= ENV['DEPLOY_HOST'] %>
82
+ cmd: bin/jobs
83
+ YAML
84
+ end
85
+ end
86
+
87
+ def configure_procfile
88
+ procfile = "Procfile.dev"
89
+ return unless File.exist?(File.expand_path(procfile, destination_root))
90
+ return if file_includes?(procfile, "jobs:")
91
+
92
+ append_to_file procfile, "jobs: bin/jobs\n"
93
+ end
94
+
95
+ def setup_mission_control
96
+ bundle "mission_control-jobs"
97
+ configure_mission_control
98
+ mount_engine %(mount MissionControl::Jobs::Engine, at: "#{options[:route]}"), authenticated: true
99
+ end
100
+
101
+ def configure_mission_control
102
+ # Disable built-in HTTP Basic Auth - using route constraints instead
103
+ environment "config.mission_control.jobs.http_basic_auth_enabled = false"
104
+ end
105
+ end
106
+ end
107
+ end
@@ -7,7 +7,8 @@ Usage:
7
7
  Options:
8
8
  --auth=NAME Rodauth account to authenticate with (e.g., --auth=user)
9
9
  --public Grant public access (no authentication required)
10
- --byo Bring your own authentication (configure manually)
10
+ --byo Bring your own authentication (configure manually)
11
+ --scope=CLASS Entity class to scope to for multi-tenancy (e.g., --scope=Organization)
11
12
 
12
13
  Without options, the generator prompts interactively for authentication choice.
13
14
 
@@ -25,6 +26,10 @@ Examples:
25
26
  # Custom authentication (implement your own)
26
27
  rails g pu:pkg:portal custom --byo
27
28
 
29
+ # With entity scoping (multi-tenancy)
30
+ rails g pu:pkg:portal admin --auth=admin --scope=Organization
31
+ rails g pu:pkg:portal customer --auth=customer --scope=Account
32
+
28
33
  Generated Structure:
29
34
  packages/[name]_portal/
30
35
  ├── app/
@@ -61,5 +66,6 @@ After Generation:
61
66
  2. Connect resources to the portal:
62
67
  rails g pu:res:conn Post --dest=admin_portal
63
68
 
64
- 3. Optionally configure entity scoping in engine.rb:
69
+ 3. If you used --scope, entity scoping is already configured in engine.rb.
70
+ Otherwise, you can manually add it:
65
71
  scope_to_entity Organization, strategy: :path
@@ -16,10 +16,12 @@ module Pu
16
16
  class_option :auth, type: :string, desc: "Rodauth account to authenticate with (e.g., --auth=user)"
17
17
  class_option :public, type: :boolean, default: false, desc: "Grant public access (no authentication)"
18
18
  class_option :byo, type: :boolean, default: false, desc: "Bring your own authentication"
19
+ class_option :scope, type: :string, desc: "Entity class to scope to (e.g., --scope=Organization)"
19
20
 
20
21
  def start
21
22
  validate_package_name name
22
23
  configure_authentication
24
+ configure_entity_scoping
23
25
 
24
26
  template "lib/engine.rb", "packages/#{package_namespace}/lib/engine.rb"
25
27
  template "config/routes.rb", "packages/#{package_namespace}/config/routes.rb"
@@ -28,6 +30,8 @@ module Pu
28
30
  "packages/#{package_namespace}/app/controllers/#{package_namespace}/concerns/controller.rb"
29
31
  template "app/controllers/plutonium_controller.rb",
30
32
  "packages/#{package_namespace}/app/controllers/#{package_namespace}/plutonium_controller.rb"
33
+ template "app/controllers/resource_controller.rb",
34
+ "packages/#{package_namespace}/app/controllers/#{package_namespace}/resource_controller.rb"
31
35
 
32
36
  template "app/controllers/dashboard_controller.rb",
33
37
  "packages/#{package_namespace}/app/controllers/#{package_namespace}/dashboard_controller.rb"
@@ -48,7 +52,7 @@ module Pu
48
52
 
49
53
  private
50
54
 
51
- attr_reader :rodauth_account
55
+ attr_reader :rodauth_account, :scoped_entity_class
52
56
 
53
57
  def configure_authentication
54
58
  if options[:auth].present?
@@ -82,6 +86,12 @@ module Pu
82
86
  def public_access? = @public_access
83
87
 
84
88
  def bring_your_own_auth? = @bring_your_own_auth
89
+
90
+ def configure_entity_scoping
91
+ @scoped_entity_class = options[:scope].camelize if options[:scope].present?
92
+ end
93
+
94
+ def scoped_to_entity? = scoped_entity_class.present?
85
95
  end
86
96
  end
87
97
  end
@@ -1,5 +1,7 @@
1
1
  module <%= package_name %>
2
2
  module Concerns
3
+ # Portal-wide controller customizations go here.
4
+ # Included by both ResourceController and PlutoniumController.
3
5
  module Controller
4
6
  extend ActiveSupport::Concern
5
7
  include Plutonium::Portal::Controller
@@ -1,4 +1,5 @@
1
1
  module <%= package_name %>
2
+ # Base controller for non-resource pages (dashboard, settings, etc.).
2
3
  class PlutoniumController < ::PlutoniumController
3
4
  include <%= package_name %>::Concerns::Controller
4
5
  end
@@ -0,0 +1,7 @@
1
+ module <%= package_name %>
2
+ # Base controller for portal resources when no feature package controller exists.
3
+ # Add customizations to Concerns::Controller, not here.
4
+ class ResourceController < ::ResourceController
5
+ include <%= package_name %>::Concerns::Controller
6
+ end
7
+ end
@@ -4,6 +4,9 @@ module <%= package_name %>
4
4
  # add concerns above.
5
5
 
6
6
  config.after_initialize do
7
+ <%- if scoped_to_entity? -%>
8
+ scope_to_entity <%= scoped_entity_class %>, strategy: :path
9
+ <%- end -%>
7
10
  # add directives above.
8
11
  end
9
12
  end
@@ -10,6 +10,7 @@ Options:
10
10
  --src=PACKAGE Source feature package (optional)
11
11
  Use main_app for resources in the main application
12
12
  Use package name for resources in a feature package
13
+ --singular Register the resource as singular (e.g., profile, dashboard)
13
14
 
14
15
  Arguments:
15
16
  RESOURCES Space-separated list of model names to connect.
@@ -26,6 +27,9 @@ Examples:
26
27
  # Connect namespaced resources (from a feature package)
27
28
  rails g pu:res:conn Blogging::Post Blogging::Comment --dest=admin_portal
28
29
 
30
+ # Connect a singular resource (e.g., profile, dashboard)
31
+ rails g pu:res:conn Profile --dest=customer_portal --singular
32
+
29
33
  # Interactive mode (prompts for everything)
30
34
  rails g pu:res:conn
31
35
 
@@ -56,6 +60,7 @@ Generated Code:
56
60
 
57
61
  Routes register the resource:
58
62
  register_resource ::Blogging::Post
63
+ register_resource ::Profile, singular: true # With --singular
59
64
 
60
65
  Workflow:
61
66
  1. Create feature package: rails g pu:pkg:package blogging
@@ -12,20 +12,25 @@ module Pu
12
12
 
13
13
  desc(
14
14
  "Create a connection between a resource and a portal\n\n" \
15
- "e.g. rails g pu:res:conn todo --dest=dashboard_portal"
15
+ "e.g. rails g pu:res:conn todo --dest=dashboard_portal\n" \
16
+ " rails g pu:res:conn profile --dest=customer_portal --singular"
16
17
  )
17
18
 
18
- # argument :name
19
+ class_option :singular, type: :boolean, default: false,
20
+ desc: "Register the resource as a singular resource (e.g., profile)"
19
21
 
20
22
  def start
21
23
  selected_resources = resources_selection
22
24
  @app_namespace = portal_option(:dest, prompt: "Select destination portal").camelize
23
25
 
26
+ validate_resources!(selected_resources)
27
+
24
28
  selected_resources.each do |resource|
25
29
  @resource_class = resource
30
+
26
31
  if app_namespace == "MainApp"
27
32
  insert_into_file "config/routes.rb",
28
- indent("register_resource ::#{resource}\n", 2),
33
+ indent("register_resource ::#{resource}#{singular_option}\n", 2),
29
34
  after: /.*Rails\.application\.routes\.draw do.*\n/
30
35
  else
31
36
  unless expected_parent_policy
@@ -42,7 +47,7 @@ module Pu
42
47
  "packages/#{package_namespace}/app/controllers/#{package_namespace}/#{resource.pluralize.underscore}_controller.rb"
43
48
 
44
49
  insert_into_file "packages/#{package_namespace}/config/routes.rb",
45
- indent("register_resource ::#{resource}\n", 2),
50
+ indent("register_resource ::#{resource}#{singular_option}\n", 2),
46
51
  before: /.*# register resources above.*/
47
52
  end
48
53
  end
@@ -58,6 +63,10 @@ module Pu
58
63
  app_namespace.underscore
59
64
  end
60
65
 
66
+ def singular_option
67
+ options[:singular] ? ", singular: true" : ""
68
+ end
69
+
61
70
  def resource_namespace
62
71
  app_namespace.underscore
63
72
  end
@@ -110,6 +119,23 @@ module Pu
110
119
  def policy_attributes_for_read
111
120
  default_policy_attributes
112
121
  end
122
+
123
+ def validate_resources!(resources)
124
+ invalid = resources.reject { |r| resource_record?(r) }
125
+ return if invalid.empty?
126
+
127
+ invalid.each do |resource|
128
+ say_status :error, "#{resource} does not include Plutonium::Resource::Record", :red
129
+ end
130
+ error "All resources must include Plutonium::Resource::Record to be connected to a portal"
131
+ end
132
+
133
+ def resource_record?(resource)
134
+ klass = resource.safe_constantize
135
+ return false unless klass
136
+
137
+ klass.included_modules.any? { |mod| mod.to_s.include?("Plutonium::Resource::Record") }
138
+ end
113
139
  end
114
140
  end
115
141
  end
@@ -14,15 +14,18 @@ module Pu
14
14
 
15
15
  class_option :model, type: :boolean, default: true
16
16
 
17
- def setup
18
- return unless options[:model]
17
+ # Skip collision check when scaffolding for existing model
18
+ def check_class_collision
19
+ super if options[:model]
20
+ end
19
21
 
22
+ def setup
20
23
  model_class = class_name.safe_constantize
21
24
  if model_class.present?
22
25
  if attributes.empty?
23
26
  attributes_str = model_class.content_columns.map { |col| "#{col.name}:#{col.type}" }
24
27
  self.attributes = parse_attributes_internal!(attributes_str)
25
- else
28
+ elsif options[:model]
26
29
  warn("Overwriting existing resource. You can leave out the attributes to import an existing resource.")
27
30
  end
28
31
  end
@@ -2,13 +2,13 @@
2
2
  class <%= class_name %>Policy < <%= [feature_package_name, "ResourcePolicy"].join "::" %>
3
3
  # Core actions
4
4
 
5
- def create?
6
- true
7
- end
5
+ # def create?
6
+ # true
7
+ # end
8
8
 
9
- def read?
10
- true
11
- end
9
+ # def read?
10
+ # true
11
+ # end
12
12
 
13
13
  # Core attributes
14
14
 
@@ -18,6 +18,9 @@ module Pu
18
18
  desc "Generate a rodauth-rails account.\n\n" \
19
19
  "Configures a basic set of features as well as migrations, a model, mailer and views."
20
20
 
21
+ class_option :extra_attributes, type: :array, default: [],
22
+ desc: "Additional attributes to add to the account model (e.g., role:integer)"
23
+
21
24
  def install_dependencies
22
25
  Bundler.with_unbundled_env do
23
26
  run "bundle add jwt" if jwt? || jwt_refresh?
@@ -51,20 +54,27 @@ module Pu
51
54
 
52
55
  def configure_rodauth_plugin_load_memory
53
56
  in_root do
54
- plugin_config = indent(
55
- "rodauth#{"(:#{table_prefix})" unless primary?}.load_memory # autologin remembered #{table}\n", 4
56
- )
57
- gsub_file "app/rodauth/rodauth_app.rb", /.*# rodauth\.load_memory.*\n/, ""
57
+ rodauth_app = File.read("app/rodauth/rodauth_app.rb")
58
+ load_memory_pattern = primary? ? /rodauth\.load_memory/ : /rodauth\(:#{table_prefix}\)\.load_memory/
59
+
60
+ return if rodauth_app.match?(load_memory_pattern)
61
+
62
+ plugin_config = if primary?
63
+ indent("rodauth.load_memory # autologin remembered #{table}\n", 4)
64
+ else
65
+ indent(<<~RUBY, 4)
66
+ if r.path.start_with?("/#{table_prefix}_dashboard")
67
+ rodauth(:#{table_prefix}).load_memory # autologin remembered #{table}
68
+ end
69
+ RUBY
70
+ end
58
71
 
59
72
  if remember?
60
73
  insert_into_file "app/rodauth/rodauth_app.rb", plugin_config, after: "# plugin route configuration\n"
61
74
  else
62
- gsub_file "app/rodauth/rodauth_app.rb", plugin_config, ""
63
- in_root do
64
- unless File.read("app/rodauth/rodauth_app.rb").match?(/.*\.load_memory # autologin/)
65
- insert_into_file "app/rodauth/rodauth_app.rb", indent("# rodauth.load_memory # autologin remembered users\n", 4),
66
- after: "# plugin route configuration\n"
67
- end
75
+ unless rodauth_app.match?(/\.load_memory # autologin/)
76
+ insert_into_file "app/rodauth/rodauth_app.rb", indent("# rodauth.load_memory # autologin remembered users\n", 4),
77
+ after: "# plugin route configuration\n"
68
78
  end
69
79
  end
70
80
  end
@@ -83,13 +93,28 @@ module Pu
83
93
  migration_name: options[:migration_name],
84
94
  force: options[:force],
85
95
  skip: options[:skip]
96
+
97
+ add_extra_columns_to_migration
98
+ end
99
+
100
+ def add_extra_columns_to_migration
101
+ return if options[:extra_attributes].blank?
102
+
103
+ migration_file = Dir[File.join(destination_root, "db/migrate/*_create_rodauth_#{table_prefix}_*.rb")].first
104
+ return unless migration_file
105
+
106
+ attributes = options[:extra_attributes].map { |attr| PlutoniumGenerators::ModelGeneratorBase::GeneratedAttribute.parse(table, attr) }
107
+ columns = attributes.map { |a| " t.#{a.type} :#{a.name}#{a.inject_options}" }.join("\n")
108
+
109
+ inject_into_file migration_file, "#{columns}\n", after: /t\.string :password_hash\n/
86
110
  end
87
111
 
88
112
  def create_account_model
89
113
  return unless base?
90
114
 
91
115
  template "app/models/account.rb", "app/models/#{account_path}.rb"
92
- invoke "pu:res:scaffold", [table, "email:string", "status:integer"], dest: "main_app",
116
+ scaffold_attrs = ["email:string", "status:integer"] + Array(options[:extra_attributes])
117
+ invoke "pu:res:scaffold", [table, *scaffold_attrs], dest: "main_app",
93
118
  model: false,
94
119
  force: true,
95
120
  skip: options[:skip]
@@ -15,6 +15,12 @@ module Pu
15
15
 
16
16
  argument :name
17
17
 
18
+ class_option :roles, type: :array, default: %w[super_admin admin],
19
+ desc: "Available roles for admin accounts"
20
+
21
+ class_option :extra_attributes, type: :array, default: [],
22
+ desc: "Additional attributes to add to the account model (e.g., name:string)"
23
+
18
24
  def start
19
25
  generate_admin_account
20
26
  configure_admin_account
@@ -28,11 +34,13 @@ module Pu
28
34
  invoke "pu:rodauth:account", [name],
29
35
  defaults: false,
30
36
  **admin_features,
37
+ extra_attributes: options[:extra_attributes],
31
38
  force: options[:force],
32
39
  skip: options[:skip]
33
40
  end
34
41
 
35
42
  def configure_admin_account
43
+ add_role_column_to_migration
36
44
  # Prevent account creation from web
37
45
  insert_into_file "app/rodauth/#{normalized_name}_rodauth_plugin.rb", indent(<<~EOT, 4), after: /# ==> Hooks\n/
38
46
 
@@ -66,6 +74,37 @@ module Pu
66
74
  template "app/views/_login_form_footer.html.erb.tt", "app/views/rodauth/#{normalized_name}/_login_form_footer.html.erb"
67
75
 
68
76
  template "lib/tasks/rodauth_admin.rake", "lib/tasks/rodauth_#{normalized_name}.rake"
77
+
78
+ add_role_enum
79
+ create_invite_interaction
80
+ end
81
+
82
+ def add_role_column_to_migration
83
+ migration_file = Dir[File.join(destination_root, "db/migrate/*_create_rodauth_#{normalized_name}_*.rb")].first
84
+ return unless migration_file
85
+
86
+ inject_into_file migration_file,
87
+ " t.integer :role, null: false, default: 0\n",
88
+ after: /t\.string :password_hash\n/
89
+ end
90
+
91
+ def add_role_enum
92
+ inject_into_file "app/models/#{normalized_name}.rb",
93
+ "enum :role, #{roles_enum}\n ",
94
+ before: "# add enums above.\n"
95
+ end
96
+
97
+ def create_invite_interaction
98
+ template "app/interactions/invite_admin_interaction.rb",
99
+ "app/interactions/#{normalized_name}/invite_interaction.rb"
100
+
101
+ inject_into_file "app/definitions/#{normalized_name}_definition.rb",
102
+ " action :invite, interaction: #{name.classify}::InviteInteraction, collection: true, category: :primary\n",
103
+ after: /class #{name.classify}Definition < .+\n/
104
+
105
+ inject_into_file "app/policies/#{normalized_name}_policy.rb",
106
+ "def invite?\n true\n end\n\n ",
107
+ before: "# Core attributes"
69
108
  end
70
109
 
71
110
  def admin_features
@@ -83,6 +122,22 @@ module Pu
83
122
  def display_name = name.humanize.downcase
84
123
 
85
124
  def normalized_name = name.underscore
125
+
126
+ def roles
127
+ Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
128
+ end
129
+
130
+ def roles_enum
131
+ roles.each_with_index.map { |r, i| "#{r}: #{i}" }.join(", ")
132
+ end
133
+
134
+ def default_role
135
+ [1, roles.size - 1].min
136
+ end
137
+
138
+ def default_role_name
139
+ roles[default_role]
140
+ end
86
141
  end
87
142
  end
88
143
  end
@@ -1,7 +1,6 @@
1
1
  require "rails/generators/base"
2
2
  require "rails/generators/active_record/migration"
3
3
  require "securerandom"
4
- require "plutonium/auth/sequel_adapter"
5
4
 
6
5
  module Pu
7
6
  module Rodauth
@@ -54,13 +53,7 @@ module Pu
54
53
 
55
54
  private
56
55
 
57
- # Delegates to the SequelAdapter module to avoid code duplication.
58
- def sequel_adapter
59
- Plutonium::Auth::SequelAdapter.sequel_adapter
60
- end
61
-
62
- # Delegates to the SequelAdapter module's internal ActiveRecord adapter detection.
63
- # We still provide this method for use in create_install_migration.
56
+ # Detects the ActiveRecord adapter for migration generation.
64
57
  def activerecord_adapter
65
58
  if ActiveRecord::Base.respond_to?(:connection_db_config)
66
59
  ActiveRecord::Base.connection_db_config&.adapter
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= name.classify %>::InviteInteraction < Plutonium::Resource::Interaction
4
+ presents label: "Invite <%= name.titleize %>", icon: Phlex::TablerIcons::Mail
5
+
6
+ attribute :email
7
+ attribute :role, default: :<%= default_role_name %>
8
+
9
+ validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
10
+ validates :role, presence: true, inclusion: {in: <%= name.classify %>.roles.keys}
11
+
12
+ input :role, as: :select, choices: <%= name.classify %>.roles.keys
13
+
14
+ def execute
15
+ account = nil
16
+ <%= name.classify %>.transaction do
17
+ RodauthApp.rodauth(:<%= normalized_name %>).create_account(login: email)
18
+ account = <%= name.classify %>.find_by!(email: email)
19
+ account.update!(role: role)
20
+ end
21
+ succeed(account).with_message("Invitation sent to #{email}")
22
+ rescue ::Rodauth::InternalRequestError => e
23
+ failed(email: e.message)
24
+ end
25
+ end
@@ -2,6 +2,11 @@ class <%= account_path.classify %> < ResourceRecord
2
2
  include Rodauth::Rails.model<%= "(:#{table_prefix})" unless primary? %>
3
3
  # add concerns above.
4
4
 
5
+ # add constants above.
6
+
7
+ enum :status, unverified: 1, verified: 2, closed: 3
8
+ # add enums above.
9
+
5
10
  <%- if account_path.include?("/") -%>
6
11
  self.table_name = :<%= table_prefix.pluralize %>
7
12
  <%- end -%>
@@ -24,8 +29,7 @@ class <%= account_path.classify %> < ResourceRecord
24
29
 
25
30
  # add delegations above.
26
31
 
27
- enum :status, unverified: 1, verified: 2, closed: 3
28
32
  # add misc attribute macros above.
29
33
 
30
- # add methods above.
34
+ # add methods above. add private methods below.
31
35
  end