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
|
@@ -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
|
|
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"
|