aikotoba 0.2.0 → 0.3.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +346 -198
  3. data/app/controllers/aikotoba/accounts_controller.rb +6 -1
  4. data/app/controllers/aikotoba/application_controller.rb +7 -1
  5. data/app/controllers/aikotoba/confirms_controller.rb +2 -2
  6. data/app/controllers/aikotoba/magic_links_controller.rb +103 -0
  7. data/app/controllers/aikotoba/recoveries_controller.rb +2 -2
  8. data/app/controllers/aikotoba/sessions_controller.rb +2 -2
  9. data/app/controllers/aikotoba/unlocks_controller.rb +2 -2
  10. data/app/controllers/concerns/aikotoba/authenticatable.rb +1 -22
  11. data/app/controllers/concerns/aikotoba/protection/rate_limiting.rb +2 -11
  12. data/app/controllers/concerns/aikotoba/request_backable.rb +53 -0
  13. data/app/controllers/concerns/aikotoba/scopable.rb +5 -5
  14. data/app/mailers/aikotoba/account_mailer.rb +35 -3
  15. data/app/models/aikotoba/account/confirmation_token.rb +6 -1
  16. data/app/models/aikotoba/account/magic_link.rb +38 -0
  17. data/app/models/aikotoba/account/magic_link_token.rb +27 -0
  18. data/app/models/aikotoba/account/{password → password_hash}/argon2.rb +1 -1
  19. data/app/models/aikotoba/account/password_hash.rb +97 -0
  20. data/app/models/aikotoba/account/recovery.rb +27 -1
  21. data/app/models/aikotoba/account/recovery_token.rb +6 -1
  22. data/app/models/aikotoba/account/session.rb +5 -3
  23. data/app/models/aikotoba/account/unlock_token.rb +6 -1
  24. data/app/models/aikotoba/account.rb +80 -60
  25. data/app/models/concerns/aikotoba/enabled_feature_checkable.rb +8 -0
  26. data/app/models/concerns/aikotoba/optional_association.rb +82 -0
  27. data/app/models/concerns/aikotoba/token_encryptable.rb +1 -11
  28. data/app/views/aikotoba/account_mailer/magic_link.html.erb +3 -0
  29. data/app/views/aikotoba/magic_links/new.html.erb +14 -0
  30. data/app/views/aikotoba/sessions/_links.html.erb +6 -1
  31. data/config/locales/en.yml +10 -0
  32. data/config/routes.rb +25 -16
  33. data/db/migrate/20211204121532_create_aikotoba_accounts.rb +77 -33
  34. data/lib/aikotoba/configuration.rb +99 -0
  35. data/lib/aikotoba/constraints/magic_link_authenticatable_constraint.rb +7 -0
  36. data/lib/aikotoba/engine.rb +13 -0
  37. data/lib/aikotoba/errors.rb +2 -0
  38. data/lib/aikotoba/scope.rb +64 -0
  39. data/lib/aikotoba/scopes.rb +102 -0
  40. data/lib/aikotoba/test/authentication_helper.rb +4 -4
  41. data/lib/aikotoba/version.rb +1 -1
  42. data/lib/aikotoba.rb +33 -67
  43. metadata +16 -5
  44. data/app/models/aikotoba/account/password.rb +0 -34
@@ -1,9 +1,19 @@
1
1
  class CreateAikotobaAccounts < ActiveRecord::Migration[6.1]
2
+ # NOTE: The tables below the "optional features" marker belong to features that are
3
+ # disabled by default (Aikotoba.confirmable / .lockable / .recoverable /
4
+ # .magic_link_authenticatable all default to false), so they ship commented out.
5
+ # Uncomment the block for each feature you enable -- before running db:migrate
6
+ # on a fresh install, or in a new migration of your own if you enable the
7
+ # feature later.
8
+ #
9
+ # Leaving them commented out is safe: Aikotoba never queries a feature's table
10
+ # while that table does not exist, including on Account#destroy! and session
11
+ # revocation. The reverse is not true -- enabling a feature's flag without its
12
+ # table raises a real database error the first time that feature runs.
2
13
  def change
3
14
  create_table :aikotoba_accounts do |t|
4
15
  t.belongs_to :authenticate_target, polymorphic: true, index: {unique: true}
5
16
  t.string :email, null: false, index: {unique: true}
6
- t.string :password_digest, null: false
7
17
  t.boolean :confirmed, null: false, default: false
8
18
  t.integer :failed_attempts, null: false, default: 0
9
19
  t.boolean :locked, null: false, default: false
@@ -11,51 +21,85 @@ class CreateAikotobaAccounts < ActiveRecord::Migration[6.1]
11
21
  t.timestamps
12
22
  end
13
23
 
14
- create_table :aikotoba_account_sessions do |t|
15
- t.belongs_to :aikotoba_account, null: false, foreign_key: true
16
- t.string :token, null: false, index: {unique: true}
17
- t.string :origin, null: false, default: "browser"
18
- t.string :ip_address
19
- t.string :user_agent
20
- t.datetime :expired_at, null: false
21
-
22
- t.timestamps
23
- end
24
-
25
- create_table :aikotoba_account_confirmation_tokens do |t|
24
+ create_table :aikotoba_account_password_hashes do |t|
26
25
  t.belongs_to(
27
26
  :aikotoba_account,
28
- foreign_key: true, null: false,
29
- index: {unique: true, name: "index_account_confirmation_tokens_on_account_id"}
27
+ null: false, foreign_key: true,
28
+ index: {unique: true, name: "index_account_password_hashes_on_account_id"}
30
29
  )
31
- t.string :token, null: false, index: {unique: true}
32
- t.datetime :expired_at, null: false
30
+ t.string :digest, null: false
33
31
 
34
32
  t.timestamps
35
33
  end
36
34
 
37
- create_table :aikotoba_account_unlock_tokens do |t|
38
- t.belongs_to(
39
- :aikotoba_account,
40
- null: false, foreign_key: true,
41
- index: {unique: true, name: "index_account_unlock_tokens_on_account_id"}
42
- )
35
+ create_table :aikotoba_account_sessions do |t|
36
+ t.belongs_to :aikotoba_account, null: false, foreign_key: true
43
37
  t.string :token, null: false, index: {unique: true}
38
+ t.string :ip_address
39
+ t.string :user_agent
44
40
  t.datetime :expired_at, null: false
45
41
 
46
42
  t.timestamps
47
43
  end
48
44
 
49
- create_table :aikotoba_account_recovery_tokens do |t|
50
- t.belongs_to(
51
- :aikotoba_account,
52
- null: false, foreign_key: true,
53
- index: {unique: true, name: "index_account_recovery_tokens_on_account_id"}
54
- )
55
- t.string :token, null: false, index: {unique: true}
56
- t.datetime :expired_at, null: false
45
+ # ------------------------------------------------------------------
46
+ # Optional features -- uncomment the ones you enable. See the NOTE above.
47
+ # ------------------------------------------------------------------
57
48
 
58
- t.timestamps
59
- end
49
+ # Aikotoba.confirmable = true
50
+ #
51
+ # create_table :aikotoba_account_confirmation_tokens do |t|
52
+ # t.belongs_to(
53
+ # :aikotoba_account,
54
+ # foreign_key: true, null: false,
55
+ # index: {unique: true, name: "index_account_confirmation_tokens_on_account_id"}
56
+ # )
57
+ # t.string :token, null: false, index: {unique: true}
58
+ # t.datetime :expired_at, null: false
59
+ #
60
+ # t.timestamps
61
+ # end
62
+
63
+ # Aikotoba.lockable = true
64
+ #
65
+ # create_table :aikotoba_account_unlock_tokens do |t|
66
+ # t.belongs_to(
67
+ # :aikotoba_account,
68
+ # null: false, foreign_key: true,
69
+ # index: {unique: true, name: "index_account_unlock_tokens_on_account_id"}
70
+ # )
71
+ # t.string :token, null: false, index: {unique: true}
72
+ # t.datetime :expired_at, null: false
73
+ #
74
+ # t.timestamps
75
+ # end
76
+
77
+ # Aikotoba.recoverable = true
78
+ #
79
+ # create_table :aikotoba_account_recovery_tokens do |t|
80
+ # t.belongs_to(
81
+ # :aikotoba_account,
82
+ # null: false, foreign_key: true,
83
+ # index: {unique: true, name: "index_account_recovery_tokens_on_account_id"}
84
+ # )
85
+ # t.string :token, null: false, index: {unique: true}
86
+ # t.datetime :expired_at, null: false
87
+ #
88
+ # t.timestamps
89
+ # end
90
+
91
+ # Aikotoba.magic_link_authenticatable = true
92
+ #
93
+ # create_table :aikotoba_account_magic_link_tokens do |t|
94
+ # t.belongs_to(
95
+ # :aikotoba_account,
96
+ # null: false, foreign_key: true,
97
+ # index: {unique: true, name: "index_account_magic_link_tokens_on_account_id"}
98
+ # )
99
+ # t.string :token, null: false, index: {unique: true}
100
+ # t.datetime :expired_at, null: false
101
+ #
102
+ # t.timestamps
103
+ # end
60
104
  end
61
105
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/deep_dup"
4
+ require "active_support/core_ext/integer/time"
5
+ require "aikotoba/scopes"
6
+
7
+ module Aikotoba
8
+ # NOTE: Every setting, set through Aikotoba.configure and read through Aikotoba.xxx.
9
+ class Configuration
10
+ SETTINGS = %i[
11
+ parent_controller parent_mailer mailer_sender email_format password_pepper
12
+ password_length_range session_expiry
13
+ registerable
14
+ confirmable confirmation_token_expiry confirmation_rate_limit_options
15
+ lockable max_failed_attempts unlock_token_expiry unlock_rate_limit_options
16
+ recoverable recovery_token_expiry recovery_rate_limit_options
17
+ magic_link_authenticatable magic_link_token_expiry magic_link_rate_limit_options
18
+ encrypted_token
19
+ scopes
20
+ ].freeze
21
+
22
+ attr_accessor(*SETTINGS)
23
+
24
+ def initialize
25
+ @parent_controller = "ApplicationController"
26
+ @parent_mailer = "ActionMailer::Base"
27
+ @mailer_sender = "from@example.com"
28
+ @email_format = /\A[^\s]+@[^\s]+\z/
29
+ @password_pepper = "aikotoba-default-pepper"
30
+ @password_length_range = 8..100
31
+ @session_expiry = 7.days
32
+
33
+ # for Registerable
34
+ @registerable = true
35
+
36
+ # for Confirmable
37
+ @confirmable = false
38
+ @confirmation_token_expiry = 1.day
39
+ @confirmation_rate_limit_options = {}
40
+
41
+ # for Lockable
42
+ @lockable = false
43
+ @max_failed_attempts = 10
44
+ @unlock_token_expiry = 1.day
45
+ @unlock_rate_limit_options = {}
46
+
47
+ # for Recoverable
48
+ @recoverable = false
49
+ @recovery_token_expiry = 4.hours
50
+ @recovery_rate_limit_options = {}
51
+
52
+ # for MagicLinkAuthenticatable
53
+ @magic_link_authenticatable = false
54
+ @magic_link_token_expiry = 15.minutes
55
+ @magic_link_rate_limit_options = {}
56
+
57
+ # for encrypt token
58
+ @encrypted_token = false
59
+
60
+ @scopes = Scopes.new({
61
+ default: {
62
+ authenticate_for: nil,
63
+ root_path: "/",
64
+ session_key: "aikotoba_session_token",
65
+ sign_in_path: "/sign_in",
66
+ sign_out_path: "/sign_out",
67
+ after_sign_in_path: "/",
68
+ after_sign_out_path: "/sign_in",
69
+ sign_up_path: "/sign_up",
70
+ confirm_path: "/confirm",
71
+ unlock_path: "/unlock",
72
+ recover_path: "/recover",
73
+ magic_link_path: "/magic_link"
74
+ }
75
+ })
76
+ end
77
+
78
+ # NOTE: A copy shares nothing with the original, so changing one (scopes, rate limit
79
+ # options) leaves the other as it was.
80
+ def initialize_copy(source)
81
+ super
82
+ SETTINGS.each { |name| public_send(:"#{name}=", source.public_send(name).deep_dup) }
83
+ end
84
+
85
+ def default_scope
86
+ scopes.default_scope
87
+ end
88
+
89
+ # NOTE: Merges into the default scope (does not replace, keeps the other keys).
90
+ # Example: config.default_scope = { sign_in_path: "/custom" }
91
+ def default_scope=(hash)
92
+ default_scope.merge!(hash)
93
+ end
94
+
95
+ def add_scope(name, config = {})
96
+ scopes.add(name, config)
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Aikotoba::MagicLinkAuthenticatableConstraint
4
+ def self.matches?(_request)
5
+ Aikotoba.magic_link_authenticatable
6
+ end
7
+ end
@@ -3,5 +3,18 @@
3
3
  module Aikotoba
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace Aikotoba
6
+
7
+ initializer "aikotoba.deprecator" do |app|
8
+ app.deprecators[:aikotoba] = Aikotoba.deprecator
9
+ end
10
+
11
+ # NOTE: Runs once the host app's initializers (where Aikotoba.configure lives) are done,
12
+ # to also catch scopes changed outside configure. Not in config/routes.rb: Rails 8
13
+ # draws routes lazily in development and test, so a check there would only fire on
14
+ # the first request.
15
+ config.after_initialize do
16
+ Aikotoba.scopes.validate!
17
+ Aikotoba.scopes.warn_rewritten_paths
18
+ end
6
19
  end
7
20
  end
@@ -3,5 +3,7 @@ module Aikotoba
3
3
  class Base < StandardError; end
4
4
 
5
5
  class NotAvailableException < Base; end
6
+
7
+ class InvalidScopeConfiguration < Base; end
6
8
  end
7
9
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/hash_with_indifferent_access"
4
+
5
+ module Aikotoba
6
+ # NOTE: One scope's settings. Still a HashWithIndifferentAccess, so the documented
7
+ # Aikotoba.default_scope[:key] / Aikotoba.scopes[:name][:key] reads and writes keep
8
+ # working; the methods below answer the questions callers used to ask of the raw hash.
9
+ class Scope < ActiveSupport::HashWithIndifferentAccess
10
+ def authenticate_for
11
+ self[:authenticate_for]
12
+ end
13
+
14
+ # NOTE: A scope restricted by authenticate_for gets no sign-up: its accounts need an
15
+ # authenticate_target of that type, which the generic sign-up form can't create.
16
+ # Routes and the sign-in page's sign-up link both read this, so they can't disagree.
17
+ # blank? (not nil?) matches Account.for_target_type, which treats "" as unrestricted.
18
+ def registerable?
19
+ authenticate_for.blank?
20
+ end
21
+
22
+ # NOTE: Whether this is the scope set up for the target type (authenticate_for: "Admin"
23
+ # for "Admin"). An unrestricted scope also signs Admins in, but isn't "for" them.
24
+ def for?(target_type_name)
25
+ authenticate_for.present? && authenticate_for.to_s == target_type_name.to_s
26
+ end
27
+
28
+ # NOTE: Whether a request path belongs to this scope. Scopable resolves the current
29
+ # scope with this, so route_path keeps each route inside it.
30
+ # A trailing slash on root_path is ignored, so "/admin/" covers "/admin/sign_in" too.
31
+ def covers?(path)
32
+ path == root || path.start_with?("#{root}/")
33
+ end
34
+
35
+ # NOTE: An absolute path with at least one segment ("/admin", "/admin/", "/a/b"), the
36
+ # only shape covers? handles: "/" (or unset) would cover every request, and a
37
+ # relative or malformed one ("admin", "/admin//") none, not even its own routes.
38
+ def valid_root_path?
39
+ self[:root_path].to_s.match?(%r{\A(/[^/\s]+)+/?\z})
40
+ end
41
+
42
+ # NOTE: The path config/routes.rb draws for a path setting: the value as is when it is
43
+ # already under root_path, otherwise prefixed with root_path. So a path add_scope
44
+ # copied from the default scope (/magic_link in an /admin scope) still lands in this
45
+ # scope (/admin/magic_link), and "/admin/..." isn't prefixed twice. Only for routed
46
+ # paths; redirect targets like after_sign_in_path may point anywhere.
47
+ def route_path(key)
48
+ path = self[key]
49
+ covers?(path) ? path : File.join(self[:root_path], path)
50
+ end
51
+
52
+ # NOTE: The *_path settings config/routes.rb draws through route_path; the others are
53
+ # the scope's own root and redirect targets, which may point anywhere.
54
+ def route_path_keys
55
+ keys.select { |key| key.end_with?("_path") } - %w[root_path after_sign_in_path after_sign_out_path]
56
+ end
57
+
58
+ private
59
+
60
+ def root
61
+ self[:root_path].to_s.chomp("/")
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/hash_with_indifferent_access"
4
+ require "aikotoba/scope"
5
+
6
+ module Aikotoba
7
+ # NOTE: All scopes, keyed by name. Still a HashWithIndifferentAccess, so Aikotoba.scopes
8
+ # keeps its documented Hash behavior; the cross-scope rules live here.
9
+ class Scopes < ActiveSupport::HashWithIndifferentAccess
10
+ # NOTE: Not named default: that would override Hash#default(key = nil), which Hash calls
11
+ # with the key on every miss (scopes[:typo] raised ArgumentError), and which
12
+ # HashWithIndifferentAccess copies into every dup (select, reject, deep_dup), turning
13
+ # the default scope into the copy's fallback value for missing keys.
14
+ def default_scope
15
+ self[:default]
16
+ end
17
+
18
+ def add(name, config = {})
19
+ scope = default_scope.merge(config)
20
+ validate!(merge(name => scope))
21
+ self[name] = scope
22
+ end
23
+
24
+ # NOTE: The scope whose links an account's emails use: the scope whose authenticate_for
25
+ # matches the account's target type (unique, see validate!), else the default.
26
+ # Emails are rendered in a job, with no request to tell which scope was used.
27
+ def name_for(target_type_name)
28
+ name, _ = find { |_, scope| scope.for?(target_type_name) }
29
+ name || "default"
30
+ end
31
+
32
+ # NOTE: Run by the Engine after initialization. route_path prefixes any path outside
33
+ # root_path; for one inherited from the default scope or written relative that is
34
+ # the intent, but an absolute path set on the scope itself was probably meant as is.
35
+ # A value equal to the default scope's can't be told from an inherited one, so it is
36
+ # treated as inherited.
37
+ def warn_rewritten_paths(logger = Rails.logger)
38
+ except(:default).each do |name, scope|
39
+ scope.route_path_keys.each do |key|
40
+ path = scope[key]
41
+ rewritten = !scope.covers?(path)
42
+ absolute = path.start_with?("/")
43
+ same_as_default = path == default_scope[key]
44
+ next unless rewritten && absolute && !same_as_default
45
+
46
+ route = scope.route_path(key)
47
+ logger&.warn("[Aikotoba] #{name}.#{key} #{path.inspect} is drawn as #{route.inspect}; " \
48
+ "write #{route.inspect} (or a relative path) to silence this.")
49
+ end
50
+ end
51
+ end
52
+
53
+ # NOTE: Checked by add, and again after initialization (Engine), to catch default_scope=
54
+ # or direct edits made afterwards.
55
+ def validate!(scopes = self)
56
+ validate_root_paths!(scopes)
57
+ validate_unique_authenticate_for!(scopes)
58
+ end
59
+
60
+ private
61
+
62
+ # NOTE: An added scope serves the requests its root_path covers (Scope#covers?). One at "/"
63
+ # would take over every request, the host app's included, and a malformed one would
64
+ # leave its own pages to the default scope. add_scope copies root_path "/" from the
65
+ # default scope unless it is set.
66
+ def validate_root_paths!(scopes)
67
+ invalid = scopes.except(:default).reject { |_, scope| scope.valid_root_path? }
68
+ return if invalid.empty?
69
+
70
+ details = invalid.map { |name, scope| "#{name} (#{scope[:root_path].inspect})" }.join(", ")
71
+ raise Errors::InvalidScopeConfiguration,
72
+ "root_path of an added scope must be an absolute path other than \"/\", like \"/admin\", but is not on: " \
73
+ "#{details}. add_scope copies root_path \"/\" from the default scope unless you set it."
74
+ end
75
+
76
+ # NOTE: Each authenticate_for may belong to one scope only, so an account's type maps to
77
+ # exactly one scope (see name_for).
78
+ def validate_unique_authenticate_for!(scopes)
79
+ shared = scopes
80
+ .select { |_, scope| scope.authenticate_for.present? }
81
+ .group_by { |_, scope| scope.authenticate_for.to_s }
82
+ .select { |_, entries| entries.size > 1 }
83
+ return if shared.empty?
84
+
85
+ details = shared.map { |type, entries| "#{type} (#{entries.map(&:first).join(", ")})" }.join("; ")
86
+ raise Errors::InvalidScopeConfiguration,
87
+ "authenticate_for must be unique per scope, but is shared: #{details}. " \
88
+ "add_scope copies authenticate_for from the default scope unless you set it, so set it " \
89
+ "explicitly on the added scope (nil for an unrestricted scope)."
90
+ end
91
+
92
+ # NOTE: HashWithIndifferentAccess turns every Hash it stores into a plain
93
+ # HashWithIndifferentAccess; store a Scope instead, so a scope assigned directly
94
+ # (scopes[:name] = {...}) still answers registerable? / for?. :to_hash is the
95
+ # conversion back to a plain Hash.
96
+ def convert_value(value, conversion: nil)
97
+ return Scope.new(value) if value.is_a?(Hash) && !value.is_a?(Scope) && conversion != :to_hash
98
+
99
+ super
100
+ end
101
+ end
102
+ end
@@ -9,8 +9,8 @@ module Aikotoba
9
9
  follow_redirect!
10
10
  end
11
11
 
12
- def aikotoba_sign_in(account)
13
- post aikotoba.new_session_path, params: {account: {email: account.email, password: account.password}}
12
+ def aikotoba_sign_in(account, password:)
13
+ post aikotoba.new_session_path, params: {account: {email: account.email, password: password}}
14
14
  follow_redirect!
15
15
  end
16
16
  end
@@ -24,10 +24,10 @@ module Aikotoba
24
24
  end
25
25
  end
26
26
 
27
- def aikotoba_sign_in(account)
27
+ def aikotoba_sign_in(account, password:)
28
28
  if page.driver.is_a?(Capybara::RackTest::Driver)
29
29
  disable_forgery_protection do
30
- page.driver.send(:post, aikotoba.new_session_path, account: {email: account.email, password: account.password})
30
+ page.driver.send(:post, aikotoba.new_session_path, account: {email: account.email, password: password})
31
31
  end
32
32
  else
33
33
  raise NotImplementedError, "Sorry. Only RackTest::Driver is supported as a test helper for Aikotoba's authentication."
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aikotoba
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/aikotoba.rb CHANGED
@@ -3,83 +3,49 @@
3
3
  require "aikotoba/version"
4
4
  require "aikotoba/engine"
5
5
  require "aikotoba/errors"
6
+ require "aikotoba/configuration"
6
7
 
7
8
  module Aikotoba
8
- DEPRECATOR = ActiveSupport::Deprecation.new("1.0", "Aikotoba")
9
-
10
- mattr_accessor(:parent_controller) { "ApplicationController" }
11
- mattr_accessor(:parent_mailer) { "ActionMailer::Base" }
12
- mattr_accessor(:mailer_sender) { "from@example.com" }
13
- mattr_accessor(:email_format) { /\A[^\s]+@[^\s]+\z/ }
14
- mattr_accessor(:password_pepper) { "aikotoba-default-pepper" }
15
- mattr_accessor(:password_length_range) { 8..100 }
16
- mattr_accessor(:session_expiry) { 7.days }
17
- mattr_accessor(:keep_legacy_login_session) { false }
18
-
19
- # for Registerable
20
- mattr_accessor(:registerable) { true }
21
-
22
- # for Confirmable
23
- mattr_accessor(:confirmable) { false }
24
- mattr_accessor(:confirmation_token_expiry) { 1.day }
25
- mattr_accessor(:confirmation_rate_limit_options) { {} }
26
-
27
- # for Lockable
28
- mattr_accessor(:lockable) { false }
29
- mattr_accessor(:max_failed_attempts) { 10 }
30
- mattr_accessor(:unlock_token_expiry) { 1.day }
31
- mattr_accessor(:unlock_rate_limit_options) { {} }
32
-
33
- # for Recoverable
34
- mattr_accessor(:recoverable) { false }
35
- mattr_accessor(:recovery_token_expiry) { 4.hours }
36
- mattr_accessor(:recovery_rate_limit_options) { {} }
37
-
38
- # for encrypt token
39
- mattr_accessor(:encrypted_token) { false }
40
-
41
- mattr_accessor(:scopes) {
42
- HashWithIndifferentAccess.new({
43
- default: {
44
- authenticate_for: nil,
45
- root_path: "/",
46
- session_key: "aikotoba_session_token",
47
- sign_in_path: "/sign_in",
48
- sign_out_path: "/sign_out",
49
- after_sign_in_path: "/",
50
- after_sign_out_path: "/sign_in",
51
- sign_up_path: "/sign_up",
52
- confirm_path: "/confirm",
53
- unlock_path: "/unlock",
54
- recover_path: "/recover"
55
- }
56
- })
57
- }
58
-
59
- def self.default_scope
60
- scopes[:default]
9
+ # NOTE: Set every setting in one place:
10
+ # Aikotoba.configure do |config|
11
+ # config.confirmable = true
12
+ # config.add_scope(:admin, { root_path: "/admin", authenticate_for: "Admin" })
13
+ # end
14
+ # The scopes are checked once the block has set everything, feature flags included.
15
+ def self.configure
16
+ yield config
17
+ scopes.validate!
61
18
  end
62
19
 
63
- # NOTE: Merge configuration into default scope (does not replace, merges with existing keys)
64
- # Example: Aikotoba.default_scope = { sign_in_path: "/custom" }
65
- def self.default_scope=(hash)
66
- default_scope.merge!(hash)
20
+ def self.config
21
+ @config ||= Configuration.new
67
22
  end
68
23
 
69
- def self.add_scope(name, config = {})
70
- scopes[name] = default_scope.merge(config)
24
+ def self.deprecator
25
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0", "Aikotoba")
71
26
  end
72
27
 
73
- # TODO: Deprecated for compatibility, will be removed in the future
74
- default_scope.each do |key, value|
75
- define_singleton_method(key) do
76
- DEPRECATOR.warn("Aikotoba.#{key} is deprecated. Please use Aikotoba.default_scope[:#{key}] instead.")
77
- default_scope[key]
28
+ # NOTE: Read a setting with Aikotoba.xxx. The Aikotoba.xxx= writers, add_scope and
29
+ # default_scope= still work for initializers written before configure existed, but
30
+ # are deprecated and will be removed in 1.0.
31
+ class << self
32
+ delegate(*Configuration::SETTINGS, :default_scope, to: :config)
33
+
34
+ Configuration::SETTINGS.each do |name|
35
+ define_method(:"#{name}=") do |value|
36
+ deprecator.warn("Aikotoba.#{name}= is deprecated. Set config.#{name} in Aikotoba.configure instead.")
37
+ config.public_send(:"#{name}=", value)
38
+ end
39
+ end
40
+
41
+ def default_scope=(hash)
42
+ deprecator.warn("Aikotoba.default_scope= is deprecated. Set config.default_scope in Aikotoba.configure instead.")
43
+ config.default_scope = hash
78
44
  end
79
45
 
80
- define_singleton_method("#{key}=") do |new_value|
81
- DEPRECATOR.warn("Aikotoba.#{key}= is deprecated. Please use Aikotoba.default_scope[:#{key}]= instead.")
82
- default_scope[key] = new_value
46
+ def add_scope(name, scope_config = {})
47
+ deprecator.warn("Aikotoba.add_scope is deprecated. Call config.add_scope in Aikotoba.configure instead.")
48
+ config.add_scope(name, scope_config)
83
49
  end
84
50
  end
85
51
  end