plutonium 0.42.0 → 0.43.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-controller/SKILL.md +38 -1
  3. data/.claude/skills/plutonium-definition/SKILL.md +14 -0
  4. data/.claude/skills/plutonium-forms/SKILL.md +16 -1
  5. data/.claude/skills/plutonium-profile/SKILL.md +276 -0
  6. data/.claude/skills/plutonium-views/SKILL.md +23 -1
  7. data/CHANGELOG.md +36 -0
  8. data/app/assets/plutonium.css +1 -1
  9. data/app/views/plutonium/_resource_header.html.erb +6 -27
  10. data/app/views/plutonium/_resource_sidebar.html.erb +1 -2
  11. data/app/views/resource/_resource_details.rabl +3 -2
  12. data/app/views/resource/index.rabl +3 -2
  13. data/app/views/resource/show.rabl +3 -2
  14. data/docs/guides/user-profile.md +322 -0
  15. data/docs/reference/controller/index.md +38 -1
  16. data/docs/reference/definition/index.md +16 -0
  17. data/docs/reference/views/forms.md +15 -0
  18. data/docs/reference/views/index.md +23 -1
  19. data/gemfiles/rails_7.gemfile.lock +1 -1
  20. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  21. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  22. data/lib/generators/pu/core/assets/assets_generator.rb +12 -0
  23. data/lib/generators/pu/core/install/templates/app/controllers/resource_controller.rb.tt +11 -0
  24. data/lib/generators/pu/core/typespec/templates/common.tsp.tt +95 -0
  25. data/lib/generators/pu/core/typespec/templates/main.tsp.tt +27 -0
  26. data/lib/generators/pu/core/typespec/templates/main_multi.tsp.tt +25 -0
  27. data/lib/generators/pu/core/typespec/templates/model.tsp.tt +226 -0
  28. data/lib/generators/pu/core/typespec/typespec_generator.rb +342 -0
  29. data/lib/generators/pu/invites/USAGE +0 -1
  30. data/lib/generators/pu/invites/install_generator.rb +62 -15
  31. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +2 -2
  32. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +2 -0
  33. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +1 -0
  34. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +5 -5
  35. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +4 -4
  36. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +1 -1
  37. data/lib/generators/pu/lib/plutonium_generators/generator.rb +29 -0
  38. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +6 -23
  39. data/lib/generators/pu/pkg/portal/portal_generator.rb +5 -1
  40. data/lib/generators/pu/profile/USAGE +59 -0
  41. data/lib/generators/pu/profile/concerns/profile_arguments.rb +27 -0
  42. data/lib/generators/pu/profile/conn/USAGE +33 -0
  43. data/lib/generators/pu/profile/conn_generator.rb +167 -0
  44. data/lib/generators/pu/profile/install_generator.rb +119 -0
  45. data/lib/generators/pu/profile/setup/USAGE +42 -0
  46. data/lib/generators/pu/profile/setup_generator.rb +73 -0
  47. data/lib/generators/pu/rodauth/account_generator.rb +2 -4
  48. data/lib/generators/pu/rodauth/install_generator.rb +2 -2
  49. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +3 -0
  50. data/lib/generators/pu/saas/api_client_generator.rb +0 -2
  51. data/lib/generators/pu/saas/membership_generator.rb +68 -19
  52. data/lib/generators/pu/saas/setup_generator.rb +7 -2
  53. data/lib/generators/pu/saas/user_generator.rb +0 -2
  54. data/lib/plutonium/auth/rodauth.rb +8 -0
  55. data/lib/plutonium/core/controller.rb +7 -4
  56. data/lib/plutonium/core/controllers/authorizable.rb +5 -1
  57. data/lib/plutonium/definition/base.rb +7 -0
  58. data/lib/plutonium/helpers/display_helper.rb +6 -0
  59. data/lib/plutonium/profile/security_section.rb +118 -0
  60. data/lib/plutonium/resource/controller.rb +17 -7
  61. data/lib/plutonium/resource/controllers/interactive_actions.rb +11 -25
  62. data/lib/plutonium/resource/controllers/presentable.rb +46 -3
  63. data/lib/plutonium/resource/record/associated_with.rb +7 -1
  64. data/lib/plutonium/routing/mapper_extensions.rb +18 -18
  65. data/lib/plutonium/routing/route_set_extensions.rb +23 -2
  66. data/lib/plutonium/ui/breadcrumbs.rb +111 -131
  67. data/lib/plutonium/ui/dyna_frame/content.rb +12 -2
  68. data/lib/plutonium/ui/form/resource.rb +26 -19
  69. data/lib/plutonium/ui/page/base.rb +14 -14
  70. data/lib/plutonium/ui/table/components/selection_column.rb +6 -2
  71. data/lib/plutonium/ui/table/resource.rb +3 -2
  72. data/lib/plutonium/version.rb +1 -1
  73. data/package.json +1 -1
  74. metadata +17 -3
  75. data/lib/generators/pu/rodauth/concerns/gem_helpers.rb +0 -19
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require_relative "../lib/plutonium_generators"
5
+ require_relative "concerns/profile_arguments"
6
+
7
+ module Pu
8
+ module Profile
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ include PlutoniumGenerators::Generator
11
+ include Concerns::ProfileArguments
12
+
13
+ desc "Generate a Profile resource for managing Rodauth account settings"
14
+
15
+ class_option :user_model, type: :string, default: "User",
16
+ desc: "The Rodauth user model"
17
+
18
+ class_option :dest, type: :string,
19
+ desc: "Package where the Profile resource should be created"
20
+
21
+ def start
22
+ normalize_arguments
23
+ generate_profile_scaffold
24
+ add_user_association
25
+ add_unique_index_to_migration
26
+ rescue => e
27
+ exception "#{self.class} failed:", e
28
+ end
29
+
30
+ private
31
+
32
+ def generate_profile_scaffold
33
+ invoke "pu:res:scaffold", [@profile_name, *scaffold_attributes],
34
+ dest: selected_destination_feature,
35
+ force: options[:force],
36
+ skip: options[:skip]
37
+ end
38
+
39
+ def add_user_association
40
+ association = if dest_package?
41
+ " has_one :#{file_name}, class_name: \"#{namespaced_class_name}\", dependent: :destroy\n"
42
+ else
43
+ " has_one :#{file_name}, dependent: :destroy\n"
44
+ end
45
+ inject_into_file user_model_path, association,
46
+ before: /^\s*# add has_one associations above\.\n/
47
+ end
48
+
49
+ def add_unique_index_to_migration
50
+ migration_file = Dir[File.join(migration_dir, "*_create_#{table_name}.rb")].first
51
+ unless migration_file
52
+ say_status :warning, "Migration file not found in #{migration_dir}, skipping unique index", :yellow
53
+ return
54
+ end
55
+
56
+ # Add unique: true to the user reference for has_one relationship
57
+ gsub_file migration_file,
58
+ /t\.belongs_to :#{user_table}, null: false, foreign_key: true/,
59
+ "t.belongs_to :#{user_table}, null: false, foreign_key: true, index: {unique: true}"
60
+ end
61
+
62
+ def class_name
63
+ @profile_name.camelize
64
+ end
65
+
66
+ def namespaced_class_name
67
+ if dest_package?
68
+ "#{dest_name.camelize}::#{class_name}"
69
+ else
70
+ class_name
71
+ end
72
+ end
73
+
74
+ def file_name
75
+ @profile_name.underscore
76
+ end
77
+
78
+ def user_table
79
+ options[:user_model].underscore
80
+ end
81
+
82
+ def user_model_path
83
+ "app/models/#{user_table}.rb"
84
+ end
85
+
86
+ def dest_package?
87
+ selected_destination_feature != "main_app"
88
+ end
89
+
90
+ def dest_name
91
+ selected_destination_feature
92
+ end
93
+
94
+ def migration_dir
95
+ if dest_package?
96
+ "packages/#{dest_name}/db/migrate"
97
+ else
98
+ "db/migrate"
99
+ end
100
+ end
101
+
102
+ def table_name
103
+ if dest_package?
104
+ "#{dest_name}_#{file_name.pluralize}"
105
+ else
106
+ file_name.pluralize
107
+ end
108
+ end
109
+
110
+ def scaffold_attributes
111
+ ["#{user_table}:belongs_to", *@profile_attributes.map(&:to_s)]
112
+ end
113
+
114
+ def selected_destination_feature
115
+ feature_option :dest, prompt: "Select destination feature"
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,42 @@
1
+ Description:
2
+ Generate a complete Profile setup: creates the resource and connects it to a portal.
3
+ This combines pu:profile:install and pu:profile:conn into a single command.
4
+
5
+ Usage:
6
+ rails g pu:profile:setup [NAME] [field:type ...] --dest=PACKAGE --portal=PORTAL
7
+
8
+ Arguments:
9
+ NAME Profile resource name (default: Profile)
10
+ field:type Additional fields for the profile
11
+
12
+ Options:
13
+ --dest=PACKAGE Package where Profile resource is created
14
+ --portal=PORTAL Portal to connect the Profile to
15
+ --user-model=NAME Rodauth user model (default: User)
16
+
17
+ Examples:
18
+ # Complete setup in one command
19
+ rails g pu:profile:setup date_of_birth:date bio:text \
20
+ --dest=competition \
21
+ --portal=competition_portal
22
+
23
+ # With custom name
24
+ rails g pu:profile:setup AccountSettings bio:text avatar:attachment \
25
+ --dest=main_app \
26
+ --portal=customer_portal
27
+
28
+ # Main app profile connected to admin portal
29
+ rails g pu:profile:setup --dest=main_app --portal=admin_portal
30
+
31
+ What This Does:
32
+ 1. Creates Profile resource (model, migration, controller, policy, definition)
33
+ 2. Adds has_one :profile to User model
34
+ 3. Adds unique index on user_id
35
+ 4. Customizes policy for owner-only access
36
+ 5. Adds SecuritySection to show page
37
+ 6. Connects to portal as singular resource
38
+ 7. Adds profile_url helper to portal
39
+
40
+ Equivalent to running:
41
+ rails g pu:profile:install [NAME] [fields] --dest=PACKAGE
42
+ rails g pu:profile:conn [RESOURCE] --dest=PORTAL
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require_relative "../lib/plutonium_generators"
5
+ require_relative "concerns/profile_arguments"
6
+
7
+ module Pu
8
+ module Profile
9
+ class SetupGenerator < ::Rails::Generators::Base
10
+ include PlutoniumGenerators::Generator
11
+ include Concerns::ProfileArguments
12
+
13
+ desc "Generate a complete Profile setup with resource and portal connection"
14
+
15
+ class_option :user_model, type: :string, default: "User",
16
+ desc: "The Rodauth user model"
17
+
18
+ class_option :dest, type: :string,
19
+ desc: "Package where the Profile resource should be created"
20
+
21
+ class_option :portal, type: :string,
22
+ desc: "Portal to connect the Profile to"
23
+
24
+ def start
25
+ normalize_arguments
26
+ generate_profile
27
+ connect_to_portal if options[:portal].present?
28
+ rescue => e
29
+ exception "#{self.class} failed:", e
30
+ end
31
+
32
+ private
33
+
34
+ def generate_profile
35
+ klass = Rails::Generators.find_by_namespace("pu:profile:install")
36
+ klass.new(
37
+ [@profile_name, *@profile_attributes],
38
+ {
39
+ user_model: options[:user_model],
40
+ dest: selected_destination_feature,
41
+ force: options[:force],
42
+ skip: options[:skip]
43
+ }
44
+ ).invoke_all
45
+ end
46
+
47
+ def connect_to_portal
48
+ # Shell out to a new process so the newly created model file gets loaded
49
+ generate "pu:profile:conn", "#{resource_class_name} --dest=#{options[:portal]} --user-model=#{options[:user_model]}"
50
+ end
51
+
52
+ def resource_class_name
53
+ if dest_package?
54
+ "#{dest_name.camelize}::#{@profile_name.camelize}"
55
+ else
56
+ @profile_name.camelize
57
+ end
58
+ end
59
+
60
+ def dest_package?
61
+ selected_destination_feature != "main_app"
62
+ end
63
+
64
+ def dest_name
65
+ selected_destination_feature
66
+ end
67
+
68
+ def selected_destination_feature
69
+ @selected_destination_feature ||= feature_option :dest, prompt: "Select destination feature"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -1,19 +1,17 @@
1
- return unless defined?(Rodauth::Rails)
2
-
3
1
  require "rails/generators/base"
4
2
  require "securerandom"
5
3
 
6
4
  require "#{__dir__}/concerns/configuration"
7
5
  require "#{__dir__}/concerns/account_selector"
8
6
  require "#{__dir__}/concerns/feature_selector"
9
- require "#{__dir__}/concerns/gem_helpers"
7
+ require "#{__dir__}/../lib/plutonium_generators/concerns/actions"
10
8
 
11
9
  module Pu
12
10
  module Rodauth
13
11
  class AccountGenerator < ::Rails::Generators::Base
14
12
  include Concerns::AccountSelector
15
13
  include Concerns::FeatureSelector
16
- include Concerns::GemHelpers
14
+ include PlutoniumGenerators::Concerns::Actions
17
15
 
18
16
  source_root "#{__dir__}/templates"
19
17
 
@@ -2,13 +2,13 @@ require "rails/generators/base"
2
2
  require "rails/generators/active_record/migration"
3
3
  require "securerandom"
4
4
 
5
- require "#{__dir__}/concerns/gem_helpers"
5
+ require "#{__dir__}/../lib/plutonium_generators/concerns/actions"
6
6
 
7
7
  module Pu
8
8
  module Rodauth
9
9
  class InstallGenerator < ::Rails::Generators::Base
10
10
  include ::ActiveRecord::Generators::Migration
11
- include Concerns::GemHelpers
11
+ include PlutoniumGenerators::Concerns::Actions
12
12
 
13
13
  source_root "#{__dir__}/templates"
14
14
 
@@ -290,6 +290,9 @@ class <%= account_path.classify %>RodauthPlugin < RodauthPlugin
290
290
  <% end -%>
291
291
  <% if reset_password? -%>
292
292
 
293
+ # Redirect to login page after requesting password reset.
294
+ reset_password_email_sent_redirect { login_path }
295
+
293
296
  # Redirect to login page after password reset.
294
297
  reset_password_redirect { login_path }
295
298
  <% end -%>
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- return unless defined?(Rodauth::Rails)
4
-
5
3
  require "rails/generators/named_base"
6
4
  require_relative "../lib/plutonium_generators"
7
5
 
@@ -39,14 +39,14 @@ module Pu
39
39
 
40
40
  def validate_models_exist!
41
41
  user_model_path = File.join("app", "models", "#{normalized_user_name}.rb")
42
- entity_model_path = File.join("app", "models", "#{normalized_entity_name}.rb")
42
+ entity_model_path = File.join(dest_root, "app", "models", "#{full_entity_path}.rb")
43
43
 
44
44
  unless File.exist?(Rails.root.join(user_model_path))
45
45
  raise "User model '#{normalized_user_name}' does not exist at #{user_model_path}. Please create it first with: rails g pu:saas:user #{options[:user]}"
46
46
  end
47
47
 
48
- unless File.exist?(Rails.root.join(entity_model_path))
49
- raise "Entity model '#{normalized_entity_name}' does not exist at #{entity_model_path}. Please create it first with: rails g pu:saas:entity #{options[:entity]}"
48
+ unless File.exist?(entity_model_path)
49
+ raise "Entity model '#{full_entity_path}' does not exist at #{entity_model_path}. Please create it first with: rails g pu:saas:entity #{options[:entity]}"
50
50
  end
51
51
  end
52
52
 
@@ -63,7 +63,7 @@ module Pu
63
63
  return unless migration_file
64
64
 
65
65
  insert_into_file migration_file,
66
- indent("add_index :#{membership_table_name}, [:#{normalized_entity_name}_id, :#{normalized_user_name}_id], unique: true\n", 4),
66
+ indent("add_index :#{full_membership_table_name}, [:#{normalized_entity_name}_id, :#{normalized_user_name}_id], unique: true\n", 4),
67
67
  before: /^ end\s*$/
68
68
  end
69
69
 
@@ -78,18 +78,18 @@ module Pu
78
78
  end
79
79
 
80
80
  def add_role_enum_to_model
81
- model_file = File.join("app", "models", "#{membership_model_name}.rb")
81
+ model_file = File.join(dest_root, "app", "models", "#{full_membership_path}.rb")
82
82
 
83
- return unless File.exist?(Rails.root.join(model_file))
83
+ return unless File.exist?(model_file)
84
84
 
85
85
  enum_definition = "enum :role, #{roles_enum}\n"
86
86
  insert_into_file model_file, indent(enum_definition, 2), before: /^\s*# add enums above\./
87
87
  end
88
88
 
89
89
  def add_unique_validation_to_model
90
- model_file = File.join("app", "models", "#{membership_model_name}.rb")
90
+ model_file = File.join(dest_root, "app", "models", "#{full_membership_path}.rb")
91
91
 
92
- return unless File.exist?(Rails.root.join(model_file))
92
+ return unless File.exist?(model_file)
93
93
 
94
94
  validation = "validates :#{normalized_user_name}, uniqueness: {scope: :#{normalized_entity_name}_id, message: \"is already a member of this #{normalized_entity_name.humanize.downcase}\"}\n"
95
95
  insert_into_file model_file, indent(validation, 2), before: /^\s*# add validations above\./
@@ -101,9 +101,9 @@ module Pu
101
101
  end
102
102
 
103
103
  def add_association_to_entity_model
104
- entity_model_path = File.join("app", "models", "#{normalized_entity_name}.rb")
104
+ entity_model_path = File.join(dest_root, "app", "models", "#{full_entity_path}.rb")
105
105
 
106
- return unless File.exist?(Rails.root.join(entity_model_path))
106
+ return unless File.exist?(entity_model_path)
107
107
 
108
108
  associations = <<~RUBY
109
109
  has_many :#{membership_table_name}, dependent: :destroy
@@ -117,17 +117,24 @@ module Pu
117
117
 
118
118
  return unless File.exist?(Rails.root.join(user_model_path))
119
119
 
120
- associations = <<~RUBY
121
- has_many :#{membership_table_name}, dependent: :destroy
122
- has_many :#{normalized_entity_name.pluralize}, through: :#{membership_table_name}
123
- RUBY
120
+ associations = if dest_namespace
121
+ <<~RUBY
122
+ has_many :#{membership_table_name}, class_name: "#{full_membership_class_name}", dependent: :destroy
123
+ has_many :#{normalized_entity_name.pluralize}, through: :#{membership_table_name}, source: :#{normalized_entity_name}
124
+ RUBY
125
+ else
126
+ <<~RUBY
127
+ has_many :#{membership_table_name}, dependent: :destroy
128
+ has_many :#{normalized_entity_name.pluralize}, through: :#{membership_table_name}
129
+ RUBY
130
+ end
124
131
  inject_into_file user_model_path, indent(associations, 2), before: /^\s*# add has_many associations above\.\n/
125
132
  end
126
133
 
127
134
  def add_associated_with_scope_to_entity
128
- entity_model_path = File.join("app", "models", "#{normalized_entity_name}.rb")
135
+ entity_model_path = File.join(dest_root, "app", "models", "#{full_entity_path}.rb")
129
136
 
130
- return unless File.exist?(Rails.root.join(entity_model_path))
137
+ return unless File.exist?(entity_model_path)
131
138
 
132
139
  scope_code = <<~RUBY
133
140
  scope :associated_with_#{normalized_user_name}, ->(#{normalized_user_name}) { joins(:#{membership_table_name}).where(#{membership_table_name}: {#{normalized_user_name}_id: #{normalized_user_name}.id}) }
@@ -136,8 +143,8 @@ module Pu
136
143
  end
137
144
 
138
145
  def find_migration_file
139
- migration_dir = File.join("db", "migrate")
140
- Dir[Rails.root.join(migration_dir, "*_create_#{membership_table_name}.rb")].first
146
+ migration_dir = File.join(dest_root, "db", "migrate")
147
+ Dir[File.join(migration_dir, "*_create_#{full_membership_table_name}.rb")].first
141
148
  end
142
149
 
143
150
  def membership_model_name
@@ -150,7 +157,7 @@ module Pu
150
157
 
151
158
  def membership_attributes
152
159
  [
153
- "#{normalized_entity_name}:references",
160
+ "#{full_entity_name}:references",
154
161
  "#{normalized_user_name}:references",
155
162
  "role:integer",
156
163
  *Array(options[:extra_attributes])
@@ -161,6 +168,48 @@ module Pu
161
168
 
162
169
  def normalized_entity_name = options[:entity].underscore
163
170
 
171
+ def dest_namespace
172
+ return nil if main_app?
173
+
174
+ selected_destination_feature.underscore
175
+ end
176
+
177
+ def main_app?
178
+ selected_destination_feature == "main_app"
179
+ end
180
+
181
+ def dest_root
182
+ if main_app?
183
+ Rails.root
184
+ else
185
+ Rails.root.join("packages", dest_namespace)
186
+ end
187
+ end
188
+
189
+ def full_entity_path
190
+ [dest_namespace, normalized_entity_name].compact.join("/")
191
+ end
192
+
193
+ def full_membership_path
194
+ [dest_namespace, membership_model_name].compact.join("/")
195
+ end
196
+
197
+ def full_entity_name
198
+ full_entity_path
199
+ end
200
+
201
+ def full_membership_table_name
202
+ full_membership_path.tr("/", "_").pluralize
203
+ end
204
+
205
+ def full_membership_class_name
206
+ full_membership_path.camelize
207
+ end
208
+
209
+ def full_entity_class_name
210
+ full_entity_path.camelize
211
+ end
212
+
164
213
  def roles
165
214
  Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
166
215
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- return unless defined?(Rodauth::Rails)
4
-
5
3
  require "rails/generators/base"
6
4
  require_relative "../lib/plutonium_generators"
7
5
 
@@ -46,6 +44,7 @@ module Pu
46
44
  desc: "Available roles for API client memberships"
47
45
 
48
46
  def start
47
+ ensure_rodauth_installed
49
48
  generate_user
50
49
  generate_entity unless options[:skip_entity]
51
50
  generate_membership unless options[:skip_membership]
@@ -56,6 +55,12 @@ module Pu
56
55
 
57
56
  private
58
57
 
58
+ def ensure_rodauth_installed
59
+ return if File.exist?(Rails.root.join("app/rodauth/rodauth_app.rb"))
60
+
61
+ invoke "pu:rodauth:install"
62
+ end
63
+
59
64
  def generate_user
60
65
  # Use class-based invocation to avoid Thor's invoke caching
61
66
  klass = Rails::Generators.find_by_namespace("pu:saas:user")
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- return unless defined?(Rodauth::Rails)
4
-
5
3
  require "rails/generators/named_base"
6
4
  require_relative "../lib/plutonium_generators"
7
5
 
@@ -9,6 +9,7 @@ module Plutonium
9
9
  included do
10
10
  helper_method :current_user
11
11
  helper_method :logout_url
12
+ helper_method :profile_url
12
13
  end
13
14
 
14
15
  private
@@ -27,6 +28,13 @@ module Plutonium
27
28
  rodauth.logout_path
28
29
  end
29
30
 
31
+ # Override this method to return your profile page URL.
32
+ # When defined, a "Profile" link will appear in the user menu.
33
+ # Example: rodauth.change_password_path or your custom profile_path
34
+ def profile_url
35
+ nil
36
+ end
37
+
30
38
  define_singleton_method(:to_s) { "Plutonium::Auth::Rodauth(:#{name})" }
31
39
  define_singleton_method(:inspect) { "Plutonium::Auth::Rodauth(:#{name})" }
32
40
  RUBY
@@ -207,7 +207,11 @@ module Plutonium
207
207
  url_args[:action] ||= :index if index == args.length - 1
208
208
  elsif element.is_a?(Class)
209
209
  controller_chain << element.to_s.pluralize
210
- url_args[:action] ||= :index if index == args.length - 1 && parent.present?
210
+ if index == args.length - 1
211
+ # Singular resources have no index, default to show
212
+ is_singular = current_engine.routes.singular_resource_route?(element.model_name.plural)
213
+ url_args[:action] ||= is_singular ? :show : :index
214
+ end
211
215
  else
212
216
  model_class = element.class
213
217
  if model_class.respond_to?(:base_class) && model_class != model_class.base_class
@@ -223,8 +227,7 @@ module Plutonium
223
227
  else
224
228
  model_class.model_name.plural
225
229
  end
226
- resource_route_config = current_engine.routes.resource_route_config_for(route_key)[0]
227
- is_singular = resource_route_config&.dig(:route_type) == :resource
230
+ is_singular = current_engine.routes.singular_resource_route?(route_key)
228
231
  url_args[:id] = element.to_param unless is_singular
229
232
  url_args[:action] ||= :show
230
233
  else
@@ -236,7 +239,7 @@ module Plutonium
236
239
 
237
240
  url_args[:"#{parent.model_name.singular}_id"] = parent.to_param if parent.present?
238
241
  if scoped_to_entity? && scoped_entity_strategy == :path
239
- url_args[scoped_entity_param_key] = current_scoped_entity
242
+ url_args[scoped_entity_param_key] = current_scoped_entity.to_param
240
243
  end
241
244
 
242
245
  if !url_args.key?(:format) && request.present? && request.format.present? && !request.format.symbol.in?([:html, :turbo_stream])
@@ -30,7 +30,11 @@ module Plutonium
30
30
  end
31
31
 
32
32
  def entity_scope_for_authorize
33
- current_scoped_entity if scoped_to_entity?
33
+ # Use the instance variable directly to avoid circular dependency.
34
+ # When authorizing the scoped entity itself (in fetch_current_scoped_entity),
35
+ # @current_scoped_entity is not yet set, so this returns nil, which is correct
36
+ # since we can't use the entity as its own authorization scope.
37
+ @current_scoped_entity if scoped_to_entity?
34
38
  end
35
39
 
36
40
  def verify_authorized
@@ -75,6 +75,13 @@ module Plutonium
75
75
  # global default
76
76
  breadcrumbs true
77
77
 
78
+ # forms
79
+ # Controls the "Save and add another" / "Update and continue editing" buttons
80
+ # nil = auto-detect (hidden for singular resources, shown for plural)
81
+ # true = always show
82
+ # false = always hide
83
+ inheritable_config_attr :submit_and_continue
84
+
78
85
  def initialize
79
86
  super
80
87
  end
@@ -14,6 +14,12 @@ module Plutonium
14
14
  resource_name resource_class, 2
15
15
  end
16
16
 
17
+ # Returns the appropriate label for a resource (singular for singular resources, plural otherwise)
18
+ def resource_label(resource_class)
19
+ is_singular = current_engine.routes.singular_resource_route?(resource_class.model_name.plural)
20
+ resource_name(resource_class, is_singular ? 1 : 2)
21
+ end
22
+
17
23
  # Returns a human-readable name for a nested collection using the association name.
18
24
  # Falls back to resource_name_plural if not in a nested context.
19
25
  # Uses I18n via human_attribute_name for proper localization.