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
@@ -359,7 +359,7 @@ In your admin portal:
359
359
  ```ruby
360
360
  # Invites are scoped to the current entity
361
361
  # Admins see all pending invites for their organization
362
- Invites::UserInvite.pending.where(entity: current_entity)
362
+ Invites::UserInvite.pending.where(entity: current_scoped_entity)
363
363
  ```
364
364
 
365
365
  ## Security Considerations
@@ -410,6 +410,7 @@ Generate a complete multi-tenant SaaS setup with user, entity, and membership.
410
410
  rails generate pu:saas:setup --user Customer --entity Organization
411
411
  rails generate pu:saas:setup --user Customer --entity Organization --roles=member,admin,owner
412
412
  rails generate pu:saas:setup --user Customer --entity Organization --no-allow-signup
413
+ rails generate pu:saas:setup --user Customer --entity Organization --api_client ApiClient
413
414
  ```
414
415
 
415
416
  #### Options
@@ -425,12 +426,15 @@ rails generate pu:saas:setup --user Customer --entity Organization --no-allow-si
425
426
  | `--user-attributes` | Additional user model attributes |
426
427
  | `--entity-attributes` | Additional entity model attributes |
427
428
  | `--membership-attributes` | Additional membership model attributes |
429
+ | `--api_client NAME` | Generate an API client model for M2M auth |
430
+ | `--api_client_roles` | Roles for API client (default: read_only,write,admin) |
428
431
 
429
432
  Creates:
430
433
  - User account model with Rodauth authentication
431
434
  - Entity model with unique name
432
435
  - Membership join model with role enum
433
436
  - Has-many-through associations with `dependent: :destroy`
437
+ - (Optional) API client with HTTP Basic Auth, scoped to entity
434
438
 
435
439
  ### pu:saas:user
436
440
 
@@ -460,6 +464,45 @@ rails generate pu:saas:membership --user Customer --entity Organization
460
464
  rails generate pu:saas:membership --user Customer --entity Organization --roles=member,admin,owner
461
465
  ```
462
466
 
467
+ ### pu:saas:api_client
468
+
469
+ Generate an API client account for machine-to-machine authentication.
470
+
471
+ ```bash
472
+ rails generate pu:saas:api_client ApiClient
473
+ rails generate pu:saas:api_client ApiClient --entity=Organization
474
+ rails generate pu:saas:api_client ApiClient --entity=Organization --roles=read_only,write,admin
475
+ ```
476
+
477
+ #### Options
478
+
479
+ | Option | Description |
480
+ |--------|-------------|
481
+ | `--entity NAME` | Entity model to scope API clients to |
482
+ | `--roles` | Available roles (default: read_only,write,admin) |
483
+ | `--extra_attributes` | Additional model attributes |
484
+ | `--dest` | Destination package |
485
+
486
+ Creates:
487
+ - Rodauth account with HTTP Basic Auth (login + auto-generated password)
488
+ - Create and Disable interactions
489
+ - Rake task for CLI creation (`rake api_clients:create`)
490
+ - (If entity) Membership model with roles
491
+
492
+ #### Usage
493
+
494
+ ```bash
495
+ # Create via rake task
496
+ rake api_clients:create LOGIN=my-service
497
+
498
+ # With entity scoping
499
+ rake api_clients:create LOGIN=my-service ORGANIZATION=acme ROLE=write
500
+ ```
501
+
502
+ ::: tip Credentials
503
+ Credentials are displayed once on creation and cannot be retrieved later. The password is auto-generated using `SecureRandom.base64(32)`.
504
+ :::
505
+
463
506
  ## Core Generators
464
507
 
465
508
  ### pu:core:install
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.41.0)
4
+ plutonium (0.41.1)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 9.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.41.0)
4
+ plutonium (0.41.1)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 9.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.41.0)
4
+ plutonium (0.41.1)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 9.0)
@@ -3,6 +3,8 @@
3
3
  class <%= entity_model %>::InviteUserInteraction < Plutonium::Resource::Interaction
4
4
  include Plutonium::Invites::Concerns::InviteUser
5
5
 
6
+ presents label: "Invite <%= user_model.underscore.humanize.titleize %>", icon: Phlex::TablerIcons::Mail
7
+
6
8
  attribute :role
7
9
  input :role, as: :select, choices: Invites::UserInvite.roles.keys
8
10
  <% if membership_model != "EntityUser" || user_model != "User" -%>
@@ -3,13 +3,15 @@
3
3
  class <%= user_model %>::InviteUserInteraction < Plutonium::Resource::Interaction
4
4
  include Plutonium::Invites::Concerns::InviteUser
5
5
 
6
+ presents label: "Invite <%= user_model.underscore.humanize.titleize %>", icon: Phlex::TablerIcons::Mail
7
+
6
8
  attribute :role
7
9
  input :role, as: :select, choices: Invites::UserInvite.roles.keys
8
10
 
9
11
  private
10
12
 
11
13
  def entity
12
- current_entity
14
+ current_scoped_entity
13
15
  end
14
16
  <% if membership_model != "EntityUser" -%>
15
17
 
@@ -3,12 +3,14 @@
3
3
  class <%= model_class %>::InviteUserInteraction < Plutonium::Resource::Interaction
4
4
  include Plutonium::Invites::Concerns::InviteUser
5
5
 
6
+ presents label: "Invite <%= user_model.underscore.humanize.titleize %>", icon: Phlex::TablerIcons::Mail
7
+
6
8
  input :email
7
9
 
8
10
  private
9
11
 
10
12
  def entity
11
- current_entity
13
+ current_scoped_entity
12
14
  end
13
15
 
14
16
  def invitable
@@ -22,7 +22,7 @@ module PlutoniumGenerators
22
22
 
23
23
  def available_packages
24
24
  @available_packages ||= begin
25
- packages = Dir["packages/*"].map { |dir| dir.gsub "packages/", "" }
25
+ packages = Dir[Rails.root.join("packages", "*")].map { |dir| File.basename(dir) }
26
26
  packages - reserved_packages
27
27
  end
28
28
  end
@@ -6,12 +6,14 @@ require "securerandom"
6
6
  require "#{__dir__}/concerns/configuration"
7
7
  require "#{__dir__}/concerns/account_selector"
8
8
  require "#{__dir__}/concerns/feature_selector"
9
+ require "#{__dir__}/concerns/gem_helpers"
9
10
 
10
11
  module Pu
11
12
  module Rodauth
12
13
  class AccountGenerator < ::Rails::Generators::Base
13
14
  include Concerns::AccountSelector
14
15
  include Concerns::FeatureSelector
16
+ include Concerns::GemHelpers
15
17
 
16
18
  source_root "#{__dir__}/templates"
17
19
 
@@ -21,12 +23,20 @@ module Pu
21
23
  class_option :extra_attributes, type: :array, default: [],
22
24
  desc: "Additional attributes to add to the account model (e.g., role:integer)"
23
25
 
26
+ class_option :login_column, type: :string, default: "email",
27
+ desc: "Name of the login column (default: email)"
28
+
24
29
  def install_dependencies
30
+ gems = []
31
+ gems << "jwt" if jwt? || jwt_refresh?
32
+ gems << "rotp" if otp?
33
+ gems << "rqrcode" if otp?
34
+ gems << "webauthn" if webauthn? || webauthn_autofill?
35
+ gems = gems.reject { |g| gem_in_bundle?(g) }
36
+ return if gems.empty?
37
+
25
38
  Bundler.with_unbundled_env do
26
- run "bundle add jwt" if jwt? || jwt_refresh?
27
- run "bundle add rotp" if otp?
28
- run "bundle add rqrcode" if otp?
29
- run "bundle add webauthn" if webauthn? || webauthn_autofill?
39
+ gems.each { |gem| run "bundle add #{gem}" }
30
40
  end
31
41
  end
32
42
 
@@ -91,6 +101,7 @@ module Pu
91
101
  invoke "pu:rodauth:migration", [table], features: selected_migration_features,
92
102
  name: kitchen_sink? ? "rodauth_kitchen_sink" : nil,
93
103
  migration_name: options[:migration_name],
104
+ login_column: login_column,
94
105
  force: options[:force],
95
106
  skip: options[:skip]
96
107
 
@@ -113,7 +124,7 @@ module Pu
113
124
  return unless base?
114
125
 
115
126
  template "app/models/account.rb", "app/models/#{account_path}.rb"
116
- scaffold_attrs = ["email:string", "status:integer"] + Array(options[:extra_attributes])
127
+ scaffold_attrs = ["#{login_column}:string", "status:integer"] + Array(options[:extra_attributes])
117
128
  invoke "pu:res:scaffold", [table, *scaffold_attrs], dest: "main_app",
118
129
  model: false,
119
130
  force: true,
@@ -133,6 +144,10 @@ module Pu
133
144
  def only_json?
134
145
  ::Rails.application.config.api_only || !::Rails.application.config.session_store || options[:api_only]
135
146
  end
147
+
148
+ def login_column
149
+ options[:login_column] || "email"
150
+ end
136
151
  end
137
152
  end
138
153
  end
@@ -119,7 +119,7 @@ module Pu
119
119
  ].map { |feature| [feature, true] }.to_h
120
120
  end
121
121
 
122
- def display_name = name.humanize.downcase
122
+ def display_name = name.underscore.humanize.downcase
123
123
 
124
124
  def normalized_name = name.underscore
125
125
 
@@ -184,6 +184,7 @@ module Pu
184
184
  },
185
185
  jwt: {},
186
186
  json: {},
187
+ http_basic_auth: {},
187
188
  case_insensitive_login: {default: true},
188
189
  internal_request: {default: true}
189
190
  }.freeze
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pu
4
+ module Rodauth
5
+ module Concerns
6
+ module GemHelpers
7
+ private
8
+
9
+ def gem_in_bundle?(name)
10
+ in_root do
11
+ return true if File.exist?("Gemfile") && File.read("Gemfile").match?(/gem ['"]#{name}['"]/)
12
+ return true if File.exist?("Gemfile.lock") && File.read("Gemfile.lock").include?(" #{name} ")
13
+ end
14
+ false
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -2,20 +2,24 @@ require "rails/generators/base"
2
2
  require "rails/generators/active_record/migration"
3
3
  require "securerandom"
4
4
 
5
+ require "#{__dir__}/concerns/gem_helpers"
6
+
5
7
  module Pu
6
8
  module Rodauth
7
9
  class InstallGenerator < ::Rails::Generators::Base
8
10
  include ::ActiveRecord::Generators::Migration
11
+ include Concerns::GemHelpers
9
12
 
10
13
  source_root "#{__dir__}/templates"
11
14
 
12
15
  desc "Install rodauth-rails"
13
16
 
14
17
  def add_rodauth
18
+ gems = %w[bcrypt sequel-activerecord_connection tilt rodauth-rails].reject { |g| gem_in_bundle?(g) }
19
+ return if gems.empty?
20
+
15
21
  Bundler.with_unbundled_env do
16
- %w[bcrypt sequel-activerecord_connection tilt rodauth-rails].each do |gem|
17
- run "bundle add #{gem}"
18
- end
22
+ gems.each { |gem| run "bundle add #{gem}" }
19
23
  end
20
24
  end
21
25
 
@@ -2,15 +2,15 @@ create_table :<%= table_prefix.pluralize %><%= primary_key_type %> do |t|
2
2
  t.integer :status, null: false, default: 1
3
3
  <% case activerecord_adapter -%>
4
4
  <% when "postgresql" -%>
5
- t.citext :email, null: false
5
+ t.citext :<%= login_column %>, null: false
6
6
  <% else -%>
7
- t.string :email, null: false
7
+ t.string :<%= login_column %>, null: false
8
8
  <% end -%>
9
9
  <% case activerecord_adapter -%>
10
10
  <% when "postgresql", "sqlite3" -%>
11
- t.index :email, unique: true, where: "status IN (1, 2)"
11
+ t.index :<%= login_column %>, unique: true, where: "status IN (1, 2)"
12
12
  <% else -%>
13
- t.index :email, unique: true
13
+ t.index :<%= login_column %>, unique: true
14
14
  <% end -%>
15
15
  <% unless separate_passwords? -%>
16
16
  t.string :password_hash
@@ -27,6 +27,9 @@ module Pu
27
27
  class_option :features, required: true, type: :array,
28
28
  desc: "Rodauth features to create tables for (otp, sms_codes, single_session, account_expiration etc.)"
29
29
 
30
+ class_option :login_column, type: :string, default: "email",
31
+ desc: "Name of the login column (default: email)"
32
+
30
33
  def validate_selected_features
31
34
  if selected_features.empty?
32
35
  say "No migration features specified!", :yellow
@@ -119,6 +122,10 @@ module Pu
119
122
  selected_features.include? :separate_passwords
120
123
  end
121
124
 
125
+ def login_column
126
+ options[:login_column] || "email"
127
+ end
128
+
122
129
  def migration_chunk(feature)
123
130
  "#{MIGRATION_DIR}/#{feature}.erb"
124
131
  end
@@ -22,7 +22,7 @@ class <%= account_path.classify %> < ResourceRecord
22
22
 
23
23
  # add scopes above.
24
24
 
25
- validates :email, presence: true
25
+ validates :<%= login_column %>, presence: true
26
26
  # add validations above.
27
27
 
28
28
  # add callbacks above.
@@ -2,10 +2,11 @@ Description:
2
2
  SaaS generators for multi-tenant applications with user accounts, entities, and memberships.
3
3
 
4
4
  Generators:
5
- pu:saas:setup Complete SaaS setup (user + entity + membership)
5
+ pu:saas:setup Complete SaaS setup (user + entity + membership + optional api_client)
6
6
  pu:saas:user SaaS user account with Rodauth
7
7
  pu:saas:entity Entity/organization model
8
8
  pu:saas:membership Membership join table linking users to entities
9
+ pu:saas:api_client API client account for machine-to-machine auth
9
10
 
10
11
  Example:
11
12
  rails g pu:saas:setup --user Customer --entity Organization
@@ -15,8 +16,16 @@ Example:
15
16
  - Organization entity model with unique name
16
17
  - OrganizationCustomer membership model with role enum
17
18
 
19
+ With API client:
20
+ rails g pu:saas:setup --user Customer --entity Organization --api_client ApiClient
21
+
22
+ This also creates:
23
+ - ApiClient account with HTTP Basic Auth
24
+ - OrganizationApiClient membership model with roles
25
+
18
26
  See individual generator help for more options:
19
27
  rails g pu:saas:setup --help
20
28
  rails g pu:saas:user --help
21
29
  rails g pu:saas:entity --help
22
30
  rails g pu:saas:membership --help
31
+ rails g pu:saas:api_client --help
@@ -0,0 +1,32 @@
1
+ Description:
2
+ Generate an API client account for machine-to-machine authentication.
3
+
4
+ API clients use HTTP Basic Auth with application name (login) and
5
+ auto-generated password. Credentials are displayed once on creation
6
+ and cannot be reset - only disabled or deleted.
7
+
8
+ Example:
9
+ # Basic API client (platform-level)
10
+ rails generate pu:saas:api_client ApiClient
11
+
12
+ # API client scoped to an organization with roles
13
+ rails generate pu:saas:api_client ApiClient --entity=Organization
14
+
15
+ # Custom roles
16
+ rails generate pu:saas:api_client ApiClient --entity=Organization --roles=read_only,write,admin
17
+
18
+ # With extra attributes
19
+ rails generate pu:saas:api_client ApiClient --extra_attributes=description:string,rate_limit:integer
20
+
21
+ This will create:
22
+ - Rodauth account with HTTP Basic Auth
23
+ - Create and Disable interactions
24
+ - Rake task for CLI creation
25
+ - If --entity: membership model with roles
26
+
27
+ Usage:
28
+ # Create via rake task
29
+ rake api_clients:create LOGIN=my-service
30
+
31
+ # Or with entity
32
+ rake api_clients:create LOGIN=my-service ORGANIZATION=acme ROLE=write
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= name.classify %>::CreateInteraction < Plutonium::Resource::Interaction
4
+ include Plutonium::ApiClient::Concerns::CreateApiClient
5
+
6
+ <% if entity? -%>
7
+ attribute :<%= normalized_entity_name %>_id, :integer
8
+ attribute :role, :string, default: "<%= default_role %>"
9
+
10
+ validates :<%= normalized_entity_name %>_id, presence: true
11
+ validates :role, presence: true, inclusion: {in: <%= membership_model_name.classify %>.roles.keys}
12
+
13
+ # Only show entity picker if not in a scoped context
14
+ input :<%= normalized_entity_name %>_id, if: -> { scoped_entity.nil? } do |f|
15
+ choices = <%= normalized_entity_name.classify %>.pluck(:name, :id)
16
+ f.select_tag choices: choices
17
+ end
18
+ input :role, as: :select, choices: <%= membership_model_name.classify %>.roles.keys
19
+
20
+ def initialize(**)
21
+ super
22
+ self.<%= normalized_entity_name %>_id ||= scoped_entity&.id
23
+ end
24
+
25
+ <% end -%>
26
+ private
27
+
28
+ def rodauth_name
29
+ :<%= normalized_name %>
30
+ end
31
+
32
+ def api_client_class
33
+ <%= name.classify %>
34
+ end
35
+ <% if entity? -%>
36
+
37
+ def entity_class
38
+ <%= normalized_entity_name.classify %>
39
+ end
40
+
41
+ def membership_class
42
+ <%= membership_model_name.classify %>
43
+ end
44
+
45
+ def scoped_entity_id
46
+ <%= normalized_entity_name %>_id
47
+ end
48
+
49
+ # Custom credentials page with proper resource URL
50
+ class CredentialsPage < Plutonium::ApiClient::Concerns::CreateApiClient::CredentialsPage
51
+ def success_title
52
+ "<%= display_name.titleize %> Created Successfully"
53
+ end
54
+
55
+ def done_url
56
+ helpers.resource_url_for(<%= name.classify %>, parent: @parent)
57
+ end
58
+ end
59
+
60
+ def credentials_page_class
61
+ CredentialsPage
62
+ end
63
+ <% else -%>
64
+
65
+ # Custom credentials page with proper resource URL
66
+ class CredentialsPage < Plutonium::ApiClient::Concerns::CreateApiClient::CredentialsPage
67
+ def success_title
68
+ "<%= display_name.titleize %> Created Successfully"
69
+ end
70
+
71
+ def done_url
72
+ helpers.resource_url_for(<%= name.classify %>)
73
+ end
74
+ end
75
+
76
+ def credentials_page_class
77
+ CredentialsPage
78
+ end
79
+ <% end -%>
80
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= name.classify %>::DisableInteraction < Plutonium::Resource::Interaction
4
+ include Plutonium::ApiClient::Concerns::DisableApiClient
5
+
6
+ private
7
+
8
+ def rodauth_name
9
+ :<%= normalized_name %>
10
+ end
11
+
12
+ def success_message(login)
13
+ "<%= display_name.titleize %> '#{login}' has been disabled"
14
+ end
15
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :<%= normalized_name.pluralize %> do
4
+ desc "Create a new <%= display_name %>"
5
+ task create: :environment do
6
+ require "tty-prompt"
7
+
8
+ prompt = TTY::Prompt.new
9
+
10
+ login = ENV["LOGIN"] || prompt.ask("Application name:", required: true)
11
+ <% if entity? -%>
12
+ <%= normalized_entity_name %>_name = ENV["<%= normalized_entity_name.upcase %>"] || prompt.ask("<%= normalized_entity_name.titleize %> name:", required: true)
13
+ role = ENV["ROLE"] || prompt.select("Role:", %w[<%= roles.join(" ") %>])
14
+
15
+ <%= normalized_entity_name %> = <%= normalized_entity_name.classify %>.find_by!(name: <%= normalized_entity_name %>_name)
16
+ <% end -%>
17
+
18
+ password = SecureRandom.base64(32)
19
+
20
+ RodauthApp.rodauth(:<%= normalized_name %>).create_account(
21
+ login: login,
22
+ password: password
23
+ )
24
+ <% if entity? -%>
25
+
26
+ <%= normalized_name %> = <%= name.classify %>.find_by!(login: login)
27
+
28
+ <%= membership_model_name.classify %>.create!(
29
+ <%= normalized_entity_name %>: <%= normalized_entity_name %>,
30
+ <%= normalized_name %>: <%= normalized_name %>,
31
+ role: role
32
+ )
33
+ <% end -%>
34
+
35
+ puts
36
+ puts "=== <%= display_name.titleize %> Created ==="
37
+ puts
38
+ puts "Login: #{login}"
39
+ puts "Password: #{password}"
40
+ <% if entity? -%>
41
+ puts "<%= normalized_entity_name.titleize %>: #{<%= normalized_entity_name %>.name}"
42
+ puts "Role: #{role}"
43
+ <% end -%>
44
+ puts
45
+ puts "IMPORTANT: Save these credentials now. The password cannot be retrieved later."
46
+ puts
47
+ end
48
+ end