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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/app/assets/plutonium.css +2 -2
  4. data/app/assets/plutonium.js +46 -1
  5. data/app/assets/plutonium.js.map +4 -4
  6. data/app/assets/plutonium.min.js +32 -32
  7. data/app/assets/plutonium.min.js.map +4 -4
  8. data/docs/guides/user-invites.md +1 -1
  9. data/docs/reference/generators/index.md +43 -0
  10. data/gemfiles/rails_7.gemfile.lock +1 -1
  11. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  12. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  13. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +2 -0
  14. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +3 -1
  15. data/lib/generators/pu/invites/templates/invitable/invite_user_interaction.rb.tt +3 -1
  16. data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +1 -1
  17. data/lib/generators/pu/rodauth/account_generator.rb +20 -5
  18. data/lib/generators/pu/rodauth/admin_generator.rb +1 -1
  19. data/lib/generators/pu/rodauth/concerns/configuration.rb +1 -0
  20. data/lib/generators/pu/rodauth/concerns/gem_helpers.rb +19 -0
  21. data/lib/generators/pu/rodauth/install_generator.rb +7 -3
  22. data/lib/generators/pu/rodauth/migration/active_record/base.erb +4 -4
  23. data/lib/generators/pu/rodauth/migration_generator.rb +7 -0
  24. data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +1 -1
  25. data/lib/generators/pu/saas/USAGE +10 -1
  26. data/lib/generators/pu/saas/api_client/USAGE +32 -0
  27. data/lib/generators/pu/saas/api_client/templates/app/interactions/create_interaction.rb.tt +80 -0
  28. data/lib/generators/pu/saas/api_client/templates/app/interactions/disable_interaction.rb.tt +15 -0
  29. data/lib/generators/pu/saas/api_client/templates/lib/tasks/api_client.rake.tt +48 -0
  30. data/lib/generators/pu/saas/api_client_generator.rb +254 -0
  31. data/lib/generators/pu/saas/entity_generator.rb +5 -3
  32. data/lib/generators/pu/saas/membership_generator.rb +21 -9
  33. data/lib/generators/pu/saas/setup_generator.rb +23 -0
  34. data/lib/plutonium/api_client/concerns/create_api_client.rb +256 -0
  35. data/lib/plutonium/api_client/concerns/disable_api_client.rb +64 -0
  36. data/lib/plutonium/api_client.rb +21 -0
  37. data/lib/plutonium/interaction/concerns/scoping.rb +68 -0
  38. data/lib/plutonium/interaction/response/render.rb +16 -1
  39. data/lib/plutonium/invites/concerns/invite_user.rb +1 -1
  40. data/lib/plutonium/version.rb +1 -1
  41. data/package.json +1 -1
  42. data/src/js/controllers/clipboard_controller.js +37 -0
  43. data/src/js/controllers/register_controllers.js +2 -0
  44. data/src/js/controllers/remote_modal_controller.js +18 -4
  45. 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[File.join(migration_dir, "*_create_#{normalized_name.pluralize}.rb")].first
35
+ migration_file = Dir[Rails.root.join(migration_dir, "*_create_#{normalized_name.pluralize}.rb")].first
36
36
 
37
- return unless migration_file && File.exist?(migration_file)
37
+ return unless migration_file
38
38
 
39
- insert_into_file migration_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[File.join(migration_dir, "*_create_#{membership_table_name}.rb")].first
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