rsb-auth 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +15 -0
  3. data/README.md +76 -0
  4. data/Rakefile +25 -0
  5. data/app/controllers/concerns/rsb/auth/ensure_identity_complete.rb +72 -0
  6. data/app/controllers/concerns/rsb/auth/rate_limitable.rb +20 -0
  7. data/app/controllers/rsb/auth/account/login_methods_controller.rb +85 -0
  8. data/app/controllers/rsb/auth/account/sessions_controller.rb +31 -0
  9. data/app/controllers/rsb/auth/account_controller.rb +99 -0
  10. data/app/controllers/rsb/auth/admin/identities_controller.rb +402 -0
  11. data/app/controllers/rsb/auth/admin/sessions_management_controller.rb +27 -0
  12. data/app/controllers/rsb/auth/application_controller.rb +50 -0
  13. data/app/controllers/rsb/auth/invitations_controller.rb +31 -0
  14. data/app/controllers/rsb/auth/password_resets_controller.rb +46 -0
  15. data/app/controllers/rsb/auth/registrations_controller.rb +104 -0
  16. data/app/controllers/rsb/auth/sessions_controller.rb +109 -0
  17. data/app/controllers/rsb/auth/verifications_controller.rb +29 -0
  18. data/app/helpers/rsb/auth/user_agent_helper.rb +22 -0
  19. data/app/mailers/rsb/auth/application_mailer.rb +10 -0
  20. data/app/mailers/rsb/auth/auth_mailer.rb +33 -0
  21. data/app/models/rsb/auth/application_record.rb +10 -0
  22. data/app/models/rsb/auth/credential/email_password.rb +9 -0
  23. data/app/models/rsb/auth/credential/phone_password.rb +16 -0
  24. data/app/models/rsb/auth/credential/username_password.rb +9 -0
  25. data/app/models/rsb/auth/credential.rb +122 -0
  26. data/app/models/rsb/auth/identity.rb +62 -0
  27. data/app/models/rsb/auth/invitation.rb +55 -0
  28. data/app/models/rsb/auth/password_reset_token.rb +36 -0
  29. data/app/models/rsb/auth/session.rb +44 -0
  30. data/app/services/rsb/auth/account_service.rb +140 -0
  31. data/app/services/rsb/auth/authentication_service.rb +86 -0
  32. data/app/services/rsb/auth/invitation_service.rb +53 -0
  33. data/app/services/rsb/auth/password_reset_service.rb +48 -0
  34. data/app/services/rsb/auth/registration_service.rb +108 -0
  35. data/app/services/rsb/auth/session_service.rb +47 -0
  36. data/app/services/rsb/auth/verification_service.rb +30 -0
  37. data/app/views/layouts/rsb/auth/application.html.erb +76 -0
  38. data/app/views/rsb/auth/account/_identity_fields.html.erb +3 -0
  39. data/app/views/rsb/auth/account/confirm_destroy.html.erb +45 -0
  40. data/app/views/rsb/auth/account/login_methods/show.html.erb +92 -0
  41. data/app/views/rsb/auth/account/show.html.erb +110 -0
  42. data/app/views/rsb/auth/admin/credentials/_email_password.html.erb +34 -0
  43. data/app/views/rsb/auth/admin/credentials/_phone_password.html.erb +34 -0
  44. data/app/views/rsb/auth/admin/credentials/_username_password.html.erb +34 -0
  45. data/app/views/rsb/auth/admin/identities/index.html.erb +76 -0
  46. data/app/views/rsb/auth/admin/identities/new.html.erb +46 -0
  47. data/app/views/rsb/auth/admin/identities/new_credential.html.erb +45 -0
  48. data/app/views/rsb/auth/admin/identities/show.html.erb +180 -0
  49. data/app/views/rsb/auth/admin/sessions_management/index.html.erb +69 -0
  50. data/app/views/rsb/auth/auth_mailer/invitation.html.erb +4 -0
  51. data/app/views/rsb/auth/auth_mailer/password_reset.html.erb +4 -0
  52. data/app/views/rsb/auth/auth_mailer/verification.html.erb +4 -0
  53. data/app/views/rsb/auth/credentials/_email_password_login.html.erb +36 -0
  54. data/app/views/rsb/auth/credentials/_email_password_signup.html.erb +45 -0
  55. data/app/views/rsb/auth/credentials/_icon.html.erb +21 -0
  56. data/app/views/rsb/auth/credentials/_phone_password_login.html.erb +33 -0
  57. data/app/views/rsb/auth/credentials/_phone_password_signup.html.erb +45 -0
  58. data/app/views/rsb/auth/credentials/_selector.html.erb +43 -0
  59. data/app/views/rsb/auth/credentials/_username_password_login.html.erb +33 -0
  60. data/app/views/rsb/auth/credentials/_username_password_signup.html.erb +54 -0
  61. data/app/views/rsb/auth/invitations/show.html.erb +40 -0
  62. data/app/views/rsb/auth/password_resets/edit.html.erb +41 -0
  63. data/app/views/rsb/auth/password_resets/new.html.erb +27 -0
  64. data/app/views/rsb/auth/registrations/new.html.erb +55 -0
  65. data/app/views/rsb/auth/sessions/new.html.erb +47 -0
  66. data/config/locales/account.en.yml +65 -0
  67. data/config/locales/admin.en.yml +26 -0
  68. data/config/locales/credentials.en.yml +11 -0
  69. data/config/locales/seo.en.yml +28 -0
  70. data/config/routes.rb +34 -0
  71. data/db/migrate/20260208100001_create_rsb_auth_identities.rb +12 -0
  72. data/db/migrate/20260208100002_create_rsb_auth_credentials.rb +20 -0
  73. data/db/migrate/20260208100003_create_rsb_auth_sessions.rb +18 -0
  74. data/db/migrate/20260208100004_create_rsb_auth_password_reset_tokens.rb +15 -0
  75. data/db/migrate/20260208100005_add_verification_to_rsb_auth_credentials.rb +9 -0
  76. data/db/migrate/20260208100006_create_rsb_auth_invitations.rb +19 -0
  77. data/db/migrate/20260211100001_add_revoked_at_to_rsb_auth_credentials.rb +16 -0
  78. data/db/migrate/20260212100001_add_deleted_at_to_rsb_auth_identities.rb +10 -0
  79. data/db/migrate/20260214172956_add_recovery_email_to_rsb_auth_credentials.rb +8 -0
  80. data/lib/generators/rsb/auth/install/install_generator.rb +31 -0
  81. data/lib/generators/rsb/auth/views/views_generator.rb +197 -0
  82. data/lib/rsb/auth/configuration.rb +59 -0
  83. data/lib/rsb/auth/credential_conflict_error.rb +21 -0
  84. data/lib/rsb/auth/credential_definition.rb +39 -0
  85. data/lib/rsb/auth/credential_deprecation_bridge.rb +81 -0
  86. data/lib/rsb/auth/credential_registry.rb +96 -0
  87. data/lib/rsb/auth/credential_settings_registrar.rb +118 -0
  88. data/lib/rsb/auth/engine.rb +187 -0
  89. data/lib/rsb/auth/lifecycle_handler.rb +71 -0
  90. data/lib/rsb/auth/settings_schema.rb +74 -0
  91. data/lib/rsb/auth/test_helper.rb +139 -0
  92. data/lib/rsb/auth/version.rb +9 -0
  93. data/lib/rsb/auth.rb +49 -0
  94. metadata +192 -0
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ # Generator to export RSB Auth views to the host application for customization.
6
+ #
7
+ # Copies user-facing view files from the RSB Auth engine to the host app's
8
+ # `app/views/rsb/auth/` directory. Rails engine view lookup gives host app
9
+ # views priority over engine defaults, so copied views automatically override
10
+ # engine defaults without additional configuration.
11
+ #
12
+ # Admin views (identity management, sessions management) are NOT included —
13
+ # those are managed by the rsb-admin view generator.
14
+ #
15
+ # @example Export all auth views
16
+ # rails generate rsb:auth:views
17
+ #
18
+ # @example Export only login and registration views
19
+ # rails generate rsb:auth:views --only sessions,registrations
20
+ #
21
+ # @example Export mailer templates
22
+ # rails generate rsb:auth:views --only mailers
23
+ #
24
+ # @example Force overwrite existing files
25
+ # rails generate rsb:auth:views --force
26
+ #
27
+ class ViewsGenerator < Rails::Generators::Base
28
+ namespace 'rsb:auth:views'
29
+ desc 'Export RSB Auth views to your application for customization.'
30
+
31
+ class_option :only, type: :string, default: nil,
32
+ desc: 'Comma-separated list of view groups to export (sessions,registrations,account,verifications,password_resets,invitations,mailers,layout)'
33
+ class_option :force, type: :boolean, default: false,
34
+ desc: 'Overwrite existing files'
35
+
36
+ # Mapping of view groups to their file paths (relative to the engine views root).
37
+ # Each group corresponds to a controller's view directory.
38
+ # Admin views are intentionally excluded — use rsb:admin:views for those.
39
+ VIEW_GROUPS = {
40
+ 'sessions' => [
41
+ 'sessions/new.html.erb'
42
+ ],
43
+ 'registrations' => [
44
+ 'registrations/new.html.erb'
45
+ ],
46
+ 'account' => [
47
+ 'account/show.html.erb',
48
+ 'account/confirm_destroy.html.erb',
49
+ 'account/_identity_fields.html.erb'
50
+ ],
51
+ 'verifications' => [
52
+ 'verifications/show.html.erb'
53
+ ],
54
+ 'password_resets' => [
55
+ 'password_resets/new.html.erb',
56
+ 'password_resets/edit.html.erb'
57
+ ],
58
+ 'invitations' => [
59
+ 'invitations/show.html.erb'
60
+ ],
61
+ 'mailers' => [
62
+ 'auth_mailer/verification.html.erb',
63
+ 'auth_mailer/password_reset.html.erb',
64
+ 'auth_mailer/invitation.html.erb'
65
+ ],
66
+ 'layout' => [
67
+ 'layouts/rsb/auth/application.html.erb'
68
+ ]
69
+ }.freeze
70
+
71
+ # Copies the selected view files from the engine to the host application.
72
+ #
73
+ # Iterates through the selected view groups and copies each file.
74
+ # Existing files are skipped unless --force is used.
75
+ #
76
+ # @return [void]
77
+ def copy_views
78
+ groups = selected_groups
79
+
80
+ files_copied = 0
81
+ files_skipped = 0
82
+
83
+ groups.each_value do |paths|
84
+ paths.each do |relative_path|
85
+ source = source_path_for(relative_path)
86
+ destination = destination_path_for(relative_path)
87
+
88
+ unless File.exist?(source)
89
+ say_status :skip, "#{relative_path} (not found in engine)", :yellow
90
+ next
91
+ end
92
+
93
+ if File.exist?(destination) && !options[:force]
94
+ say_status :skip, "#{relative_path} (already exists, use --force to overwrite)", :yellow
95
+ files_skipped += 1
96
+ else
97
+ copy_file_with_status(source, destination)
98
+ files_copied += 1
99
+ end
100
+ end
101
+ end
102
+
103
+ say ''
104
+ say "Exported #{files_copied} view(s) to app/views/rsb/auth/", :green
105
+ say "Skipped #{files_skipped} existing file(s)." if files_skipped.positive?
106
+ end
107
+
108
+ # Prints instructions about the view override chain.
109
+ #
110
+ # @return [void]
111
+ def print_instructions
112
+ say ''
113
+ say 'View override chain (highest priority first):', :cyan
114
+ say ' 1. app/views/rsb/auth/ (your customizations)'
115
+ say ' 2. RSB Auth engine defaults (fallback)'
116
+ say ''
117
+ say 'Edit the exported files to customize your auth pages.'
118
+ say "Files you didn't export will continue using engine defaults."
119
+ say ''
120
+ end
121
+
122
+ private
123
+
124
+ # Returns the filtered set of view groups based on the --only option.
125
+ #
126
+ # When --only is provided, parses the comma-separated list and validates
127
+ # that all group names are recognized. Returns only the matching groups.
128
+ # When --only is not provided, returns all groups.
129
+ #
130
+ # @return [Hash{String => Array<String>}] filtered view groups
131
+ # @raise [SystemExit] if invalid group names are provided
132
+ def selected_groups
133
+ if options[:only]
134
+ keys = options[:only].split(',').map(&:strip)
135
+ invalid = keys - VIEW_GROUPS.keys
136
+ if invalid.any?
137
+ say_status :error,
138
+ "Unknown view group(s): #{invalid.join(', ')}. Valid groups: #{VIEW_GROUPS.keys.join(', ')}", :red
139
+ exit 1
140
+ end
141
+ VIEW_GROUPS.slice(*keys)
142
+ else
143
+ VIEW_GROUPS
144
+ end
145
+ end
146
+
147
+ # Returns the full source path for a given relative view path.
148
+ #
149
+ # Layout files live under app/views/layouts/ (not namespaced),
150
+ # while all other views live under app/views/rsb/auth/.
151
+ #
152
+ # @param relative_path [String] the relative path from VIEW_GROUPS
153
+ # @return [String] absolute path to the source file in the engine
154
+ def source_path_for(relative_path)
155
+ if relative_path.start_with?('layouts/')
156
+ RSB::Auth::Engine.root.join('app', 'views', relative_path).to_s
157
+ else
158
+ File.join(engine_views_root, relative_path)
159
+ end
160
+ end
161
+
162
+ # Returns the full destination path for a given relative view path.
163
+ #
164
+ # Layout files go to app/views/layouts/ (not namespaced),
165
+ # while all other views go to app/views/rsb/auth/.
166
+ #
167
+ # @param relative_path [String] the relative path from VIEW_GROUPS
168
+ # @return [String] absolute path to the destination file in the host app
169
+ def destination_path_for(relative_path)
170
+ if relative_path.start_with?('layouts/')
171
+ File.join(destination_root, 'app', 'views', relative_path)
172
+ else
173
+ File.join(destination_root, 'app', 'views', 'rsb', 'auth', relative_path)
174
+ end
175
+ end
176
+
177
+ # Returns the engine's namespaced views root directory.
178
+ #
179
+ # @return [String] absolute path to rsb/auth views in the engine
180
+ def engine_views_root
181
+ RSB::Auth::Engine.root.join('app', 'views', 'rsb', 'auth').to_s
182
+ end
183
+
184
+ # Copies a file and prints a create status message.
185
+ #
186
+ # @param source [String] absolute path to source file
187
+ # @param destination [String] absolute path to destination file
188
+ # @return [void]
189
+ def copy_file_with_status(source, destination)
190
+ FileUtils.mkdir_p(File.dirname(destination))
191
+ FileUtils.cp(source, destination)
192
+ relative = destination.sub("#{destination_root}/", '')
193
+ say_status :create, relative, :green
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ # Configuration for rsb-auth lifecycle handler and model concerns.
6
+ #
7
+ # @example Configure a custom handler and identity concerns
8
+ # RSB::Auth.configure do |config|
9
+ # config.lifecycle_handler = "MyApp::AuthLifecycleHandler"
10
+ # config.identity_concerns << MyApp::HasProfile
11
+ # config.credential_concerns << MyApp::HasTenant
12
+ # end
13
+ #
14
+ class Configuration
15
+ # @return [String, nil] fully-qualified class name of the lifecycle handler.
16
+ # When nil, the base {LifecycleHandler} null object is used.
17
+ attr_accessor :lifecycle_handler
18
+
19
+ # @return [Array<Module>] concern modules to include into Identity model.
20
+ # Applied in order during Rails +to_prepare+ block.
21
+ attr_reader :identity_concerns
22
+
23
+ # @return [Array<Module>] concern modules to include into Credential base model.
24
+ # Applied in order during Rails +to_prepare+ block. Inherited by all STI subtypes.
25
+ attr_reader :credential_concerns
26
+
27
+ # @return [Array<Symbol, Hash>] parameters permitted in account update form.
28
+ # Default: +[:metadata]+. Host app extends this for concern-added nested attributes.
29
+ #
30
+ # @example Permit nested profile attributes
31
+ # config.permitted_account_params = [:metadata, profile_attributes: [:first_name, :last_name]]
32
+ attr_accessor :permitted_account_params
33
+
34
+ def initialize
35
+ @lifecycle_handler = nil
36
+ @identity_concerns = []
37
+ @credential_concerns = []
38
+ @permitted_account_params = [:metadata]
39
+ end
40
+
41
+ # Resolves and instantiates the lifecycle handler.
42
+ #
43
+ # If {#lifecycle_handler} is set, constantizes the string and returns a new
44
+ # instance. If nil, returns a base {LifecycleHandler} (null object).
45
+ #
46
+ # The class name is resolved via +constantize+ on every call (not cached),
47
+ # which supports Rails code reloading in development.
48
+ #
49
+ # @return [RSB::Auth::LifecycleHandler] handler instance
50
+ def resolve_lifecycle_handler
51
+ if lifecycle_handler
52
+ lifecycle_handler.constantize.new
53
+ else
54
+ LifecycleHandler.new
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ # Raised when restoring a revoked credential would violate the active
6
+ # uniqueness constraint (another active credential has the same type + identifier).
7
+ #
8
+ # @example Handling the error
9
+ # begin
10
+ # credential.restore!
11
+ # rescue RSB::Auth::CredentialConflictError => e
12
+ # e.message # => "Cannot restore — another active credential with the same identifier exists."
13
+ # end
14
+ #
15
+ class CredentialConflictError < StandardError
16
+ def initialize(msg = 'Cannot restore — another active credential with the same identifier exists.')
17
+ super
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ class CredentialDefinition
6
+ attr_reader :key, :class_name, :authenticatable, :registerable, :label,
7
+ :icon, :form_partial, :redirect_url, :admin_form_partial
8
+
9
+ # @param key [Symbol, String] unique identifier for the credential type
10
+ # @param class_name [String] fully-qualified class name of the credential model
11
+ # @param authenticatable [Boolean] whether this credential can be used to sign in (default: true)
12
+ # @param registerable [Boolean] whether new users can register with this credential (default: true)
13
+ # @param label [String, nil] human-readable label (defaults to titleized key)
14
+ # @param icon [String, nil] icon name for UI display (e.g. "mail", "phone", "user")
15
+ # @param form_partial [String, nil] Rails partial path for the credential's login/register form
16
+ # @param redirect_url [String, nil] redirect URL for OAuth/redirect-based credential flows
17
+ def initialize(key:, class_name:, authenticatable: true, registerable: true, label: nil,
18
+ icon: nil, form_partial: nil, redirect_url: nil, admin_form_partial: nil)
19
+ @key = key.to_sym
20
+ @class_name = class_name.to_s
21
+ @authenticatable = authenticatable
22
+ @registerable = registerable
23
+ @label = label || key.to_s.titleize
24
+ @icon = icon
25
+ @form_partial = form_partial
26
+ @redirect_url = redirect_url
27
+ @admin_form_partial = admin_form_partial
28
+ end
29
+
30
+ def credential_class
31
+ @class_name.constantize
32
+ end
33
+
34
+ def valid?
35
+ key.present? && class_name.present?
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ # Provides backward compatibility between the old `auth.login_identifier`
6
+ # setting and the new per-credential `auth.credentials.<key>.enabled` settings.
7
+ #
8
+ # When per-credential settings are NOT explicitly set in the database,
9
+ # this bridge reads `auth.login_identifier` and infers which types should
10
+ # be enabled. When per-credential settings ARE explicitly set, this bridge
11
+ # is bypassed entirely — the explicit settings win.
12
+ #
13
+ # @see SRS-004 US-006 (Deprecate login_identifier)
14
+ class CredentialDeprecationBridge
15
+ IDENTIFIER_MAP = {
16
+ 'email' => { email_password: true, phone_password: false, username_password: false },
17
+ 'phone' => { email_password: false, phone_password: true, username_password: false },
18
+ 'username' => { email_password: false, phone_password: false, username_password: true }
19
+ }.freeze
20
+
21
+ class << self
22
+ # Returns the enabled/disabled map for a given login_identifier value.
23
+ #
24
+ # @param identifier [String] "email", "phone", or "username"
25
+ # @return [Hash<Symbol, Boolean>]
26
+ def enabled_map_for(identifier)
27
+ IDENTIFIER_MAP.fetch(identifier.to_s, IDENTIFIER_MAP['email'])
28
+ end
29
+
30
+ # Checks whether any per-credential enabled setting has been explicitly
31
+ # set in the database (not just relying on the default).
32
+ #
33
+ # @return [Boolean]
34
+ def per_credential_settings_explicit?
35
+ RSB::Auth.credentials.all.any? do |defn|
36
+ RSB::Settings::Setting.exists?(
37
+ category: 'auth',
38
+ key: "credentials.#{defn.key}.enabled"
39
+ )
40
+ end
41
+ rescue StandardError
42
+ false
43
+ end
44
+
45
+ # Resolves the deprecated login_identifier into per-credential settings.
46
+ # Only called when per-credential settings are NOT explicitly set.
47
+ # Logs a deprecation warning.
48
+ #
49
+ # @return [void]
50
+ def resolve_from_login_identifier
51
+ identifier = RSB::Settings.get('auth.login_identifier')
52
+ fire_deprecation(
53
+ 'auth.login_identifier is deprecated. Use auth.credentials.<key>.enabled settings instead. ' \
54
+ "Current value '#{identifier}' maps to: #{enabled_map_for(identifier).inspect}"
55
+ )
56
+ end
57
+
58
+ # Register a deprecation handler (for testing).
59
+ # @param block [Proc]
60
+ def on_deprecation(&block)
61
+ @deprecation_handler = block
62
+ end
63
+
64
+ # Clear the deprecation handler (for test teardown).
65
+ def clear_deprecation_handler
66
+ @deprecation_handler = nil
67
+ end
68
+
69
+ private
70
+
71
+ def fire_deprecation(message)
72
+ if @deprecation_handler
73
+ @deprecation_handler.call(message)
74
+ elsif defined?(Rails) && Rails.logger
75
+ Rails.logger.warn("[RSB::Auth DEPRECATION] #{message}")
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ class CredentialRegistry
6
+ attr_reader :definitions
7
+
8
+ def initialize
9
+ @definitions = {}
10
+ end
11
+
12
+ def register(definition)
13
+ unless definition.is_a?(CredentialDefinition)
14
+ raise ArgumentError, "Expected CredentialDefinition, got #{definition.class}"
15
+ end
16
+
17
+ @definitions[definition.key] = definition
18
+ end
19
+
20
+ def find(key)
21
+ @definitions[key.to_sym]
22
+ end
23
+
24
+ def all
25
+ @definitions.values
26
+ end
27
+
28
+ def authenticatable
29
+ @definitions.values.select(&:authenticatable)
30
+ end
31
+
32
+ def registerable
33
+ @definitions.values.select(&:registerable)
34
+ end
35
+
36
+ def for_identifier(identifier_type)
37
+ # Find the default credential type for a given login identifier
38
+ key = :"#{identifier_type}_password"
39
+ @definitions[key]
40
+ end
41
+
42
+ def keys
43
+ @definitions.keys
44
+ end
45
+
46
+ # Returns only credential definitions that are currently enabled via settings.
47
+ # A credential type is enabled if `auth.credentials.<key>.enabled` resolves to `true`.
48
+ # If no setting exists for the credential type, it defaults to enabled (true).
49
+ #
50
+ # @return [Array<CredentialDefinition>]
51
+ def enabled
52
+ @definitions.values.select { |defn| credential_enabled?(defn.key) }
53
+ end
54
+
55
+ # Returns keys of enabled credential types.
56
+ #
57
+ # @return [Array<Symbol>]
58
+ def enabled_keys
59
+ enabled.map(&:key)
60
+ end
61
+
62
+ # Checks if a specific credential type is enabled.
63
+ # Returns false if the credential type is not registered.
64
+ #
65
+ # @param key [Symbol, String] credential type key
66
+ # @return [Boolean]
67
+ def enabled?(key)
68
+ defn = @definitions[key.to_sym]
69
+ return false unless defn
70
+
71
+ credential_enabled?(defn.key)
72
+ end
73
+
74
+ private
75
+
76
+ # Reads the enabled setting for a credential type.
77
+ # Falls back to true if no setting is registered (backward compat / custom types
78
+ # that haven't registered their enabled setting yet).
79
+ #
80
+ # @param key [Symbol] credential type key
81
+ # @return [Boolean]
82
+ def credential_enabled?(key)
83
+ setting_key = "auth.credentials.#{key}.enabled"
84
+ value = begin
85
+ RSB::Settings.get(setting_key)
86
+ rescue StandardError
87
+ nil
88
+ end
89
+ # If no setting registered, default to true (backward compat)
90
+ return true if value.nil?
91
+
92
+ ActiveModel::Type::Boolean.new.cast(value)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ # Handles registration of per-credential-type enabled settings
6
+ # and validation callbacks (e.g., preventing disabling the last type).
7
+ #
8
+ # Called from the engine initializer after credential types are registered.
9
+ class CredentialSettingsRegistrar
10
+ # Auto-registers `auth.credentials.<key>.enabled` settings for all
11
+ # currently registered credential types.
12
+ #
13
+ # @return [void]
14
+ def self.register_enabled_settings
15
+ definitions = RSB::Auth.credentials.all
16
+ return if definitions.empty?
17
+
18
+ schema = RSB::Settings::Schema.new('auth') do
19
+ definitions.each do |defn|
20
+ setting :"credentials.#{defn.key}.enabled",
21
+ type: :boolean,
22
+ default: true,
23
+ group: 'Credential Types',
24
+ label: defn.label,
25
+ description: "Enable or disable #{defn.label} as a sign-in method"
26
+
27
+ setting :"credentials.#{defn.key}.verification_required",
28
+ type: :boolean,
29
+ default: true,
30
+ group: 'Credential Types',
31
+ label: "#{defn.label} — Verification Required",
32
+ depends_on: "auth.credentials.#{defn.key}.enabled",
33
+ description: "Require email verification before login for #{defn.label}"
34
+
35
+ setting :"credentials.#{defn.key}.auto_verify_on_signup",
36
+ type: :boolean,
37
+ default: false,
38
+ group: 'Credential Types',
39
+ label: "#{defn.label} — Auto-verify on Signup",
40
+ depends_on: "auth.credentials.#{defn.key}.enabled",
41
+ description: "Auto-verify credentials at registration time for #{defn.label}"
42
+
43
+ setting :"credentials.#{defn.key}.allow_login_unverified",
44
+ type: :boolean,
45
+ default: false,
46
+ group: 'Credential Types',
47
+ label: "#{defn.label} — Allow Login Unverified",
48
+ depends_on: "auth.credentials.#{defn.key}.enabled",
49
+ description: "Allow login without verification for #{defn.label}"
50
+
51
+ setting :"credentials.#{defn.key}.registerable",
52
+ type: :boolean,
53
+ default: true,
54
+ group: 'Credential Types',
55
+ label: "#{defn.label} — Self-registration",
56
+ depends_on: "auth.credentials.#{defn.key}.enabled",
57
+ description: "Allow self-registration for #{defn.label}"
58
+ end
59
+ end
60
+
61
+ RSB::Settings.registry.register(schema)
62
+ end
63
+
64
+ # Registers on_change callbacks that prevent disabling the last
65
+ # enabled credential type. For each credential type, when its
66
+ # enabled setting changes to false, check that at least one other
67
+ # type remains enabled.
68
+ #
69
+ # @return [void]
70
+ def self.register_last_type_validation
71
+ RSB::Auth.credentials.all.each do |defn|
72
+ setting_key = "auth.credentials.#{defn.key}.enabled"
73
+ RSB::Settings.registry.on_change(setting_key) do |_old_value, new_value|
74
+ # Only check when disabling (new_value is falsy)
75
+ is_disabling = !ActiveModel::Type::Boolean.new.cast(new_value)
76
+ if is_disabling
77
+ # Count how many OTHER types are still enabled
78
+ other_enabled = RSB::Auth.credentials.all.count do |other|
79
+ next false if other.key == defn.key
80
+
81
+ RSB::Auth.credentials.enabled?(other.key)
82
+ end
83
+
84
+ if other_enabled.zero?
85
+ raise RSB::Settings::ValidationError,
86
+ "Cannot disable #{defn.label}: at least one credential type must remain enabled."
87
+ end
88
+ end
89
+ end
90
+
91
+ # Mutual exclusion: auto_verify_on_signup and verification_required cannot both be true
92
+ auto_verify_key = "auth.credentials.#{defn.key}.auto_verify_on_signup"
93
+ verification_key = "auth.credentials.#{defn.key}.verification_required"
94
+
95
+ RSB::Settings.registry.on_change(auto_verify_key) do |_old_value, new_value|
96
+ if ActiveModel::Type::Boolean.new.cast(new_value)
97
+ verif = RSB::Settings.get(verification_key)
98
+ if ActiveModel::Type::Boolean.new.cast(verif)
99
+ raise RSB::Settings::ValidationError,
100
+ "Cannot enable auto-verify when verification is required for #{defn.label}. Disable verification_required first."
101
+ end
102
+ end
103
+ end
104
+
105
+ RSB::Settings.registry.on_change(verification_key) do |_old_value, new_value|
106
+ if ActiveModel::Type::Boolean.new.cast(new_value)
107
+ auto = RSB::Settings.get(auto_verify_key)
108
+ if ActiveModel::Type::Boolean.new.cast(auto)
109
+ raise RSB::Settings::ValidationError,
110
+ "Cannot enable verification_required when auto-verify is enabled for #{defn.label}. Disable auto_verify_on_signup first."
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end