plutonium 0.42.0 → 0.43.1
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-controller/SKILL.md +38 -1
- data/.claude/skills/plutonium-definition/SKILL.md +14 -0
- data/.claude/skills/plutonium-forms/SKILL.md +16 -1
- data/.claude/skills/plutonium-profile/SKILL.md +276 -0
- data/.claude/skills/plutonium-views/SKILL.md +23 -1
- data/CHANGELOG.md +42 -0
- data/app/assets/plutonium.css +2 -2
- data/app/views/plutonium/_resource_header.html.erb +6 -27
- data/app/views/plutonium/_resource_sidebar.html.erb +1 -2
- data/app/views/resource/_resource_details.rabl +3 -2
- data/app/views/resource/index.rabl +3 -2
- data/app/views/resource/show.rabl +3 -2
- data/docs/guides/user-profile.md +322 -0
- data/docs/reference/controller/index.md +38 -1
- data/docs/reference/definition/index.md +16 -0
- data/docs/reference/views/forms.md +15 -0
- data/docs/reference/views/index.md +23 -1
- 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/core/assets/assets_generator.rb +12 -0
- data/lib/generators/pu/core/install/templates/app/controllers/resource_controller.rb.tt +11 -0
- data/lib/generators/pu/core/typespec/templates/common.tsp.tt +95 -0
- data/lib/generators/pu/core/typespec/templates/main.tsp.tt +27 -0
- data/lib/generators/pu/core/typespec/templates/main_multi.tsp.tt +25 -0
- data/lib/generators/pu/core/typespec/templates/model.tsp.tt +226 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +342 -0
- data/lib/generators/pu/invites/USAGE +0 -1
- data/lib/generators/pu/invites/install_generator.rb +62 -15
- data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +2 -2
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +2 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +1 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +5 -5
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +4 -4
- data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +1 -1
- data/lib/generators/pu/lib/plutonium_generators/generator.rb +29 -0
- data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +6 -23
- data/lib/generators/pu/pkg/portal/portal_generator.rb +5 -1
- data/lib/generators/pu/profile/USAGE +59 -0
- data/lib/generators/pu/profile/concerns/profile_arguments.rb +27 -0
- data/lib/generators/pu/profile/conn/USAGE +33 -0
- data/lib/generators/pu/profile/conn_generator.rb +167 -0
- data/lib/generators/pu/profile/install_generator.rb +119 -0
- data/lib/generators/pu/profile/setup/USAGE +42 -0
- data/lib/generators/pu/profile/setup_generator.rb +73 -0
- data/lib/generators/pu/rodauth/account_generator.rb +2 -4
- data/lib/generators/pu/rodauth/install_generator.rb +2 -2
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +3 -0
- data/lib/generators/pu/saas/api_client_generator.rb +0 -2
- data/lib/generators/pu/saas/membership_generator.rb +68 -19
- data/lib/generators/pu/saas/setup_generator.rb +7 -2
- data/lib/generators/pu/saas/user_generator.rb +0 -2
- data/lib/plutonium/auth/rodauth.rb +8 -0
- data/lib/plutonium/core/controller.rb +7 -4
- data/lib/plutonium/core/controllers/authorizable.rb +5 -1
- data/lib/plutonium/definition/base.rb +7 -0
- data/lib/plutonium/helpers/display_helper.rb +6 -0
- data/lib/plutonium/profile/security_section.rb +118 -0
- data/lib/plutonium/resource/controller.rb +17 -7
- data/lib/plutonium/resource/controllers/interactive_actions.rb +11 -25
- data/lib/plutonium/resource/controllers/presentable.rb +46 -3
- data/lib/plutonium/resource/record/associated_with.rb +7 -1
- data/lib/plutonium/routing/mapper_extensions.rb +18 -18
- data/lib/plutonium/routing/route_set_extensions.rb +23 -2
- data/lib/plutonium/ui/breadcrumbs.rb +111 -131
- data/lib/plutonium/ui/dyna_frame/content.rb +12 -2
- data/lib/plutonium/ui/form/resource.rb +26 -19
- data/lib/plutonium/ui/page/base.rb +14 -14
- data/lib/plutonium/ui/table/components/scopes_bar.rb +2 -74
- data/lib/plutonium/ui/table/components/selection_column.rb +6 -2
- data/lib/plutonium/ui/table/resource.rb +3 -2
- data/lib/plutonium/version.rb +1 -1
- data/lib/tasks/release.rake +6 -6
- data/package.json +1 -1
- metadata +17 -3
- data/lib/generators/pu/rodauth/concerns/gem_helpers.rb +0 -19
|
@@ -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", "#{
|
|
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?(
|
|
49
|
-
raise "Entity model '#{
|
|
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 :#{
|
|
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", "#{
|
|
81
|
+
model_file = File.join(dest_root, "app", "models", "#{full_membership_path}.rb")
|
|
82
82
|
|
|
83
|
-
return unless File.exist?(
|
|
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", "#{
|
|
90
|
+
model_file = File.join(dest_root, "app", "models", "#{full_membership_path}.rb")
|
|
91
91
|
|
|
92
|
-
return unless File.exist?(
|
|
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", "#{
|
|
104
|
+
entity_model_path = File.join(dest_root, "app", "models", "#{full_entity_path}.rb")
|
|
105
105
|
|
|
106
|
-
return unless File.exist?(
|
|
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 =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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", "#{
|
|
135
|
+
entity_model_path = File.join(dest_root, "app", "models", "#{full_entity_path}.rb")
|
|
129
136
|
|
|
130
|
-
return unless File.exist?(
|
|
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[
|
|
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
|
-
"#{
|
|
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")
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Profile
|
|
5
|
+
# Renders security settings links based on enabled Rodauth features.
|
|
6
|
+
class SecuritySection < Plutonium::UI::Component::Base
|
|
7
|
+
FEATURES = {
|
|
8
|
+
change_password: {
|
|
9
|
+
label: "Change Password",
|
|
10
|
+
description: "Update your account password",
|
|
11
|
+
icon: Phlex::TablerIcons::Key,
|
|
12
|
+
path_method: :change_password_path
|
|
13
|
+
},
|
|
14
|
+
change_login: {
|
|
15
|
+
label: "Change Email",
|
|
16
|
+
description: "Update your email address",
|
|
17
|
+
icon: Phlex::TablerIcons::Mail,
|
|
18
|
+
path_method: :change_login_path
|
|
19
|
+
},
|
|
20
|
+
otp: {
|
|
21
|
+
label: "Two-Factor Authentication",
|
|
22
|
+
description: "Add an extra layer of security",
|
|
23
|
+
icon: Phlex::TablerIcons::DeviceMobile,
|
|
24
|
+
path_method: :otp_setup_path
|
|
25
|
+
},
|
|
26
|
+
recovery_codes: {
|
|
27
|
+
label: "Recovery Codes",
|
|
28
|
+
description: "View or regenerate backup codes",
|
|
29
|
+
icon: Phlex::TablerIcons::FileCode,
|
|
30
|
+
path_method: :recovery_codes_path
|
|
31
|
+
},
|
|
32
|
+
webauthn: {
|
|
33
|
+
label: "Security Keys",
|
|
34
|
+
description: "Manage passkeys and security keys",
|
|
35
|
+
icon: Phlex::TablerIcons::Fingerprint,
|
|
36
|
+
path_method: :webauthn_setup_path
|
|
37
|
+
},
|
|
38
|
+
active_sessions: {
|
|
39
|
+
label: "Active Sessions",
|
|
40
|
+
description: "View and manage your sessions",
|
|
41
|
+
icon: Phlex::TablerIcons::DevicesCheck,
|
|
42
|
+
path_method: :active_sessions_path
|
|
43
|
+
},
|
|
44
|
+
close_account: {
|
|
45
|
+
label: "Close Account",
|
|
46
|
+
description: "Permanently delete your account",
|
|
47
|
+
icon: Phlex::TablerIcons::Trash,
|
|
48
|
+
path_method: :close_account_path,
|
|
49
|
+
danger: true
|
|
50
|
+
}
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
def view_template
|
|
54
|
+
div(class: "mt-8") do
|
|
55
|
+
render_section_header
|
|
56
|
+
render_feature_links
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def render_section_header
|
|
63
|
+
div(class: "mb-4") do
|
|
64
|
+
h2(class: "text-lg font-semibold text-[var(--pu-text)]") { "Security Settings" }
|
|
65
|
+
p(class: "text-sm text-[var(--pu-text-muted)]") { "Manage your account security" }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def render_feature_links
|
|
70
|
+
div(
|
|
71
|
+
class: "bg-[var(--pu-card-bg)] border border-[var(--pu-card-border)] rounded-[var(--pu-radius-lg)] divide-y divide-[var(--pu-border)]",
|
|
72
|
+
style: "box-shadow: var(--pu-shadow-sm)"
|
|
73
|
+
) do
|
|
74
|
+
enabled_features.each do |feature, config|
|
|
75
|
+
render_feature_link(feature, config)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def render_feature_link(feature, config)
|
|
81
|
+
path = helpers.rodauth.send(config[:path_method])
|
|
82
|
+
danger = config[:danger]
|
|
83
|
+
|
|
84
|
+
a(
|
|
85
|
+
href: path,
|
|
86
|
+
class: tokens(
|
|
87
|
+
"flex items-center gap-4 p-4 hover:bg-[var(--pu-surface-alt)] transition-colors first:rounded-t-[var(--pu-radius-lg)] last:rounded-b-[var(--pu-radius-lg)]",
|
|
88
|
+
danger ? "text-[var(--pu-text-danger)]" : "text-[var(--pu-text)]"
|
|
89
|
+
)
|
|
90
|
+
) do
|
|
91
|
+
# Icon
|
|
92
|
+
div(class: "flex-shrink-0") do
|
|
93
|
+
render config[:icon].new(class: "w-5 h-5")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Content
|
|
97
|
+
div(class: "flex-grow") do
|
|
98
|
+
div(class: "font-medium") { config[:label] }
|
|
99
|
+
div(class: "text-sm text-[var(--pu-text-muted)]") { config[:description] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Arrow
|
|
103
|
+
div(class: "flex-shrink-0 text-[var(--pu-text-muted)]") do
|
|
104
|
+
render Phlex::TablerIcons::ChevronRight.new(class: "w-5 h-5")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def enabled_features
|
|
110
|
+
FEATURES.select { |feature, _config| feature_enabled?(feature) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def feature_enabled?(feature)
|
|
114
|
+
helpers.rodauth.features.include?(feature)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -104,6 +104,13 @@ module Plutonium
|
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# Returns true if current resource is registered as a singular route
|
|
108
|
+
# (e.g., `resource :profile` vs `resources :users`)
|
|
109
|
+
# @return [Boolean]
|
|
110
|
+
def singular_resource_context?
|
|
111
|
+
current_resource_route_config&.[](:route_type) == :resource
|
|
112
|
+
end
|
|
113
|
+
|
|
107
114
|
# Extracts the association name from the current nested route
|
|
108
115
|
# e.g., for route /posts/:post_id/nested_comments, returns :comments
|
|
109
116
|
# @return [Symbol, nil] The association name
|
|
@@ -242,14 +249,17 @@ module Plutonium
|
|
|
242
249
|
# Overrides entity scoping parameters
|
|
243
250
|
# @param [Hash] input_params The input parameters
|
|
244
251
|
def override_entity_scoping_params(input_params)
|
|
245
|
-
|
|
246
|
-
if input_params.key?(scoped_entity_param_key) || resource_class.method_defined?(:"#{scoped_entity_param_key}=")
|
|
247
|
-
input_params[scoped_entity_param_key] = current_scoped_entity
|
|
248
|
-
end
|
|
252
|
+
return unless scoped_to_entity?
|
|
249
253
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
254
|
+
# Use the detected association if available, otherwise fall back to param_key
|
|
255
|
+
assoc_name = scoped_entity_association || scoped_entity_param_key
|
|
256
|
+
|
|
257
|
+
if input_params.key?(assoc_name) || resource_class.method_defined?(:"#{assoc_name}=")
|
|
258
|
+
input_params[assoc_name] = current_scoped_entity
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
if input_params.key?(:"#{assoc_name}_id") || resource_class.method_defined?(:"#{assoc_name}_id=")
|
|
262
|
+
input_params[:"#{assoc_name}_id"] = current_scoped_entity.id
|
|
253
263
|
end
|
|
254
264
|
end
|
|
255
265
|
|
|
@@ -29,12 +29,7 @@ module Plutonium
|
|
|
29
29
|
# GET /resources/1/record_actions/:interactive_action
|
|
30
30
|
def interactive_record_action
|
|
31
31
|
build_interactive_record_action_interaction
|
|
32
|
-
|
|
33
|
-
if helpers.current_turbo_frame == "remote_modal"
|
|
34
|
-
render layout: false, formats: [:html]
|
|
35
|
-
else
|
|
36
|
-
render :interactive_record_action, formats: [:html]
|
|
37
|
-
end
|
|
32
|
+
render :interactive_record_action, layout: modal_layout, formats: [:html]
|
|
38
33
|
end
|
|
39
34
|
|
|
40
35
|
# POST /resources/1/record_actions/:interactive_action
|
|
@@ -67,7 +62,7 @@ module Plutonium
|
|
|
67
62
|
end
|
|
68
63
|
else
|
|
69
64
|
format.any(:html, :turbo_stream) do
|
|
70
|
-
render :interactive_record_action, formats: [:html], status: :unprocessable_content
|
|
65
|
+
render :interactive_record_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
|
|
71
66
|
end
|
|
72
67
|
format.any do
|
|
73
68
|
@errors = @interaction.errors
|
|
@@ -82,16 +77,7 @@ module Plutonium
|
|
|
82
77
|
def interactive_resource_action
|
|
83
78
|
skip_verify_current_authorized_scope!
|
|
84
79
|
build_interactive_resource_action_interaction
|
|
85
|
-
|
|
86
|
-
respond_to do |format|
|
|
87
|
-
format.any(:html, :turbo_stream) do
|
|
88
|
-
if helpers.current_turbo_frame == "remote_modal"
|
|
89
|
-
render layout: false, formats: [:html]
|
|
90
|
-
else
|
|
91
|
-
render :interactive_resource_action, formats: [:html]
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
80
|
+
render :interactive_resource_action, layout: modal_layout, formats: [:html]
|
|
95
81
|
end
|
|
96
82
|
|
|
97
83
|
# POST /resources/resource_actions/:interactive_action
|
|
@@ -125,7 +111,7 @@ module Plutonium
|
|
|
125
111
|
end
|
|
126
112
|
else
|
|
127
113
|
format.any(:html, :turbo_stream) do
|
|
128
|
-
render :interactive_resource_action, formats: [:html], status: :unprocessable_content
|
|
114
|
+
render :interactive_resource_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
|
|
129
115
|
end
|
|
130
116
|
format.any do
|
|
131
117
|
@errors = @interaction.errors
|
|
@@ -139,12 +125,7 @@ module Plutonium
|
|
|
139
125
|
# GET /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
|
|
140
126
|
def interactive_bulk_action
|
|
141
127
|
build_interactive_bulk_action_interaction
|
|
142
|
-
|
|
143
|
-
if helpers.current_turbo_frame == "remote_modal"
|
|
144
|
-
render layout: false, formats: [:html]
|
|
145
|
-
else
|
|
146
|
-
render :interactive_bulk_action, formats: [:html]
|
|
147
|
-
end
|
|
128
|
+
render :interactive_bulk_action, layout: modal_layout, formats: [:html]
|
|
148
129
|
end
|
|
149
130
|
|
|
150
131
|
# POST /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
|
|
@@ -177,7 +158,7 @@ module Plutonium
|
|
|
177
158
|
end
|
|
178
159
|
else
|
|
179
160
|
format.any(:html, :turbo_stream) do
|
|
180
|
-
render :interactive_bulk_action, formats: [:html], status: :unprocessable_content
|
|
161
|
+
render :interactive_bulk_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
|
|
181
162
|
end
|
|
182
163
|
format.any do
|
|
183
164
|
@errors = @interaction.errors
|
|
@@ -190,6 +171,11 @@ module Plutonium
|
|
|
190
171
|
|
|
191
172
|
private
|
|
192
173
|
|
|
174
|
+
# Returns false for modal requests (skip layout), nil otherwise (use default layout)
|
|
175
|
+
def modal_layout
|
|
176
|
+
helpers.current_turbo_frame.present? ? false : nil
|
|
177
|
+
end
|
|
178
|
+
|
|
193
179
|
def current_interactive_action
|
|
194
180
|
@current_interactive_action = interactive_resource_actions[params[:interactive_action].to_sym]
|
|
195
181
|
end
|
|
@@ -17,7 +17,7 @@ module Plutonium
|
|
|
17
17
|
presentable_attributes -= [parent_input_param, :"#{parent_input_param}_id"]
|
|
18
18
|
end
|
|
19
19
|
if scoped_to_entity? && !present_scoped_entity?
|
|
20
|
-
presentable_attributes -=
|
|
20
|
+
presentable_attributes -= scoped_entity_field_names
|
|
21
21
|
end
|
|
22
22
|
presentable_attributes
|
|
23
23
|
end
|
|
@@ -33,11 +33,49 @@ module Plutonium
|
|
|
33
33
|
submittable_attributes -= [parent_input_param, :"#{parent_input_param}_id"]
|
|
34
34
|
end
|
|
35
35
|
if scoped_to_entity? && !submit_scoped_entity?
|
|
36
|
-
submittable_attributes -=
|
|
36
|
+
submittable_attributes -= scoped_entity_field_names
|
|
37
37
|
end
|
|
38
38
|
submittable_attributes
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Returns all field names related to the scoped entity.
|
|
42
|
+
# Finds associations by class to handle cases where param_key differs from association name.
|
|
43
|
+
def scoped_entity_field_names
|
|
44
|
+
field_names = [scoped_entity_param_key, :"#{scoped_entity_param_key}_id"]
|
|
45
|
+
|
|
46
|
+
assoc_name = scoped_entity_association
|
|
47
|
+
if assoc_name
|
|
48
|
+
field_names << assoc_name
|
|
49
|
+
field_names << :"#{assoc_name}_id"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
field_names.uniq
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns the name of the belongs_to association pointing to the scoped entity class.
|
|
56
|
+
# Raises if multiple associations exist (ambiguous - user must configure manually).
|
|
57
|
+
# @return [Symbol, nil] the association name or nil if not found
|
|
58
|
+
def scoped_entity_association
|
|
59
|
+
return @scoped_entity_association if defined?(@scoped_entity_association)
|
|
60
|
+
|
|
61
|
+
matching_assocs = resource_class.reflect_on_all_associations(:belongs_to).select do |assoc|
|
|
62
|
+
assoc.klass.name == scoped_entity_class.name
|
|
63
|
+
rescue NameError
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if matching_assocs.size > 1
|
|
68
|
+
assoc_names = matching_assocs.map(&:name).join(", ")
|
|
69
|
+
raise <<~MSG.squish
|
|
70
|
+
#{resource_class} has multiple associations to #{scoped_entity_class}: #{assoc_names}.
|
|
71
|
+
Plutonium cannot auto-detect which one to use for entity scoping.
|
|
72
|
+
Override `scoped_entity_association` in your controller to specify the association.
|
|
73
|
+
MSG
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@scoped_entity_association = matching_assocs.first&.name
|
|
77
|
+
end
|
|
78
|
+
|
|
41
79
|
def build_collection
|
|
42
80
|
current_definition.collection_class.new(@resource_records, resource_fields: presentable_attributes, resource_definition: current_definition)
|
|
43
81
|
end
|
|
@@ -47,7 +85,12 @@ module Plutonium
|
|
|
47
85
|
end
|
|
48
86
|
|
|
49
87
|
def build_form(record = resource_record!, action: action_name, form_action: nil, **)
|
|
50
|
-
form_options = {
|
|
88
|
+
form_options = {
|
|
89
|
+
resource_fields: submittable_attributes_for(action),
|
|
90
|
+
resource_definition: current_definition,
|
|
91
|
+
singular_resource: singular_resource_context?,
|
|
92
|
+
**
|
|
93
|
+
}
|
|
51
94
|
form_options[:action] = form_action unless form_action.nil?
|
|
52
95
|
current_definition.form_class.new(record, **form_options)
|
|
53
96
|
end
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# lib/plutonium/resource/associations.rb
|
|
4
3
|
module Plutonium
|
|
5
4
|
module Resource
|
|
6
5
|
module Record
|
|
@@ -9,6 +8,13 @@ module Plutonium
|
|
|
9
8
|
|
|
10
9
|
included do
|
|
11
10
|
scope :associated_with, ->(record) do
|
|
11
|
+
# If scoping to same class, just match by ID (e.g., Team scoped to Team)
|
|
12
|
+
# Compare by name to handle Rails class reloading (different object_id after reload)
|
|
13
|
+
if klass.name == record.class.name
|
|
14
|
+
pk = klass.primary_key
|
|
15
|
+
return where(pk => record.public_send(pk))
|
|
16
|
+
end
|
|
17
|
+
|
|
12
18
|
named_scope = :"associated_with_#{record.model_name.singular}"
|
|
13
19
|
return send(named_scope, record) if respond_to?(named_scope)
|
|
14
20
|
|