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.
Files changed (77) 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 +42 -0
  8. data/app/assets/plutonium.css +2 -2
  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/scopes_bar.rb +2 -74
  71. data/lib/plutonium/ui/table/components/selection_column.rb +6 -2
  72. data/lib/plutonium/ui/table/resource.rb +3 -2
  73. data/lib/plutonium/version.rb +1 -1
  74. data/lib/tasks/release.rake +6 -6
  75. data/package.json +1 -1
  76. metadata +17 -3
  77. data/lib/generators/pu/rodauth/concerns/gem_helpers.rb +0 -19
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Core
7
+ class TypespecGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ desc "Generate TypeSpec API specifications from Plutonium resources"
13
+
14
+ class_option :output, type: :string, default: "typespec", desc: "Output directory for TypeSpec files"
15
+ class_option :portal, type: :string, desc: "Generate specs for a specific portal only"
16
+
17
+ def start
18
+ load_application
19
+ check_pending_migrations!
20
+ detect_portals
21
+ generate_typespec_files
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :portals
27
+
28
+ def load_application
29
+ say_status :loading, "Rails application", :blue
30
+ Rails.application.eager_load!
31
+ Rails.application.reload_routes!
32
+ end
33
+
34
+ def check_pending_migrations!
35
+ context = ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths)
36
+ pending = context.migrations.select { |m| !context.get_all_versions.include?(m.version) }
37
+
38
+ return if pending.empty?
39
+
40
+ say_status :error, "Pending migrations detected!", :red
41
+ pending.each do |migration|
42
+ say_status :pending, "#{migration.version} - #{migration.name}", :yellow
43
+ end
44
+ say ""
45
+ say "Run `bin/rails db:migrate` before generating TypeSpec specifications.", :red
46
+ raise Thor::Error, "Cannot generate TypeSpec with pending migrations"
47
+ end
48
+
49
+ def detect_portals
50
+ say_status :detecting, "portals", :blue
51
+ @portals = []
52
+
53
+ Rails::Engine.subclasses.each do |engine|
54
+ next unless engine.included_modules.any? { |m| m.name&.include?("Plutonium::Portal") }
55
+
56
+ portal_name = engine.name.sub(/::Engine$/, "")
57
+ route_path = find_engine_route_path(engine)
58
+
59
+ next if options[:portal] && portal_name.underscore != options[:portal].underscore
60
+
61
+ @portals << build_portal_data(engine, portal_name, route_path)
62
+ end
63
+
64
+ if @portals.empty?
65
+ say_status :warning, "No portals found#{" matching '#{options[:portal]}'" if options[:portal]}", :yellow
66
+ else
67
+ @portals.each do |portal|
68
+ say_status :found, "#{portal[:name]} (#{portal[:resources].size} resources)", :green
69
+ end
70
+ end
71
+ end
72
+
73
+ def find_engine_route_path(engine)
74
+ Rails.application.routes.routes.find do |route|
75
+ route.app.app == engine
76
+ end&.path&.spec&.to_s&.gsub(/\(\.:format\)$/, "")
77
+ end
78
+
79
+ def build_portal_data(engine, portal_name, route_path)
80
+ resources = []
81
+
82
+ engine.resource_register.resources.each do |resource|
83
+ route_config = engine.routes.resource_route_config_lookup[resource.model_name.plural]
84
+ next unless route_config
85
+
86
+ resource_data = build_resource_data(resource, route_config, portal_name, route_path)
87
+ resources << resource_data if resource_data
88
+ end
89
+
90
+ {
91
+ name: portal_name,
92
+ engine: engine,
93
+ route_path: route_path || "/#{portal_name.underscore}",
94
+ file_name: portal_name.underscore.tr("/", "_"),
95
+ resources: resources
96
+ }
97
+ end
98
+
99
+ def build_resource_data(resource, route_config, portal_name, portal_route_path)
100
+ return nil unless resource.table_exists?
101
+
102
+ # Try portal-specific definition first, then fall back to base definition
103
+ definition_class = safe_constantize("#{portal_name}::#{resource.name.demodulize}Definition") ||
104
+ safe_constantize("#{resource.name}Definition")
105
+
106
+ resource_path = route_config[:route_options][:path]
107
+ full_route = [portal_route_path, resource_path].compact.join("/").gsub(%r{//+}, "/")
108
+
109
+ {
110
+ name: resource.name,
111
+ typespec_name: resource.name.demodulize,
112
+ file_name: resource.name.underscore.tr("/", "_"),
113
+ table_name: resource.table_name,
114
+ route_path: full_route,
115
+ primary_key: resource.primary_key,
116
+ primary_key_type: primary_key_type(resource),
117
+ columns: build_columns_data(resource),
118
+ associations: build_associations_data(resource),
119
+ enums: build_enums_data(resource),
120
+ definition: definition_class ? build_definition_data(definition_class, resource) : nil
121
+ }
122
+ rescue NameError => e
123
+ say_status :skip, "#{resource.name}: #{e.message}", :yellow
124
+ nil
125
+ end
126
+
127
+ def primary_key_type(resource)
128
+ column_to_typespec_type(resource.columns.find { |c| c.name == resource.primary_key })
129
+ end
130
+
131
+ def column_to_typespec_type(column)
132
+ return "int64" unless column
133
+ TYPE_MAPPING[column.type] || "int64"
134
+ end
135
+
136
+ def build_columns_data(resource)
137
+ resource.columns.map do |column|
138
+ {
139
+ name: column.name,
140
+ type: column.type.to_s,
141
+ null: column.null,
142
+ default: column.default,
143
+ typespec_type: TYPE_MAPPING[column.type] || "string"
144
+ }
145
+ end
146
+ end
147
+
148
+ def build_associations_data(resource)
149
+ resource.reflect_on_all_associations.map do |assoc|
150
+ fk_type = resolve_foreign_key_type(assoc, resource)
151
+
152
+ {
153
+ name: assoc.name.to_s,
154
+ macro: assoc.macro.to_s,
155
+ class_name: safe_association_attr(assoc, :klass)&.name,
156
+ foreign_key: safe_association_attr(assoc, :foreign_key),
157
+ foreign_key_type: fk_type,
158
+ polymorphic: !!assoc.polymorphic?
159
+ }
160
+ end
161
+ end
162
+
163
+ def resolve_foreign_key_type(assoc, resource)
164
+ return "int64" if assoc.polymorphic?
165
+
166
+ # For belongs_to, look up the associated model's primary key type
167
+ if assoc.macro == :belongs_to
168
+ target_class = safe_association_attr(assoc, :klass)
169
+ if target_class&.table_exists?
170
+ pk_column = target_class.columns.find { |c| c.name == target_class.primary_key }
171
+ return column_to_typespec_type(pk_column)
172
+ end
173
+ end
174
+
175
+ # For has_many/has_one, use this resource's primary key type
176
+ primary_key_type(resource)
177
+ end
178
+
179
+ def safe_association_attr(assoc, attr)
180
+ assoc.public_send(attr)
181
+ rescue NameError
182
+ nil
183
+ end
184
+
185
+ def build_enums_data(resource)
186
+ return {} unless resource.respond_to?(:defined_enums)
187
+ resource.defined_enums.transform_values(&:keys)
188
+ end
189
+
190
+ def build_definition_data(definition_class, resource)
191
+ inputs = extract_inputs(definition_class, resource)
192
+
193
+ {
194
+ class_name: definition_class.name,
195
+ inputs: inputs
196
+ }
197
+ end
198
+
199
+ def extract_inputs(definition_class, resource)
200
+ return [] unless definition_class.respond_to?(:defined_inputs)
201
+
202
+ defined_inputs = definition_class.defined_inputs
203
+ return [] if defined_inputs.empty?
204
+
205
+ defined_inputs.map do |name, config|
206
+ input_config = config[:options] || {}
207
+ column = resource.columns_hash[name.to_s]
208
+ assoc = resource.reflect_on_association(name)
209
+
210
+ {
211
+ name: name.to_s,
212
+ as: input_config[:as]&.to_s,
213
+ required: !input_config[:optional],
214
+ type: determine_input_type(name, input_config, column, assoc, resource),
215
+ typespec_type: determine_typespec_input_type(name, input_config, column, assoc, resource),
216
+ is_association: assoc.present?,
217
+ is_polymorphic: assoc&.polymorphic?,
218
+ association_macro: assoc&.macro&.to_s,
219
+ nested: input_config[:nested].present?
220
+ }
221
+ end
222
+ end
223
+
224
+ def determine_input_type(name, config, column, assoc, resource)
225
+ return config[:as].to_s if config[:as]
226
+ return "association" if assoc
227
+ return "enum" if resource.defined_enums.key?(name.to_s)
228
+ return column.type.to_s if column
229
+ "string"
230
+ end
231
+
232
+ def determine_typespec_input_type(name, config, column, assoc, resource)
233
+ # Associations use SGIDs
234
+ if assoc
235
+ return "SignedGlobalId[]" if %i[has_many has_and_belongs_to_many].include?(assoc.macro)
236
+ return "SignedGlobalId"
237
+ end
238
+
239
+ # Enums use the enum type
240
+ return "#{resource.name.demodulize}#{name.to_s.camelize}" if resource.defined_enums.key?(name.to_s)
241
+
242
+ # Use column type
243
+ return TYPE_MAPPING[column.type] || "string" if column
244
+
245
+ # Infer from as: option
246
+ AS_TYPE_MAPPING[config[:as]&.to_sym] || "string"
247
+ end
248
+
249
+ def generate_typespec_files
250
+ say_status :generating, "TypeSpec files", :blue
251
+
252
+ empty_directory output_dir
253
+ @single_portal = @portals.size == 1
254
+ template "common.tsp.tt", "#{output_dir}/common.tsp"
255
+
256
+ if @single_portal
257
+ # Single portal - generate flat structure
258
+ @current_portal = @portals.first
259
+ generate_portal_files(@current_portal, output_dir)
260
+ else
261
+ # Multiple portals - generate per-portal directories
262
+ @portals.each do |portal|
263
+ portal_dir = "#{output_dir}/#{portal[:file_name]}"
264
+ empty_directory portal_dir
265
+ generate_portal_files(portal, portal_dir)
266
+ end
267
+
268
+ # Generate root main.tsp that imports all portals
269
+ template "main_multi.tsp.tt", "#{output_dir}/main.tsp"
270
+ end
271
+
272
+ say_status :complete, "TypeSpec files generated in #{output_dir}/", :green
273
+ end
274
+
275
+ def generate_portal_files(portal, dir)
276
+ @current_portal = portal
277
+ template "main.tsp.tt", "#{dir}/main.tsp"
278
+
279
+ empty_directory "#{dir}/models"
280
+
281
+ portal[:resources].each do |resource|
282
+ @current_resource = resource
283
+ template "model.tsp.tt", "#{dir}/models/#{resource[:file_name]}.tsp"
284
+ end
285
+ end
286
+
287
+ def output_dir
288
+ options[:output]
289
+ end
290
+
291
+ def safe_constantize(name)
292
+ name.constantize
293
+ rescue NameError
294
+ nil
295
+ end
296
+
297
+ TYPE_MAPPING = {
298
+ string: "string",
299
+ text: "string",
300
+ integer: "int32",
301
+ bigint: "int64",
302
+ float: "float64",
303
+ decimal: "decimal",
304
+ boolean: "boolean",
305
+ date: "plainDate",
306
+ datetime: "utcDateTime",
307
+ time: "plainTime",
308
+ binary: "bytes",
309
+ json: "Record<string, unknown>",
310
+ jsonb: "Record<string, unknown>",
311
+ uuid: "string",
312
+ hstore: "Record<string, string>"
313
+ }.freeze
314
+
315
+ AS_TYPE_MAPPING = {
316
+ text: "string",
317
+ textarea: "string",
318
+ markdown: "string",
319
+ rich_text: "string",
320
+ number: "int32",
321
+ integer: "int32",
322
+ decimal: "decimal",
323
+ boolean: "boolean",
324
+ checkbox: "boolean",
325
+ date: "plainDate",
326
+ datetime: "utcDateTime",
327
+ time: "plainTime",
328
+ file: "bytes",
329
+ attachment: "bytes",
330
+ email: "string",
331
+ url: "url",
332
+ phone: "string",
333
+ password: "string",
334
+ color: "string",
335
+ json: "Record<string, unknown>",
336
+ jsonb: "Record<string, unknown>",
337
+ hstore: "Record<string, string>",
338
+ key_value: "Record<string, string>"
339
+ }.freeze
340
+ end
341
+ end
342
+ end
@@ -18,7 +18,6 @@ Options:
18
18
  --entity-model=NAME Entity model name for scoping (default: Entity)
19
19
  --user-model=NAME User model name (default: User)
20
20
  --membership-model=NAME Membership model name (default: <Entity>User)
21
- --roles=ROLES Comma-separated roles (default: member,admin)
22
21
  --rodauth=NAME Rodauth configuration for signup (default: user)
23
22
  --enforce-domain Require user email domain to match entity domain
24
23
 
@@ -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. Ensure #{entity_model} and #{user_model} resources exist."
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
- template "app/interactions/invite_user_interaction.rb",
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) || current_membership&.admin?\n end\n\n ",
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) || current_membership&.admin?\n end\n\n ",
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
- cache(entity_scope, user, :current_membership) { #{membership_model}.find_by(#{entity_table}: entity_scope, user: user) }
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
- "app/models/#{entity_table}.rb"
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
- "app/definitions/#{entity_table}_definition.rb"
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
- "app/policies/#{entity_table}_policy.rb"
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 roles
344
- Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
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
- def roles_enum
348
- roles.each_with_index.map { |r, i| "#{r}: #{i}" }.join(", ")
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 :<%= entity_table %>, null: false, foreign_key: true
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 [:<%= entity_table %>_id, :email], unique: true, where: "state = 0",
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)
@@ -4,7 +4,9 @@ module Invites
4
4
  class UserInvitationsController < ApplicationController
5
5
  include Plutonium::Invites::Controller
6
6
 
7
+ prepend_view_path Invites::Engine.root.join("app/views")
7
8
  layout "invites/invitation"
9
+ helper_method :login_path
8
10
 
9
11
  private
10
12
 
@@ -4,6 +4,7 @@ module Invites
4
4
  class WelcomeController < ApplicationController
5
5
  include Plutonium::Invites::PendingInviteCheck
6
6
 
7
+ prepend_view_path Invites::Engine.root.join("app/views")
7
8
  layout "invites/invitation"
8
9
 
9
10
  <% if rodauth? -%>
@@ -4,11 +4,11 @@ module Invites
4
4
  class UserInvite < Invites::ResourceRecord
5
5
  include Plutonium::Invites::Concerns::InviteToken
6
6
 
7
- enum :role, <%= roles_enum %>
7
+ enum :role, <%= membership_model %>.roles
8
8
 
9
9
  encrypts :token, deterministic: true
10
10
 
11
- belongs_to :<%= entity_table %>
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!(<%= entity_table %>: <%= entity_table %>, user: user, role: role)
33
+ <%= membership_model %>.create!(<%= entity_association_name %>: <%= entity_association_name %>, user: user, role: role)
34
34
  end
35
- <% if entity_table != "entity" -%>
35
+ <% if entity_association_name != "entity" -%>
36
36
 
37
37
  # Alias for InviteToken concern compatibility
38
- alias_method :entity, :<%= entity_table %>
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?(/gem ['"]#{name}['"]/)
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"