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.
- 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 +36 -0
- data/app/assets/plutonium.css +1 -1
- 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/selection_column.rb +6 -2
- data/lib/plutonium/ui/table/resource.rb +3 -2
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- metadata +17 -3
- data/lib/generators/pu/rodauth/concerns/gem_helpers.rb +0 -19
|
@@ -23,15 +23,15 @@ module Pu
|
|
|
23
23
|
class_option :membership_model, type: :string,
|
|
24
24
|
desc: "The membership model name (defaults to <Entity>User)"
|
|
25
25
|
|
|
26
|
-
class_option :roles, type: :array, default: %w[member admin],
|
|
27
|
-
desc: "Available roles for invitations"
|
|
28
|
-
|
|
29
26
|
class_option :rodauth, type: :string, default: "user",
|
|
30
27
|
desc: "Rodauth configuration name for signup integration"
|
|
31
28
|
|
|
32
29
|
class_option :enforce_domain, type: :boolean, default: false,
|
|
33
30
|
desc: "Require invited user email to match entity domain"
|
|
34
31
|
|
|
32
|
+
class_option :dest, type: :string, default: "main_app",
|
|
33
|
+
desc: "Package where entity model is located (default: main_app)"
|
|
34
|
+
|
|
35
35
|
def validate_requirements
|
|
36
36
|
errors = []
|
|
37
37
|
|
|
@@ -55,9 +55,13 @@ module Pu
|
|
|
55
55
|
errors << "User policy not found: #{user_policy_path}"
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
unless File.exist?(membership_model_file)
|
|
59
|
+
errors << "Membership model not found: #{membership_model_file.relative_path_from(Rails.root)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
58
62
|
if errors.any?
|
|
59
63
|
errors.each { |e| say_status :error, e, :red }
|
|
60
|
-
raise Thor::Error, "Required files missing
|
|
64
|
+
raise Thor::Error, "Required files missing:\n - #{errors.join("\n - ")}"
|
|
61
65
|
end
|
|
62
66
|
end
|
|
63
67
|
|
|
@@ -132,8 +136,13 @@ module Pu
|
|
|
132
136
|
end
|
|
133
137
|
|
|
134
138
|
def create_entity_interaction
|
|
135
|
-
|
|
139
|
+
dest_path = if entity_in_package?
|
|
140
|
+
"packages/#{entity_package}/app/interactions/#{entity_table}/invite_user_interaction.rb"
|
|
141
|
+
else
|
|
136
142
|
"app/interactions/#{entity_table}/invite_user_interaction.rb"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
template "app/interactions/invite_user_interaction.rb", dest_path
|
|
137
146
|
end
|
|
138
147
|
|
|
139
148
|
def add_entity_action
|
|
@@ -144,7 +153,7 @@ module Pu
|
|
|
144
153
|
|
|
145
154
|
def add_entity_policy
|
|
146
155
|
inject_into_file entity_policy_path,
|
|
147
|
-
"def invite_user?\n user.is_a?(Admin)
|
|
156
|
+
"def invite_user?\n false # TODO: e.g., current_membership&.admin? or user.is_a?(Admin)\n end\n\n ",
|
|
148
157
|
before: "# Core attributes"
|
|
149
158
|
end
|
|
150
159
|
|
|
@@ -161,7 +170,7 @@ module Pu
|
|
|
161
170
|
|
|
162
171
|
def add_user_policy
|
|
163
172
|
inject_into_file user_policy_path,
|
|
164
|
-
"def invite_user?\n user.is_a?(Admin)
|
|
173
|
+
"def invite_user?\n false # TODO: e.g., current_membership&.admin? or user.is_a?(Admin)\n end\n\n ",
|
|
165
174
|
before: "# Core attributes"
|
|
166
175
|
end
|
|
167
176
|
|
|
@@ -176,7 +185,7 @@ module Pu
|
|
|
176
185
|
def current_membership
|
|
177
186
|
return unless entity_scope && user
|
|
178
187
|
|
|
179
|
-
|
|
188
|
+
@current_membership ||= #{membership_model}.find_by(#{entity_association_name}: entity_scope, user: user)
|
|
180
189
|
end
|
|
181
190
|
RUBY
|
|
182
191
|
|
|
@@ -308,16 +317,43 @@ module Pu
|
|
|
308
317
|
options[:entity_model].underscore
|
|
309
318
|
end
|
|
310
319
|
|
|
320
|
+
# Returns the association name for entity on the membership model.
|
|
321
|
+
# Strips shared namespace between membership and entity models.
|
|
322
|
+
# e.g., Competition::TeamUser -> Competition::Team uses :team (not :competition_team)
|
|
323
|
+
def entity_association_name
|
|
324
|
+
PlutoniumGenerators::Generator.derive_association_name(membership_model, entity_model)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def entity_in_package?
|
|
328
|
+
options[:dest] != "main_app"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def entity_package
|
|
332
|
+
options[:dest]
|
|
333
|
+
end
|
|
334
|
+
|
|
311
335
|
def entity_model_path
|
|
312
|
-
|
|
336
|
+
if entity_in_package?
|
|
337
|
+
"packages/#{entity_package}/app/models/#{entity_table}.rb"
|
|
338
|
+
else
|
|
339
|
+
"app/models/#{entity_table}.rb"
|
|
340
|
+
end
|
|
313
341
|
end
|
|
314
342
|
|
|
315
343
|
def entity_definition_path
|
|
316
|
-
|
|
344
|
+
if entity_in_package?
|
|
345
|
+
"packages/#{entity_package}/app/definitions/#{entity_table}_definition.rb"
|
|
346
|
+
else
|
|
347
|
+
"app/definitions/#{entity_table}_definition.rb"
|
|
348
|
+
end
|
|
317
349
|
end
|
|
318
350
|
|
|
319
351
|
def entity_policy_path
|
|
320
|
-
|
|
352
|
+
if entity_in_package?
|
|
353
|
+
"packages/#{entity_package}/app/policies/#{entity_table}_policy.rb"
|
|
354
|
+
else
|
|
355
|
+
"app/policies/#{entity_table}_policy.rb"
|
|
356
|
+
end
|
|
321
357
|
end
|
|
322
358
|
|
|
323
359
|
def user_model
|
|
@@ -340,12 +376,23 @@ module Pu
|
|
|
340
376
|
options[:membership_model] || "#{entity_model}User"
|
|
341
377
|
end
|
|
342
378
|
|
|
343
|
-
def
|
|
344
|
-
|
|
379
|
+
def membership_model_file
|
|
380
|
+
model_path = "#{membership_model.underscore}.rb"
|
|
381
|
+
if entity_in_package?
|
|
382
|
+
Rails.root.join("packages", options[:dest], "app/models", model_path)
|
|
383
|
+
else
|
|
384
|
+
Rails.root.join("app/models", model_path)
|
|
385
|
+
end
|
|
345
386
|
end
|
|
346
387
|
|
|
347
|
-
|
|
348
|
-
|
|
388
|
+
# Read roles from the membership model's enum definition
|
|
389
|
+
def roles
|
|
390
|
+
content = File.read(membership_model_file)
|
|
391
|
+
if (match = content.match(/enum\s+:role,\s*(.+?)(?:\n|$)/))
|
|
392
|
+
match[1].scan(/(\w+):/).flatten
|
|
393
|
+
else
|
|
394
|
+
raise Thor::Error, "Could not find 'enum :role' in #{membership_model_file.relative_path_from(Rails.root)}"
|
|
395
|
+
end
|
|
349
396
|
end
|
|
350
397
|
|
|
351
398
|
def rodauth_config
|
|
@@ -4,7 +4,7 @@ class CreateUserInvites < ActiveRecord::Migration[<%= ActiveRecord::Migration.cu
|
|
|
4
4
|
def change
|
|
5
5
|
create_table :user_invites do |t|
|
|
6
6
|
# Entity association
|
|
7
|
-
t.belongs_to :<%=
|
|
7
|
+
t.belongs_to :<%= entity_association_name %>, null: false, foreign_key: true
|
|
8
8
|
|
|
9
9
|
# Invitation details
|
|
10
10
|
t.string :email, null: false
|
|
@@ -34,7 +34,7 @@ class CreateUserInvites < ActiveRecord::Migration[<%= ActiveRecord::Migration.cu
|
|
|
34
34
|
t.index :token, unique: true
|
|
35
35
|
|
|
36
36
|
# Only one pending invite per email per entity
|
|
37
|
-
t.index [:<%=
|
|
37
|
+
t.index [:<%= entity_association_name %>_id, :email], unique: true, where: "state = 0",
|
|
38
38
|
name: "index_user_invites_on_entity_email_pending"
|
|
39
39
|
|
|
40
40
|
# Only one pending invite per invitable (when invitable is present)
|
data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt
CHANGED
|
@@ -4,11 +4,11 @@ module Invites
|
|
|
4
4
|
class UserInvite < Invites::ResourceRecord
|
|
5
5
|
include Plutonium::Invites::Concerns::InviteToken
|
|
6
6
|
|
|
7
|
-
enum :role, <%=
|
|
7
|
+
enum :role, <%= membership_model %>.roles
|
|
8
8
|
|
|
9
9
|
encrypts :token, deterministic: true
|
|
10
10
|
|
|
11
|
-
belongs_to :<%=
|
|
11
|
+
belongs_to :<%= entity_association_name %>, class_name: "<%= entity_model %>"
|
|
12
12
|
belongs_to :invited_by, polymorphic: true
|
|
13
13
|
belongs_to :<%= user_table %>, optional: true
|
|
14
14
|
belongs_to :invitable, polymorphic: true, optional: true
|
|
@@ -30,12 +30,12 @@ module Invites
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def create_membership_for(user)
|
|
33
|
-
<%= membership_model %>.create!(<%=
|
|
33
|
+
<%= membership_model %>.create!(<%= entity_association_name %>: <%= entity_association_name %>, user: user, role: role)
|
|
34
34
|
end
|
|
35
|
-
<% if
|
|
35
|
+
<% if entity_association_name != "entity" -%>
|
|
36
36
|
|
|
37
37
|
# Alias for InviteToken concern compatibility
|
|
38
|
-
alias_method :entity, :<%=
|
|
38
|
+
alias_method :entity, :<%= entity_association_name %>
|
|
39
39
|
<% end -%>
|
|
40
40
|
end
|
|
41
41
|
end
|
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
<label for="email" class="pu-label">Email</label>
|
|
16
16
|
<%% if @invite.enforce_email? %>
|
|
17
17
|
<input type="email" name="email" value="<%%= @invite.email %>" disabled
|
|
18
|
-
class="pu-input cursor-not-allowed opacity-60">
|
|
18
|
+
autocomplete="email" class="pu-input cursor-not-allowed opacity-60">
|
|
19
19
|
<input type="hidden" name="email" value="<%%= @invite.email %>">
|
|
20
20
|
<p class="pu-hint">This email is required for your invitation</p>
|
|
21
21
|
<%% else %>
|
|
22
22
|
<%%= form.email_field :email, value: @invite.email, required: true,
|
|
23
23
|
placeholder: "you@example.com",
|
|
24
|
-
class: "pu-input" %>
|
|
24
|
+
autocomplete: "email", class: "pu-input" %>
|
|
25
25
|
<%% if (domain = @invite.enforce_domain) %>
|
|
26
26
|
<p class="pu-hint">Must be from <%%= domain %> domain</p>
|
|
27
27
|
<%% else %>
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
|
|
33
33
|
<div>
|
|
34
34
|
<label for="password" class="pu-label">Password</label>
|
|
35
|
-
<%%= form.password_field :password, required: true, class: "pu-input" %>
|
|
35
|
+
<%%= form.password_field :password, required: true, autocomplete: "new-password", class: "pu-input" %>
|
|
36
36
|
</div>
|
|
37
37
|
|
|
38
38
|
<div>
|
|
39
39
|
<label for="password_confirmation" class="pu-label">Confirm Password</label>
|
|
40
|
-
<%%= form.password_field :password_confirmation, required: true, class: "pu-input" %>
|
|
40
|
+
<%%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", class: "pu-input" %>
|
|
41
41
|
</div>
|
|
42
42
|
|
|
43
43
|
<div class="space-y-3 pt-4">
|
|
@@ -447,7 +447,7 @@ module PlutoniumGenerators
|
|
|
447
447
|
|
|
448
448
|
def gem_in_bundle?(name)
|
|
449
449
|
in_root do
|
|
450
|
-
return true if File.exist?("Gemfile") && File.read("Gemfile").match?(
|
|
450
|
+
return true if File.exist?("Gemfile") && File.read("Gemfile").match?(/^\s*gem ['"]#{name}['"]/)
|
|
451
451
|
return true if File.exist?("Gemfile.lock") && File.read("Gemfile.lock").include?(" #{name} ")
|
|
452
452
|
end
|
|
453
453
|
false
|
|
@@ -10,6 +10,35 @@ module PlutoniumGenerators
|
|
|
10
10
|
include Concerns::Serializer
|
|
11
11
|
include Concerns::Actions
|
|
12
12
|
|
|
13
|
+
# Finds the shared namespace prefix between two model names.
|
|
14
|
+
# Used to derive association names when models share a namespace.
|
|
15
|
+
# e.g., find_shared_namespace("Competition::TeamUser", "Competition::Team") => "competition"
|
|
16
|
+
def self.find_shared_namespace(model1, model2, separator: "/")
|
|
17
|
+
parts1 = model1.underscore.split(separator)
|
|
18
|
+
parts2 = model2.underscore.split(separator)
|
|
19
|
+
|
|
20
|
+
shared = []
|
|
21
|
+
[parts1.length, parts2.length].min.times do |i|
|
|
22
|
+
break unless parts1[i] == parts2[i]
|
|
23
|
+
shared << parts1[i]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
shared.empty? ? nil : shared.join(separator)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Derives the association name for a reference, stripping shared namespace.
|
|
30
|
+
# e.g., derive_association_name("Competition::TeamUser", "Competition::Team") => "team"
|
|
31
|
+
def self.derive_association_name(from_model, to_model)
|
|
32
|
+
to_parts = to_model.underscore.split("/")
|
|
33
|
+
|
|
34
|
+
if (shared = find_shared_namespace(from_model, to_model))
|
|
35
|
+
shared_parts = shared.split("/")
|
|
36
|
+
to_parts = to_parts.drop(shared_parts.length)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
to_parts.join("_")
|
|
40
|
+
end
|
|
41
|
+
|
|
13
42
|
def self.included(base)
|
|
14
43
|
base.send :class_option, :interactive, type: :boolean, desc: "Show prompts. Default: true"
|
|
15
44
|
base.send :class_option, :bundle, type: :boolean, desc: "Run bundle after setup. Default: true"
|
|
@@ -117,11 +117,7 @@ module PlutoniumGenerators
|
|
|
117
117
|
if name.include? "/"
|
|
118
118
|
attr_options[:to_table] = name.underscore.tr("/", "_").pluralize.to_sym
|
|
119
119
|
attr_options[:class_name] = name.classify
|
|
120
|
-
name = name
|
|
121
|
-
if (shared_namespace = find_shared_namespace(model_name, name, separator: "/"))
|
|
122
|
-
name = name.sub("#{shared_namespace}/", "")
|
|
123
|
-
end
|
|
124
|
-
name = name.tr("/", "_")
|
|
120
|
+
name = PlutoniumGenerators::Generator.derive_association_name(model_name, name)
|
|
125
121
|
end
|
|
126
122
|
end
|
|
127
123
|
|
|
@@ -259,27 +255,14 @@ module PlutoniumGenerators
|
|
|
259
255
|
end
|
|
260
256
|
end
|
|
261
257
|
|
|
262
|
-
def find_shared_namespace(model1, model2, separator: "::")
|
|
263
|
-
parts1 = model1.underscore.split(separator)
|
|
264
|
-
parts2 = model2.underscore.split(separator)
|
|
265
|
-
|
|
266
|
-
shared_namespace = []
|
|
267
|
-
[parts1.length, parts2.length].min.times do |i|
|
|
268
|
-
if parts1[i] == parts2[i]
|
|
269
|
-
shared_namespace << parts1[i]
|
|
270
|
-
else
|
|
271
|
-
break
|
|
272
|
-
end
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
shared_namespace.empty? ? nil : shared_namespace.join(separator)
|
|
276
|
-
end
|
|
277
258
|
end
|
|
278
259
|
|
|
279
260
|
def required?
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
261
|
+
if attr_options.key?(:null)
|
|
262
|
+
!attr_options[:null]
|
|
263
|
+
else
|
|
264
|
+
super
|
|
265
|
+
end
|
|
283
266
|
end
|
|
284
267
|
|
|
285
268
|
def cents?
|
|
@@ -88,7 +88,11 @@ module Pu
|
|
|
88
88
|
def bring_your_own_auth? = @bring_your_own_auth
|
|
89
89
|
|
|
90
90
|
def configure_entity_scoping
|
|
91
|
-
|
|
91
|
+
return unless options[:scope].present?
|
|
92
|
+
|
|
93
|
+
scope = options[:scope].camelize
|
|
94
|
+
# Prepend :: to ensure absolute constant reference (avoids NameError in engine.rb)
|
|
95
|
+
@scoped_entity_class = scope.start_with?("::") ? scope : "::#{scope}"
|
|
92
96
|
end
|
|
93
97
|
|
|
94
98
|
def scoped_to_entity? = scoped_entity_class.present?
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Generate a Profile resource for managing Rodauth account settings.
|
|
3
|
+
Creates a resource linked to the User model with a customized ShowPage
|
|
4
|
+
that displays security settings links (change password, 2FA, etc.).
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
rails g pu:profile:install [NAME] [field:type field:type] --dest=DESTINATION
|
|
8
|
+
|
|
9
|
+
Arguments:
|
|
10
|
+
NAME Profile resource name (default: Profile)
|
|
11
|
+
field:type Additional fields to add to the profile
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--dest=DESTINATION Target destination (required to avoid interactive prompts)
|
|
15
|
+
Use 'main_app' for main application
|
|
16
|
+
Use 'package_name' for feature package
|
|
17
|
+
--user-model=NAME Rodauth user model (default: User)
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
# Basic profile in main_app
|
|
21
|
+
rails g pu:profile:install --dest=main_app
|
|
22
|
+
|
|
23
|
+
# Profile with custom fields
|
|
24
|
+
rails g pu:profile:install bio:text avatar:attachment --dest=main_app
|
|
25
|
+
|
|
26
|
+
# Custom name with fields in a package
|
|
27
|
+
rails g pu:profile:install AccountSettings \
|
|
28
|
+
bio:text \
|
|
29
|
+
'timezone:string?' \
|
|
30
|
+
'notifications_enabled:boolean' \
|
|
31
|
+
--dest=customer
|
|
32
|
+
|
|
33
|
+
# With custom user model
|
|
34
|
+
rails g pu:profile:install --dest=main_app --user-model=Account
|
|
35
|
+
|
|
36
|
+
Generated Files:
|
|
37
|
+
Model: app/models/[package/]profile.rb
|
|
38
|
+
Migration: db/migrate/xxx_create_[package_]profiles.rb
|
|
39
|
+
Controller: [packages/package/]app/controllers/[package/]profiles_controller.rb
|
|
40
|
+
Policy: [packages/package/]app/policies/[package/]profile_policy.rb
|
|
41
|
+
Definition: [packages/package/]app/definitions/[package/]profile_definition.rb
|
|
42
|
+
|
|
43
|
+
What Gets Modified:
|
|
44
|
+
User Model: Adds `has_one :profile, dependent: :destroy`
|
|
45
|
+
Definition: Injects ShowPage with SecuritySection for Rodauth links
|
|
46
|
+
|
|
47
|
+
The SecuritySection automatically displays links for enabled Rodauth features:
|
|
48
|
+
- Change Password (change_password)
|
|
49
|
+
- Change Email (change_login)
|
|
50
|
+
- Two-Factor Authentication (otp)
|
|
51
|
+
- Recovery Codes (recovery_codes)
|
|
52
|
+
- Security Keys (webauthn)
|
|
53
|
+
- Active Sessions (active_sessions)
|
|
54
|
+
- Close Account (close_account)
|
|
55
|
+
|
|
56
|
+
After Generation:
|
|
57
|
+
1. Run migrations: rails db:migrate
|
|
58
|
+
2. Connect to portal: rails g pu:profile:conn --dest=my_portal
|
|
59
|
+
3. Customize the profile definition as needed
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pu
|
|
4
|
+
module Profile
|
|
5
|
+
module Concerns
|
|
6
|
+
module ProfileArguments
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
argument :name, type: :string, default: "Profile", required: false, banner: "NAME"
|
|
11
|
+
argument :attributes, type: :array, default: [], banner: "field[:type] field[:type]"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Normalize arguments: if name contains ":", treat it as an attribute
|
|
15
|
+
def normalize_arguments
|
|
16
|
+
if name.include?(":")
|
|
17
|
+
@profile_attributes = [name, *attributes]
|
|
18
|
+
@profile_name = "Profile"
|
|
19
|
+
else
|
|
20
|
+
@profile_name = name
|
|
21
|
+
@profile_attributes = attributes
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Connect a Profile resource to a portal with the profile_url helper configured.
|
|
3
|
+
This enables the "Profile" link in the user menu.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
rails g pu:profile:conn [RESOURCE] --dest=PORTAL
|
|
7
|
+
|
|
8
|
+
Arguments:
|
|
9
|
+
RESOURCE Profile resource class (default: Profile)
|
|
10
|
+
Use namespaced class for package resources (e.g., Competition::Profile)
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--dest=PORTAL Target portal (required to avoid interactive prompts)
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
# Connect namespaced Profile to portal
|
|
17
|
+
rails g pu:profile:conn Competition::Profile --dest=competition_portal
|
|
18
|
+
|
|
19
|
+
# Connect main_app Profile to portal
|
|
20
|
+
rails g pu:profile:conn Profile --dest=customer_portal
|
|
21
|
+
|
|
22
|
+
# Connect custom-named profile
|
|
23
|
+
rails g pu:profile:conn Competition::AccountSettings --dest=competition_portal
|
|
24
|
+
|
|
25
|
+
What Gets Created:
|
|
26
|
+
Controller: packages/[portal]/app/controllers/[portal]/profiles_controller.rb
|
|
27
|
+
|
|
28
|
+
What Gets Modified:
|
|
29
|
+
Routes: packages/[portal]/config/routes.rb (registers as singular resource)
|
|
30
|
+
Controller: packages/[portal]/app/controllers/[portal]/resource_controller.rb
|
|
31
|
+
(adds profile_url helper method)
|
|
32
|
+
|
|
33
|
+
The profile_url helper enables the "Profile" link in the user navigation menu.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require_relative "../lib/plutonium_generators"
|
|
5
|
+
|
|
6
|
+
module Pu
|
|
7
|
+
module Profile
|
|
8
|
+
class ConnGenerator < ::Rails::Generators::Base
|
|
9
|
+
include PlutoniumGenerators::Generator
|
|
10
|
+
|
|
11
|
+
desc "Connect a Profile resource to a portal and configure the profile_url helper"
|
|
12
|
+
|
|
13
|
+
argument :name, type: :string, default: "Profile", required: false, banner: "RESOURCE"
|
|
14
|
+
|
|
15
|
+
class_option :dest, type: :string,
|
|
16
|
+
desc: "Destination portal"
|
|
17
|
+
|
|
18
|
+
class_option :user_model, type: :string, default: "User",
|
|
19
|
+
desc: "The Rodauth user model"
|
|
20
|
+
|
|
21
|
+
def start
|
|
22
|
+
validate_portal_destination!
|
|
23
|
+
connect_to_portal
|
|
24
|
+
customize_policy
|
|
25
|
+
customize_definition
|
|
26
|
+
customize_controller
|
|
27
|
+
add_profile_url_helper
|
|
28
|
+
rescue => e
|
|
29
|
+
exception "#{self.class} failed:", e
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def validate_portal_destination!
|
|
35
|
+
if selected_destination_portal == "main_app"
|
|
36
|
+
raise ArgumentError, <<~MSG.squish
|
|
37
|
+
pu:profile:conn is for portal packages only. For main_app, configure
|
|
38
|
+
profile_url directly in your ResourceController.
|
|
39
|
+
MSG
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def connect_to_portal
|
|
44
|
+
invoke "pu:res:conn", [resource_class_name],
|
|
45
|
+
dest: selected_destination_portal,
|
|
46
|
+
singular: true,
|
|
47
|
+
policy: true,
|
|
48
|
+
definition: true,
|
|
49
|
+
force: options[:force],
|
|
50
|
+
skip: options[:skip]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def customize_policy
|
|
54
|
+
content = <<-RUBY.chomp
|
|
55
|
+
|
|
56
|
+
# Profile is scoped to current user, not entity.
|
|
57
|
+
# Note: `user` here is the policy's user method (current authenticated user),
|
|
58
|
+
# while `#{user_table}` is the model's association name.
|
|
59
|
+
relation_scope do |relation|
|
|
60
|
+
skip_default_relation_scope!
|
|
61
|
+
relation.where(#{user_table}: user)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def create?
|
|
65
|
+
user.#{profile_association}.nil?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def destroy?
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# User is set automatically from current_user, not via mass assignment
|
|
73
|
+
def permitted_attributes_for_create
|
|
74
|
+
super - [:#{user_table}]
|
|
75
|
+
end
|
|
76
|
+
RUBY
|
|
77
|
+
inject_into_file policy_path, content, after: /include #{dest_name.camelize}::ResourcePolicy\n/
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def customize_definition
|
|
81
|
+
# Add ShowPage with SecuritySection
|
|
82
|
+
content = indent(<<~RUBY, 2)
|
|
83
|
+
|
|
84
|
+
class ShowPage < ShowPage
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def render_after_content
|
|
88
|
+
render Plutonium::Profile::SecuritySection.new
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
RUBY
|
|
92
|
+
inject_into_file definition_path, content, after: /include #{dest_name.camelize}::ResourceDefinition\n/
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def customize_controller
|
|
96
|
+
# Set user automatically when creating profile
|
|
97
|
+
content = <<-RUBY.chomp
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def resource_params
|
|
102
|
+
super.merge(#{user_table}: current_user)
|
|
103
|
+
end
|
|
104
|
+
RUBY
|
|
105
|
+
inject_into_file controller_path, content, after: /include #{dest_name.camelize}::Concerns::Controller\n/
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def add_profile_url_helper
|
|
109
|
+
content = <<-RUBY.chomp
|
|
110
|
+
|
|
111
|
+
included do
|
|
112
|
+
helper_method :profile_url
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Returns the URL to the user's profile page.
|
|
118
|
+
def profile_url
|
|
119
|
+
profile = current_user.#{profile_association}
|
|
120
|
+
if profile
|
|
121
|
+
resource_url_for(profile)
|
|
122
|
+
else
|
|
123
|
+
resource_url_for(#{resource_class_name}, action: :new)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
RUBY
|
|
127
|
+
inject_into_file concerns_controller_path, content, after: /# add concerns above\.\n/
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def profile_association
|
|
131
|
+
resource_class_name.demodulize.underscore
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def resource_class_name
|
|
135
|
+
name.camelize
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def user_table
|
|
139
|
+
options[:user_model].underscore
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def dest_name
|
|
143
|
+
selected_destination_portal
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def concerns_controller_path
|
|
147
|
+
"packages/#{dest_name}/app/controllers/#{dest_name}/concerns/controller.rb"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def controller_path
|
|
151
|
+
"packages/#{dest_name}/app/controllers/#{dest_name}/#{resource_class_name.underscore.pluralize}_controller.rb"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def policy_path
|
|
155
|
+
"packages/#{dest_name}/app/policies/#{dest_name}/#{resource_class_name.underscore}_policy.rb"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def definition_path
|
|
159
|
+
"packages/#{dest_name}/app/definitions/#{dest_name}/#{resource_class_name.underscore}_definition.rb"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def selected_destination_portal
|
|
163
|
+
@selected_destination_portal ||= portal_option :dest, prompt: "Select destination portal"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|