plutonium 0.41.1 → 0.42.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/CHANGELOG.md +25 -0
- data/app/assets/plutonium.css +2 -2
- data/app/assets/plutonium.js +46 -1
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +32 -32
- data/app/assets/plutonium.min.js.map +4 -4
- data/docs/guides/user-invites.md +1 -1
- data/docs/reference/generators/index.md +43 -0
- 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/invites/templates/app/interactions/invite_user_interaction.rb.tt +2 -0
- data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +3 -1
- data/lib/generators/pu/invites/templates/invitable/invite_user_interaction.rb.tt +3 -1
- data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +1 -1
- data/lib/generators/pu/rodauth/account_generator.rb +20 -5
- data/lib/generators/pu/rodauth/admin_generator.rb +1 -1
- data/lib/generators/pu/rodauth/concerns/configuration.rb +1 -0
- data/lib/generators/pu/rodauth/concerns/gem_helpers.rb +19 -0
- data/lib/generators/pu/rodauth/install_generator.rb +7 -3
- data/lib/generators/pu/rodauth/migration/active_record/base.erb +4 -4
- data/lib/generators/pu/rodauth/migration_generator.rb +7 -0
- data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +1 -1
- data/lib/generators/pu/saas/USAGE +10 -1
- data/lib/generators/pu/saas/api_client/USAGE +32 -0
- data/lib/generators/pu/saas/api_client/templates/app/interactions/create_interaction.rb.tt +80 -0
- data/lib/generators/pu/saas/api_client/templates/app/interactions/disable_interaction.rb.tt +15 -0
- data/lib/generators/pu/saas/api_client/templates/lib/tasks/api_client.rake.tt +48 -0
- data/lib/generators/pu/saas/api_client_generator.rb +254 -0
- data/lib/generators/pu/saas/entity_generator.rb +5 -3
- data/lib/generators/pu/saas/membership_generator.rb +21 -9
- data/lib/generators/pu/saas/setup_generator.rb +23 -0
- data/lib/plutonium/api_client/concerns/create_api_client.rb +256 -0
- data/lib/plutonium/api_client/concerns/disable_api_client.rb +64 -0
- data/lib/plutonium/api_client.rb +21 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +68 -0
- data/lib/plutonium/interaction/response/render.rb +16 -1
- data/lib/plutonium/invites/concerns/invite_user.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/js/controllers/clipboard_controller.js +37 -0
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/remote_modal_controller.js +18 -4
- metadata +13 -2
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(Rodauth::Rails)
|
|
4
|
+
|
|
5
|
+
require "rails/generators/named_base"
|
|
6
|
+
require_relative "../lib/plutonium_generators"
|
|
7
|
+
|
|
8
|
+
module Pu
|
|
9
|
+
module Saas
|
|
10
|
+
class ApiClientGenerator < ::Rails::Generators::NamedBase
|
|
11
|
+
include PlutoniumGenerators::Generator
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path("api_client/templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Generate an API client account with optional entity scoping"
|
|
16
|
+
|
|
17
|
+
class_option :entity, type: :string,
|
|
18
|
+
desc: "Entity model to scope API clients to (e.g., Organization)"
|
|
19
|
+
|
|
20
|
+
class_option :roles, type: :array, default: %w[read_only write admin],
|
|
21
|
+
desc: "Available roles for API client memberships"
|
|
22
|
+
|
|
23
|
+
class_option :extra_attributes, type: :array, default: [],
|
|
24
|
+
desc: "Additional attributes for the API client model"
|
|
25
|
+
|
|
26
|
+
def start
|
|
27
|
+
generate_api_client_account
|
|
28
|
+
configure_api_client_account
|
|
29
|
+
generate_membership if entity?
|
|
30
|
+
create_interactions
|
|
31
|
+
create_rake_task
|
|
32
|
+
rescue => e
|
|
33
|
+
exception "#{self.class} failed:", e
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def generate_api_client_account
|
|
39
|
+
invoke "pu:rodauth:account", [name],
|
|
40
|
+
defaults: false,
|
|
41
|
+
mails: false,
|
|
42
|
+
login_column: "login",
|
|
43
|
+
**api_features,
|
|
44
|
+
extra_attributes: options[:extra_attributes],
|
|
45
|
+
force: options[:force],
|
|
46
|
+
skip: options[:skip]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def configure_api_client_account
|
|
50
|
+
plugin_file = "app/rodauth/#{normalized_name}_rodauth_plugin.rb"
|
|
51
|
+
|
|
52
|
+
# Block web signup - internal_request only
|
|
53
|
+
insert_into_file plugin_file, indent(<<~RUBY, 4), after: /# ==> Hooks\n/
|
|
54
|
+
|
|
55
|
+
# API clients can only be created programmatically
|
|
56
|
+
before_create_account_route do
|
|
57
|
+
request.halt unless internal_request?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
RUBY
|
|
61
|
+
|
|
62
|
+
# Use login instead of email
|
|
63
|
+
insert_into_file plugin_file, indent(<<~RUBY, 4), after: /# ==> General\n/
|
|
64
|
+
# Use login field for application name (not email)
|
|
65
|
+
login_column :login
|
|
66
|
+
login_label "Application Name"
|
|
67
|
+
require_login_confirmation? false
|
|
68
|
+
login_confirm_label nil
|
|
69
|
+
|
|
70
|
+
# Don't require email format for login
|
|
71
|
+
require_email_address_logins? false
|
|
72
|
+
|
|
73
|
+
RUBY
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def generate_membership
|
|
77
|
+
invoke "pu:res:model", [membership_model_name, *membership_attributes],
|
|
78
|
+
dest: selected_destination_feature,
|
|
79
|
+
force: options[:force],
|
|
80
|
+
skip: options[:skip]
|
|
81
|
+
|
|
82
|
+
add_unique_index_to_migration
|
|
83
|
+
add_default_to_role_column
|
|
84
|
+
add_role_enum_to_model
|
|
85
|
+
add_unique_validation_to_model
|
|
86
|
+
add_associations_to_models
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def create_interactions
|
|
90
|
+
template "app/interactions/create_interaction.rb.tt",
|
|
91
|
+
"app/interactions/#{normalized_name}/create_interaction.rb"
|
|
92
|
+
|
|
93
|
+
template "app/interactions/disable_interaction.rb.tt",
|
|
94
|
+
"app/interactions/#{normalized_name}/disable_interaction.rb"
|
|
95
|
+
|
|
96
|
+
inject_definition_actions
|
|
97
|
+
inject_policy_methods
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def create_rake_task
|
|
101
|
+
template "lib/tasks/api_client.rake.tt",
|
|
102
|
+
"lib/tasks/#{normalized_name}.rake"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def inject_definition_actions
|
|
106
|
+
definition_file = "app/definitions/#{normalized_name}_definition.rb"
|
|
107
|
+
|
|
108
|
+
inject_into_file definition_file, indent(<<~RUBY, 2), after: /class #{name.classify}Definition < .+\n/
|
|
109
|
+
action :register, interaction: #{name.classify}::CreateInteraction, collection: true, category: :primary
|
|
110
|
+
action :disable, interaction: #{name.classify}::DisableInteraction, category: :danger
|
|
111
|
+
|
|
112
|
+
RUBY
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def inject_policy_methods
|
|
116
|
+
policy_file = "app/policies/#{normalized_name}_policy.rb"
|
|
117
|
+
|
|
118
|
+
# Uncomment and modify create? to return false (update? inherits from create?)
|
|
119
|
+
gsub_file policy_file,
|
|
120
|
+
/ # def create\?\n # true\n # end/,
|
|
121
|
+
<<~RUBY.chomp
|
|
122
|
+
def create?
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def register?
|
|
127
|
+
true
|
|
128
|
+
end
|
|
129
|
+
RUBY
|
|
130
|
+
|
|
131
|
+
# Add disable? method
|
|
132
|
+
inject_into_file policy_file, <<~RUBY, before: /^\s*# Core attributes/
|
|
133
|
+
def disable?
|
|
134
|
+
# Can only disable verified (active) accounts
|
|
135
|
+
record.status == "verified"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
RUBY
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Membership helpers (similar to pu:saas:membership but simpler)
|
|
142
|
+
|
|
143
|
+
def add_unique_index_to_migration
|
|
144
|
+
migration_file = find_migration_file
|
|
145
|
+
return unless migration_file
|
|
146
|
+
|
|
147
|
+
insert_into_file migration_file,
|
|
148
|
+
indent("add_index :#{membership_table_name}, [:#{normalized_entity_name}_id, :#{normalized_name}_id], unique: true\n", 4),
|
|
149
|
+
before: /^ end\s*$/
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def add_default_to_role_column
|
|
153
|
+
migration_file = find_migration_file
|
|
154
|
+
return unless migration_file
|
|
155
|
+
|
|
156
|
+
gsub_file migration_file,
|
|
157
|
+
/t\.integer :role, null: false/,
|
|
158
|
+
"t.integer :role, null: false, default: 0"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def add_role_enum_to_model
|
|
162
|
+
model_file = File.join("app", "models", "#{membership_model_name}.rb")
|
|
163
|
+
return unless File.exist?(Rails.root.join(model_file))
|
|
164
|
+
|
|
165
|
+
inject_into_file model_file,
|
|
166
|
+
indent("enum :role, {#{roles_enum}}\n", 2),
|
|
167
|
+
before: /^\s*# add enums above\./
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def add_unique_validation_to_model
|
|
171
|
+
model_file = File.join("app", "models", "#{membership_model_name}.rb")
|
|
172
|
+
return unless File.exist?(Rails.root.join(model_file))
|
|
173
|
+
|
|
174
|
+
validation = "validates :#{normalized_name}, uniqueness: {scope: :#{normalized_entity_name}_id, message: \"is already registered for this #{normalized_entity_name.humanize.downcase}\"}\n"
|
|
175
|
+
inject_into_file model_file, indent(validation, 2), before: /^\s*# add validations above\./
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def add_associations_to_models
|
|
179
|
+
# Add to entity model
|
|
180
|
+
entity_model_path = File.join("app", "models", "#{normalized_entity_name}.rb")
|
|
181
|
+
if File.exist?(Rails.root.join(entity_model_path))
|
|
182
|
+
associations = <<~RUBY
|
|
183
|
+
has_many :#{membership_table_name}, dependent: :destroy
|
|
184
|
+
has_many :#{normalized_name.pluralize}, through: :#{membership_table_name}
|
|
185
|
+
RUBY
|
|
186
|
+
inject_into_file entity_model_path, indent(associations, 2), before: /^\s*# add has_many associations above\.\n/
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Add to API client model
|
|
190
|
+
api_client_model_path = File.join("app", "models", "#{normalized_name}.rb")
|
|
191
|
+
if File.exist?(Rails.root.join(api_client_model_path))
|
|
192
|
+
associations = <<~RUBY
|
|
193
|
+
has_many :#{membership_table_name}, dependent: :destroy
|
|
194
|
+
has_many :#{normalized_entity_name.pluralize}, through: :#{membership_table_name}
|
|
195
|
+
RUBY
|
|
196
|
+
inject_into_file api_client_model_path, indent(associations, 2), before: /^\s*# add has_many associations above\.\n/
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def find_migration_file
|
|
201
|
+
Dir[Rails.root.join("db", "migrate", "*_create_#{membership_table_name}.rb")].first
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Feature configuration
|
|
205
|
+
|
|
206
|
+
def api_features
|
|
207
|
+
{
|
|
208
|
+
base: true,
|
|
209
|
+
create_account: true,
|
|
210
|
+
internal_request: true,
|
|
211
|
+
http_basic_auth: true,
|
|
212
|
+
close_account: true,
|
|
213
|
+
case_insensitive_login: true
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Naming helpers
|
|
218
|
+
|
|
219
|
+
def normalized_name = name.underscore
|
|
220
|
+
|
|
221
|
+
def display_name = name.underscore.humanize.downcase
|
|
222
|
+
|
|
223
|
+
def entity? = options[:entity].present?
|
|
224
|
+
|
|
225
|
+
def normalized_entity_name = options[:entity]&.underscore
|
|
226
|
+
|
|
227
|
+
def membership_model_name = "#{normalized_entity_name}_#{normalized_name}"
|
|
228
|
+
|
|
229
|
+
def membership_table_name = membership_model_name.pluralize
|
|
230
|
+
|
|
231
|
+
def membership_attributes
|
|
232
|
+
[
|
|
233
|
+
"#{normalized_entity_name}:references",
|
|
234
|
+
"#{normalized_name}:references",
|
|
235
|
+
"role:integer"
|
|
236
|
+
]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def roles
|
|
240
|
+
Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def roles_enum
|
|
244
|
+
roles.each_with_index.map { |r, i| "#{r}: #{i}" }.join(", ")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def default_role = roles.first
|
|
248
|
+
|
|
249
|
+
def selected_destination_feature
|
|
250
|
+
feature_option :dest, prompt: "Select destination feature"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -32,11 +32,13 @@ module Pu
|
|
|
32
32
|
|
|
33
33
|
def add_unique_index_to_migration
|
|
34
34
|
migration_dir = File.join("db", "migrate")
|
|
35
|
-
migration_file = Dir[
|
|
35
|
+
migration_file = Dir[Rails.root.join(migration_dir, "*_create_#{normalized_name.pluralize}.rb")].first
|
|
36
36
|
|
|
37
|
-
return unless migration_file
|
|
37
|
+
return unless migration_file
|
|
38
38
|
|
|
39
|
-
insert_into_file
|
|
39
|
+
# Convert to relative path for insert_into_file
|
|
40
|
+
relative_path = migration_file.sub("#{Rails.root}/", "")
|
|
41
|
+
insert_into_file relative_path,
|
|
40
42
|
indent("add_index :#{normalized_name.pluralize}, :name, unique: true\n", 4),
|
|
41
43
|
before: /^ end\s*$/
|
|
42
44
|
end
|
|
@@ -30,6 +30,7 @@ module Pu
|
|
|
30
30
|
add_role_enum_to_model
|
|
31
31
|
add_unique_validation_to_model
|
|
32
32
|
add_associations_to_models
|
|
33
|
+
add_associated_with_scope_to_entity
|
|
33
34
|
rescue => e
|
|
34
35
|
exception "#{self.class} failed:", e
|
|
35
36
|
end
|
|
@@ -40,11 +41,11 @@ module Pu
|
|
|
40
41
|
user_model_path = File.join("app", "models", "#{normalized_user_name}.rb")
|
|
41
42
|
entity_model_path = File.join("app", "models", "#{normalized_entity_name}.rb")
|
|
42
43
|
|
|
43
|
-
unless File.exist?(user_model_path)
|
|
44
|
+
unless File.exist?(Rails.root.join(user_model_path))
|
|
44
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]}"
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
unless File.exist?(entity_model_path)
|
|
48
|
+
unless File.exist?(Rails.root.join(entity_model_path))
|
|
48
49
|
raise "Entity model '#{normalized_entity_name}' does not exist at #{entity_model_path}. Please create it first with: rails g pu:saas:entity #{options[:entity]}"
|
|
49
50
|
end
|
|
50
51
|
end
|
|
@@ -79,7 +80,7 @@ module Pu
|
|
|
79
80
|
def add_role_enum_to_model
|
|
80
81
|
model_file = File.join("app", "models", "#{membership_model_name}.rb")
|
|
81
82
|
|
|
82
|
-
return unless File.exist?(model_file)
|
|
83
|
+
return unless File.exist?(Rails.root.join(model_file))
|
|
83
84
|
|
|
84
85
|
enum_definition = "enum :role, #{roles_enum}\n"
|
|
85
86
|
insert_into_file model_file, indent(enum_definition, 2), before: /^\s*# add enums above\./
|
|
@@ -88,7 +89,7 @@ module Pu
|
|
|
88
89
|
def add_unique_validation_to_model
|
|
89
90
|
model_file = File.join("app", "models", "#{membership_model_name}.rb")
|
|
90
91
|
|
|
91
|
-
return unless File.exist?(model_file)
|
|
92
|
+
return unless File.exist?(Rails.root.join(model_file))
|
|
92
93
|
|
|
93
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"
|
|
94
95
|
insert_into_file model_file, indent(validation, 2), before: /^\s*# add validations above\./
|
|
@@ -102,30 +103,41 @@ module Pu
|
|
|
102
103
|
def add_association_to_entity_model
|
|
103
104
|
entity_model_path = File.join("app", "models", "#{normalized_entity_name}.rb")
|
|
104
105
|
|
|
105
|
-
return unless File.exist?(entity_model_path)
|
|
106
|
+
return unless File.exist?(Rails.root.join(entity_model_path))
|
|
106
107
|
|
|
107
108
|
associations = <<~RUBY
|
|
108
109
|
has_many :#{membership_table_name}, dependent: :destroy
|
|
109
110
|
has_many :#{normalized_user_name.pluralize}, through: :#{membership_table_name}
|
|
110
111
|
RUBY
|
|
111
|
-
inject_into_file entity_model_path, associations, before: /^\s*# add has_many associations above\.\n/
|
|
112
|
+
inject_into_file entity_model_path, indent(associations, 2), before: /^\s*# add has_many associations above\.\n/
|
|
112
113
|
end
|
|
113
114
|
|
|
114
115
|
def add_association_to_user_model
|
|
115
116
|
user_model_path = File.join("app", "models", "#{normalized_user_name}.rb")
|
|
116
117
|
|
|
117
|
-
return unless File.exist?(user_model_path)
|
|
118
|
+
return unless File.exist?(Rails.root.join(user_model_path))
|
|
118
119
|
|
|
119
120
|
associations = <<~RUBY
|
|
120
121
|
has_many :#{membership_table_name}, dependent: :destroy
|
|
121
122
|
has_many :#{normalized_entity_name.pluralize}, through: :#{membership_table_name}
|
|
122
123
|
RUBY
|
|
123
|
-
inject_into_file user_model_path, associations, before: /^\s*# add has_many associations above\.\n/
|
|
124
|
+
inject_into_file user_model_path, indent(associations, 2), before: /^\s*# add has_many associations above\.\n/
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def add_associated_with_scope_to_entity
|
|
128
|
+
entity_model_path = File.join("app", "models", "#{normalized_entity_name}.rb")
|
|
129
|
+
|
|
130
|
+
return unless File.exist?(Rails.root.join(entity_model_path))
|
|
131
|
+
|
|
132
|
+
scope_code = <<~RUBY
|
|
133
|
+
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}) }
|
|
134
|
+
RUBY
|
|
135
|
+
inject_into_file entity_model_path, indent(scope_code, 2), before: /^\s*# add scopes above\./
|
|
124
136
|
end
|
|
125
137
|
|
|
126
138
|
def find_migration_file
|
|
127
139
|
migration_dir = File.join("db", "migrate")
|
|
128
|
-
Dir[
|
|
140
|
+
Dir[Rails.root.join(migration_dir, "*_create_#{membership_table_name}.rb")].first
|
|
129
141
|
end
|
|
130
142
|
|
|
131
143
|
def membership_model_name
|
|
@@ -39,10 +39,17 @@ module Pu
|
|
|
39
39
|
class_option :membership_attributes, type: :array, default: [],
|
|
40
40
|
desc: "Additional attributes for the membership model"
|
|
41
41
|
|
|
42
|
+
class_option :api_client, type: :string, default: nil,
|
|
43
|
+
desc: "Generate an API client model (e.g., ApiClient)"
|
|
44
|
+
|
|
45
|
+
class_option :api_client_roles, type: :array, default: %w[read_only write admin],
|
|
46
|
+
desc: "Available roles for API client memberships"
|
|
47
|
+
|
|
42
48
|
def start
|
|
43
49
|
generate_user
|
|
44
50
|
generate_entity unless options[:skip_entity]
|
|
45
51
|
generate_membership unless options[:skip_membership]
|
|
52
|
+
generate_api_client if options[:api_client].present?
|
|
46
53
|
rescue => e
|
|
47
54
|
exception "#{self.class} failed:", e
|
|
48
55
|
end
|
|
@@ -93,6 +100,22 @@ module Pu
|
|
|
93
100
|
}
|
|
94
101
|
).invoke_all
|
|
95
102
|
end
|
|
103
|
+
|
|
104
|
+
def generate_api_client
|
|
105
|
+
# Use class-based invocation to avoid Thor's invoke caching
|
|
106
|
+
klass = Rails::Generators.find_by_namespace("pu:saas:api_client")
|
|
107
|
+
api_client_options = {
|
|
108
|
+
roles: options[:api_client_roles],
|
|
109
|
+
dest: options[:dest],
|
|
110
|
+
force: options[:force],
|
|
111
|
+
skip: options[:skip]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Scope to entity if entity is being generated
|
|
115
|
+
api_client_options[:entity] = options[:entity] unless options[:skip_entity]
|
|
116
|
+
|
|
117
|
+
klass.new([options[:api_client]], api_client_options).invoke_all
|
|
118
|
+
end
|
|
96
119
|
end
|
|
97
120
|
end
|
|
98
121
|
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module ApiClient
|
|
5
|
+
module Concerns
|
|
6
|
+
# CreateApiClient provides the core logic for creating API client accounts.
|
|
7
|
+
#
|
|
8
|
+
# Include this in your CreateInteraction and implement the required methods.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# class ApiClient::CreateInteraction < Plutonium::Resource::Interaction
|
|
12
|
+
# include Plutonium::ApiClient::Concerns::CreateApiClient
|
|
13
|
+
#
|
|
14
|
+
# input :role, as: :select, choices: OrganizationApiClient.roles.keys
|
|
15
|
+
#
|
|
16
|
+
# def membership_class
|
|
17
|
+
# OrganizationApiClient
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# def role
|
|
21
|
+
# attributes[:role] || "read_only"
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
module CreateApiClient
|
|
26
|
+
extend ActiveSupport::Concern
|
|
27
|
+
include Plutonium::Interaction::Concerns::Scoping
|
|
28
|
+
|
|
29
|
+
included do
|
|
30
|
+
presents label: "Create API Client", icon: Phlex::TablerIcons::Key
|
|
31
|
+
|
|
32
|
+
attribute :login, :string
|
|
33
|
+
|
|
34
|
+
validates :login, presence: true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute
|
|
38
|
+
password = generate_secure_password
|
|
39
|
+
|
|
40
|
+
rodauth_instance.create_account(
|
|
41
|
+
login: login,
|
|
42
|
+
password: password
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Rodauth internal_request returns nil, so we need to find the account
|
|
46
|
+
api_client = api_client_class.find_by!(login: login)
|
|
47
|
+
|
|
48
|
+
create_membership!(api_client) if entity_scoped_api_client?
|
|
49
|
+
|
|
50
|
+
succeed(api_client).with_render_response(
|
|
51
|
+
credentials_page_class.new(
|
|
52
|
+
login: api_client.login,
|
|
53
|
+
password: password,
|
|
54
|
+
parent: scoped_parent
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
rescue ActiveRecord::RecordNotFound => e
|
|
58
|
+
failed(login: "Failed to create account: #{e.message}")
|
|
59
|
+
rescue => e
|
|
60
|
+
failed(login: e.message)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Override to specify the Rodauth configuration name
|
|
66
|
+
# @return [Symbol]
|
|
67
|
+
def rodauth_name
|
|
68
|
+
raise NotImplementedError, "#{self.class}#rodauth_name must return the Rodauth configuration name (e.g., :api_client)"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Override to specify the API client model class
|
|
72
|
+
# @return [Class]
|
|
73
|
+
def api_client_class
|
|
74
|
+
raise NotImplementedError, "#{self.class}#api_client_class must return the API client model class"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Override to specify the entity model class for scoping
|
|
78
|
+
# @return [Class, nil]
|
|
79
|
+
def entity_class
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Override to specify the membership model class
|
|
84
|
+
# @return [Class, nil]
|
|
85
|
+
def membership_class
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Override to specify the role to assign
|
|
90
|
+
# @return [String, Symbol, nil]
|
|
91
|
+
def role
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Override to add additional attributes when creating the membership
|
|
96
|
+
# @return [Hash]
|
|
97
|
+
def additional_membership_attributes
|
|
98
|
+
{}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Override to customize the credentials page class
|
|
102
|
+
# @return [Class]
|
|
103
|
+
def credentials_page_class
|
|
104
|
+
CredentialsPage
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Override to customize password generation
|
|
108
|
+
# @return [String]
|
|
109
|
+
def generate_secure_password
|
|
110
|
+
SecureRandom.base64(32)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def rodauth_instance
|
|
114
|
+
RodauthApp.rodauth(rodauth_name)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def entity_scoped_api_client?
|
|
118
|
+
entity_class.present? && membership_class.present? && scoped_entity_id.present?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def scoped_entity
|
|
122
|
+
return unless entity_class
|
|
123
|
+
|
|
124
|
+
scoped_record_of_type(entity_class)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def scoped_entity_id
|
|
128
|
+
scoped_entity&.id
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def create_membership!(api_client)
|
|
132
|
+
attrs = {
|
|
133
|
+
entity_foreign_key => scoped_entity_id,
|
|
134
|
+
api_client_foreign_key => api_client.id,
|
|
135
|
+
**additional_membership_attributes
|
|
136
|
+
}
|
|
137
|
+
attrs[:role] = role if role.present?
|
|
138
|
+
|
|
139
|
+
membership_class.create!(attrs)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def entity_foreign_key
|
|
143
|
+
:"#{entity_class.model_name.singular}_id"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def api_client_foreign_key
|
|
147
|
+
:"#{api_client_class.model_name.singular}_id"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Default credentials page - can be overridden
|
|
151
|
+
class CredentialsPage < Plutonium::UI::Page::Base
|
|
152
|
+
def initialize(login:, password:, parent: nil)
|
|
153
|
+
@login = login
|
|
154
|
+
@password = password
|
|
155
|
+
@parent = parent
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def view_template
|
|
159
|
+
div(class: "max-w-2xl mx-auto py-8") do
|
|
160
|
+
render_success_banner
|
|
161
|
+
render_credentials_card
|
|
162
|
+
render_action_buttons
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def render_success_banner
|
|
169
|
+
div(class: "bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6 mb-6") do
|
|
170
|
+
div(class: "flex items-center gap-3 mb-4") do
|
|
171
|
+
render_check_icon
|
|
172
|
+
h2(class: "text-xl font-semibold text-green-800 dark:text-green-200") { success_title }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
p(class: "text-green-700 dark:text-green-300") do
|
|
176
|
+
strong { "Important: " }
|
|
177
|
+
plain "Save these credentials now. The password cannot be retrieved later."
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def render_credentials_card
|
|
183
|
+
div(class: "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 space-y-4") do
|
|
184
|
+
render_credential_field("Login", @login)
|
|
185
|
+
render_credential_field("Password", @password)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def render_credential_field(label, value)
|
|
190
|
+
div(class: "space-y-1", data: {controller: "clipboard"}) do
|
|
191
|
+
label(class: "block text-sm font-medium text-gray-700 dark:text-gray-300") { label }
|
|
192
|
+
div(class: "flex items-center gap-2") do
|
|
193
|
+
input(
|
|
194
|
+
type: "text",
|
|
195
|
+
value: value,
|
|
196
|
+
readonly: true,
|
|
197
|
+
data: {clipboard_target: "source"},
|
|
198
|
+
class: "flex-1 px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-md font-mono text-sm select-all focus:ring-2 focus:ring-primary-500"
|
|
199
|
+
)
|
|
200
|
+
button(
|
|
201
|
+
type: "button",
|
|
202
|
+
data: {action: "clipboard#copy"},
|
|
203
|
+
class: "px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
|
204
|
+
) { "Copy" }
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def render_action_buttons
|
|
210
|
+
credentials_text = "Login: #{@login}\nPassword: #{@password}"
|
|
211
|
+
|
|
212
|
+
div(class: "mt-6 flex gap-4", data: {controller: "clipboard"}) do
|
|
213
|
+
input(type: "hidden", value: credentials_text, data: {clipboard_target: "source"})
|
|
214
|
+
button(
|
|
215
|
+
type: "button",
|
|
216
|
+
data: {action: "clipboard#copy"},
|
|
217
|
+
class: "px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md transition-colors"
|
|
218
|
+
) { "Copy All" }
|
|
219
|
+
|
|
220
|
+
a(
|
|
221
|
+
href: done_url,
|
|
222
|
+
class: "px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-md transition-colors"
|
|
223
|
+
) { "Done" }
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def render_check_icon
|
|
228
|
+
svg(
|
|
229
|
+
class: "w-8 h-8 text-green-600 dark:text-green-400",
|
|
230
|
+
fill: "none",
|
|
231
|
+
stroke: "currentColor",
|
|
232
|
+
viewBox: "0 0 24 24"
|
|
233
|
+
) do |s|
|
|
234
|
+
s.path(
|
|
235
|
+
stroke_linecap: "round",
|
|
236
|
+
stroke_linejoin: "round",
|
|
237
|
+
stroke_width: "2",
|
|
238
|
+
d: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Override in subclass to customize
|
|
244
|
+
def success_title
|
|
245
|
+
"API Client Created Successfully"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Override in subclass to customize the done URL
|
|
249
|
+
def done_url
|
|
250
|
+
helpers.url_for(action: :index)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|