spree_otp_auth 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +42 -0
- data/LICENSE +29 -0
- data/README.md +190 -0
- data/app/controllers/spree/admin/otp_configurations_controller.rb +50 -0
- data/app/controllers/spree/api/v3/store/otp_controller.rb +301 -0
- data/app/mailers/spree/otp_mailer.rb +57 -0
- data/app/models/spree/otp_attempt.rb +82 -0
- data/app/models/spree/otp_configuration.rb +90 -0
- data/app/models/spree/otp_store_decorator.rb +15 -0
- data/app/models/spree/otp_user_decorator.rb +59 -0
- data/app/services/spree/otp/code_sent_payload.rb +55 -0
- data/app/services/spree/otp/delivery.rb +77 -0
- data/app/services/spree/otp/fallback.rb +86 -0
- data/app/services/spree/otp/generate.rb +198 -0
- data/app/services/spree/otp/identity.rb +81 -0
- data/app/services/spree/otp/marketing_consent.rb +35 -0
- data/app/services/spree/otp/rate_limiter.rb +82 -0
- data/app/services/spree/otp/resend.rb +27 -0
- data/app/services/spree/otp/verify.rb +173 -0
- data/app/subscribers/spree/otp/delivery_failed_subscriber.rb +43 -0
- data/app/views/spree/admin/otp_configurations/edit.html.erb +141 -0
- data/app/views/spree/otp_mailer/send_otp.html.erb +100 -0
- data/app/views/spree/otp_mailer/send_otp.text.erb +8 -0
- data/config/locales/en.yml +39 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20260927000001_create_spree_otp_configurations.spree_otp_auth.rb +20 -0
- data/db/migrate/20260927000002_create_spree_otp_attempts.spree_otp_auth.rb +26 -0
- data/db/migrate/20260927000003_add_otp_fields_to_spree_users.spree_otp_auth.rb +12 -0
- data/db/migrate/20260927000004_update_otp_schema_for_mandatory_mobile_and_templates.spree_otp_auth.rb +28 -0
- data/db/migrate/20261018000001_add_gupshup_fields_to_spree_otp_configurations.spree_otp_auth.rb +18 -0
- data/db/migrate/20261018000002_add_delivery_fields_to_spree_otp_attempts.spree_otp_auth.rb +18 -0
- data/db/migrate/20261018000003_add_email_marketing_decided_at_to_spree_users.spree_otp_auth.rb +30 -0
- data/db/migrate/20261019000004_add_delivery_message_ref_to_spree_otp_attempts.spree_otp_auth.rb +8 -0
- data/lib/generators/spree_otp_auth/install/install_generator.rb +27 -0
- data/lib/spree_otp_auth/engine.rb +44 -0
- data/lib/spree_otp_auth/version.rb +5 -0
- data/lib/spree_otp_auth.rb +28 -0
- data/spree_otp_auth.gemspec +39 -0
- metadata +167 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Otp
|
|
5
|
+
# Phone/email normalisation and masking shared by the OTP services and
|
|
6
|
+
# controllers. Mobile-created accounts carry a synthetic
|
|
7
|
+
# "<digits><SpreeOtpAuth.synthetic_email_domain>" email, which counts as
|
|
8
|
+
# "no email".
|
|
9
|
+
module Identity
|
|
10
|
+
PHONE_FORMAT = /\A\+[1-9]\d{9,14}\z/
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def synthetic_email_domain
|
|
15
|
+
domain = SpreeOtpAuth.synthetic_email_domain.to_s.downcase
|
|
16
|
+
domain.start_with?('@') ? domain : "@#{domain}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def normalize_phone(raw)
|
|
20
|
+
digits = raw.to_s.gsub(/\D/, '')
|
|
21
|
+
return nil if digits.length < 10
|
|
22
|
+
|
|
23
|
+
digits = "91#{digits}" if digits.length == 10
|
|
24
|
+
"+#{digits}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def valid_phone?(phone)
|
|
28
|
+
phone.to_s.match?(PHONE_FORMAT)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def normalize_email(raw)
|
|
32
|
+
raw.to_s.strip.downcase.presence
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def valid_email?(email)
|
|
36
|
+
email.to_s.match?(URI::MailTo::EMAIL_REGEXP)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def email_identifier?(identifier)
|
|
40
|
+
identifier.to_s.include?('@')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def real_email?(email)
|
|
44
|
+
email.present? && !email.to_s.downcase.end_with?(synthetic_email_domain)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def synthetic_email_for(mobile)
|
|
48
|
+
"#{mobile.to_s.gsub(/\D/, '')}#{synthetic_email_domain}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns [normalized, 'email' | 'mobile'] or [nil, nil].
|
|
52
|
+
def normalize_identifier(identifier)
|
|
53
|
+
raw = identifier.to_s.strip
|
|
54
|
+
return [normalize_email(raw), 'email'] if email_identifier?(raw)
|
|
55
|
+
|
|
56
|
+
phone = normalize_phone(raw)
|
|
57
|
+
phone ? [phone, 'mobile'] : [nil, nil]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def mask_email(email)
|
|
61
|
+
user_part, domain = email.to_s.split('@', 2)
|
|
62
|
+
return email.to_s if user_part.blank? || domain.blank?
|
|
63
|
+
|
|
64
|
+
masked_user = user_part.length <= 2 ? "#{user_part[0]}***" : "#{user_part[0]}***#{user_part[-1]}"
|
|
65
|
+
"#{masked_user}@#{domain}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def mask_phone(phone)
|
|
69
|
+
digits = phone.to_s.gsub(/\D/, '')
|
|
70
|
+
return phone.to_s if digits.length < 10
|
|
71
|
+
|
|
72
|
+
country_code = digits[0..-11]
|
|
73
|
+
"+#{country_code.presence || '91'} #{digits[-10..-9]}*** **#{digits[-3..]}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def mask(identifier)
|
|
77
|
+
email_identifier?(identifier) ? mask_email(identifier) : mask_phone(identifier)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Otp
|
|
5
|
+
# Applies the customer's news-and-offers choice from the login screen (or
|
|
6
|
+
# the account order page) to the user and Spree's newsletter list.
|
|
7
|
+
#
|
|
8
|
+
# accept: true -> subscribe (verified: they just proved the phone and
|
|
9
|
+
# typed or own the email)
|
|
10
|
+
# false -> record the opt-out and drop any subscription
|
|
11
|
+
# nil -> no choice was shown; change nothing
|
|
12
|
+
module MarketingConsent
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def call(user:, store:, accept:)
|
|
16
|
+
return if accept.nil? || user.nil?
|
|
17
|
+
return unless Spree::Otp::Identity.real_email?(user.email)
|
|
18
|
+
|
|
19
|
+
email = user.email.downcase
|
|
20
|
+
|
|
21
|
+
ApplicationRecord.transaction do
|
|
22
|
+
if accept
|
|
23
|
+
Spree::NewsletterSubscriber.subscribe(email: email, user: user, store: store)
|
|
24
|
+
subscriber = Spree::NewsletterSubscriber.find_by(email: email, store: store)
|
|
25
|
+
subscriber&.update!(user: user, verified_at: subscriber.verified_at || Time.current)
|
|
26
|
+
else
|
|
27
|
+
Spree::NewsletterSubscriber.where(email: email, store: store).destroy_all
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
user.update_columns(accepts_email_marketing: accept, email_marketing_decided_at: Time.current, updated_at: Time.current)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Otp
|
|
5
|
+
class RateLimiter
|
|
6
|
+
MAX_HOURLY_DISPATCHES = 5
|
|
7
|
+
MAX_FAILED_ATTEMPTS = 5
|
|
8
|
+
|
|
9
|
+
attr_reader :store, :identifier
|
|
10
|
+
|
|
11
|
+
def initialize(store:, identifier:)
|
|
12
|
+
@store = store
|
|
13
|
+
@identifier = identifier.to_s.strip.downcase
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Checks if a resend cooldown is currently active.
|
|
17
|
+
# Returns remaining seconds (integer) or nil if cooldown has expired.
|
|
18
|
+
def cooldown_remaining(cooldown_seconds = nil)
|
|
19
|
+
cooldown = cooldown_seconds || default_cooldown_seconds
|
|
20
|
+
last_attempt = latest_attempt
|
|
21
|
+
return nil unless last_attempt
|
|
22
|
+
|
|
23
|
+
elapsed = Time.current - last_attempt.created_at
|
|
24
|
+
return nil if elapsed >= cooldown
|
|
25
|
+
|
|
26
|
+
(cooldown - elapsed).ceil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Checks if hourly OTP dispatch limit has been exceeded.
|
|
30
|
+
def hourly_limit_exceeded?(limit = MAX_HOURLY_DISPATCHES)
|
|
31
|
+
attempts_in_last_hour >= limit
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Checks if the active attempt has reached max failed verification attempts.
|
|
35
|
+
def locked_out?(max_attempts = MAX_FAILED_ATTEMPTS)
|
|
36
|
+
active_attempt = latest_unverified_attempt
|
|
37
|
+
return false unless active_attempt
|
|
38
|
+
|
|
39
|
+
active_attempt.attempts_count >= (active_attempt.max_attempts || max_attempts)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Full check for dispatch readiness.
|
|
43
|
+
# Returns [allowed (boolean), reason_code (symbol or nil), retry_after (integer or nil)]
|
|
44
|
+
def check_dispatch_allowed(cooldown_seconds = nil)
|
|
45
|
+
if hourly_limit_exceeded?
|
|
46
|
+
return [false, :hourly_limit_exceeded, 3600]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
remaining = cooldown_remaining(cooldown_seconds)
|
|
50
|
+
if remaining && remaining.positive?
|
|
51
|
+
return [false, :cooldown_active, remaining]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
[true, nil, nil]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def default_cooldown_seconds
|
|
60
|
+
config = store.respond_to?(:effective_otp_configuration) ? store.effective_otp_configuration : nil
|
|
61
|
+
config&.resend_cooldown_seconds || 30
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def latest_attempt
|
|
65
|
+
Spree::OtpAttempt.where(store: store, identifier: identifier).order(created_at: :desc).first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def latest_unverified_attempt
|
|
69
|
+
Spree::OtpAttempt.where(store: store, identifier: identifier, verified_at: nil)
|
|
70
|
+
.where('expires_at > ?', Time.current)
|
|
71
|
+
.order(created_at: :desc)
|
|
72
|
+
.first
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def attempts_in_last_hour
|
|
76
|
+
Spree::OtpAttempt.where(store: store, identifier: identifier)
|
|
77
|
+
.where('created_at > ?', 1.hour.ago)
|
|
78
|
+
.count
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Otp
|
|
5
|
+
class Resend
|
|
6
|
+
prepend Spree::ServiceModule::Base
|
|
7
|
+
|
|
8
|
+
# Resends on the latest attempt's channel (so a code that already moved to
|
|
9
|
+
# email stays on email), carrying over whatever was collected on screen.
|
|
10
|
+
def call(store:, identifier:, channel: nil, request_env: {})
|
|
11
|
+
normalized, = Spree::Otp::Identity.normalize_identifier(identifier)
|
|
12
|
+
normalized ||= identifier.to_s.strip
|
|
13
|
+
|
|
14
|
+
latest_attempt = Spree::OtpAttempt.where(store: store).login.for_identifier(normalized).order(created_at: :desc).first
|
|
15
|
+
|
|
16
|
+
Spree::Otp::Generate.call(
|
|
17
|
+
store: store,
|
|
18
|
+
identifier: identifier,
|
|
19
|
+
mobile_number: latest_attempt&.staged_mobile_number,
|
|
20
|
+
email: latest_attempt&.staged_email || latest_attempt&.link_email_attempt&.identifier,
|
|
21
|
+
channel: channel.presence || latest_attempt&.channel,
|
|
22
|
+
request_env: request_env
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Spree
|
|
7
|
+
module Otp
|
|
8
|
+
class Verify
|
|
9
|
+
prepend Spree::ServiceModule::Base
|
|
10
|
+
|
|
11
|
+
# accept_marketing: nil when the news-and-offers box wasn't shown.
|
|
12
|
+
def call(store:, identifier:, code:, email_code: nil, accept_marketing: nil, request_env: {})
|
|
13
|
+
normalized_identifier, = Spree::Otp::Identity.normalize_identifier(identifier)
|
|
14
|
+
return failure(:invalid_identifier, 'Invalid email or phone number format.') if normalized_identifier.blank?
|
|
15
|
+
|
|
16
|
+
attempt = find_active_attempt(store, normalized_identifier)
|
|
17
|
+
return failure(:invalid_otp, 'The entered code is incorrect or expired. Please try again.') unless attempt
|
|
18
|
+
|
|
19
|
+
code_failure = check_code(attempt, code, :invalid_otp, 'The entered code is incorrect or expired. Please try again.')
|
|
20
|
+
return code_failure if code_failure
|
|
21
|
+
|
|
22
|
+
link_attempt = attempt.link_email_attempt
|
|
23
|
+
if link_attempt
|
|
24
|
+
return failure(:invalid_email_code, 'The code sent to your email is incorrect or expired.') if link_attempt.expired?
|
|
25
|
+
|
|
26
|
+
email_failure = check_code(link_attempt, email_code, :invalid_email_code, 'The code sent to your email is incorrect or expired.')
|
|
27
|
+
return email_failure if email_failure
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
user = nil
|
|
31
|
+
ApplicationRecord.transaction do
|
|
32
|
+
attempt.mark_verified!
|
|
33
|
+
link_attempt&.mark_verified!
|
|
34
|
+
user = resolve_user(attempt, normalized_identifier)
|
|
35
|
+
attempt.update(user: user) if attempt.user_id.nil?
|
|
36
|
+
Spree::Otp::MarketingConsent.call(user: user, store: store, accept: accept_marketing)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
tokens = issue_tokens(user, request_env)
|
|
40
|
+
|
|
41
|
+
success(
|
|
42
|
+
user: user,
|
|
43
|
+
token: tokens[:token],
|
|
44
|
+
refresh_token: tokens[:refresh_token],
|
|
45
|
+
attempt: attempt
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Returns a failure Result, or nil when the code matches.
|
|
52
|
+
def check_code(attempt, submitted, error_code, message)
|
|
53
|
+
if attempt.locked?
|
|
54
|
+
return failure(:max_attempts_exceeded, 'Maximum verification attempts reached. Please request a new code.')
|
|
55
|
+
end
|
|
56
|
+
return nil if attempt.valid_code?(submitted)
|
|
57
|
+
|
|
58
|
+
attempt.record_failed_attempt!
|
|
59
|
+
return failure(:max_attempts_exceeded, 'Maximum verification attempts reached. Please request a new code.') if attempt.locked?
|
|
60
|
+
|
|
61
|
+
failure(error_code, message)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def find_active_attempt(store, identifier)
|
|
65
|
+
Spree::OtpAttempt.where(store: store, identifier: identifier).login.active
|
|
66
|
+
.order(created_at: :desc)
|
|
67
|
+
.first
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def resolve_user(attempt, identifier)
|
|
71
|
+
user = if Spree::Otp::Identity.email_identifier?(identifier)
|
|
72
|
+
find_or_create_email_user(identifier)
|
|
73
|
+
else
|
|
74
|
+
attempt.user || find_or_create_mobile_user(identifier, attempt.staged_email)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
changed = false
|
|
78
|
+
if attempt.staged_mobile_number.present? && user.mobile_number != attempt.staged_mobile_number
|
|
79
|
+
user.mobile_number = attempt.staged_mobile_number
|
|
80
|
+
changed = true
|
|
81
|
+
end
|
|
82
|
+
# A mobile-first login finished with an email backup code doesn't prove
|
|
83
|
+
# the phone. Email-first logins keep feature 010's behaviour.
|
|
84
|
+
phone_unproven = attempt.channel == 'email' && !Spree::Otp::Identity.email_identifier?(identifier)
|
|
85
|
+
if !phone_unproven && user.mobile_number.present? && user.respond_to?(:mobile_confirmed_at=) &&
|
|
86
|
+
(attempt.staged_mobile_number.present? || !Spree::Otp::Identity.email_identifier?(identifier))
|
|
87
|
+
user.mobile_confirmed_at ||= Time.current
|
|
88
|
+
changed = true
|
|
89
|
+
end
|
|
90
|
+
if attempt.staged_email.present? && !Spree::Otp::Identity.real_email?(user.email)
|
|
91
|
+
user.email = attempt.staged_email
|
|
92
|
+
changed = true
|
|
93
|
+
end
|
|
94
|
+
user.save!(validate: false) if changed
|
|
95
|
+
user
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def find_or_create_email_user(email)
|
|
99
|
+
user = Spree.user_class.find_by('LOWER(email) = ?', email)
|
|
100
|
+
return user if user
|
|
101
|
+
|
|
102
|
+
user = Spree.user_class.new(
|
|
103
|
+
email: email,
|
|
104
|
+
auth_identifier: email,
|
|
105
|
+
auth_channel: 'email',
|
|
106
|
+
password: SecureRandom.hex(16)
|
|
107
|
+
)
|
|
108
|
+
user.save!(validate: false)
|
|
109
|
+
user
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def find_or_create_mobile_user(mobile, staged_email = nil)
|
|
113
|
+
user = Spree.user_class.find_by(mobile_number: mobile)
|
|
114
|
+
return user if user
|
|
115
|
+
|
|
116
|
+
placeholder_email = Spree::Otp::Identity.synthetic_email_for(mobile)
|
|
117
|
+
user = Spree.user_class.find_by('LOWER(email) = ?', placeholder_email)
|
|
118
|
+
return user if user
|
|
119
|
+
|
|
120
|
+
user = Spree.user_class.new(
|
|
121
|
+
email: staged_email.presence || placeholder_email,
|
|
122
|
+
mobile_number: mobile,
|
|
123
|
+
auth_identifier: mobile,
|
|
124
|
+
auth_channel: 'mobile',
|
|
125
|
+
password: SecureRandom.hex(16)
|
|
126
|
+
)
|
|
127
|
+
user.save!(validate: false)
|
|
128
|
+
user
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def issue_tokens(user, request_env)
|
|
132
|
+
env = {
|
|
133
|
+
ip_address: request_env[:ip_address],
|
|
134
|
+
user_agent: request_env[:user_agent]&.truncate(255)
|
|
135
|
+
}
|
|
136
|
+
refresh_token = Spree::RefreshToken.create_for(user, request_env: env)
|
|
137
|
+
jwt = generate_jwt(user)
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
token: jwt,
|
|
141
|
+
refresh_token: refresh_token.token
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def generate_jwt(user, expiration: 30.days.to_i)
|
|
146
|
+
payload = {
|
|
147
|
+
user_id: user.id,
|
|
148
|
+
user_type: determine_user_type(user),
|
|
149
|
+
jti: SecureRandom.uuid,
|
|
150
|
+
iss: 'spree',
|
|
151
|
+
aud: 'store_api',
|
|
152
|
+
exp: Time.current.to_i + expiration
|
|
153
|
+
}
|
|
154
|
+
JWT.encode(payload, jwt_secret, 'HS256')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def determine_user_type(user)
|
|
158
|
+
if defined?(Spree.admin_user_class) && user.is_a?(Spree.admin_user_class)
|
|
159
|
+
'admin'
|
|
160
|
+
else
|
|
161
|
+
'customer'
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def jwt_secret
|
|
166
|
+
Spree::Api::Config[:jwt_secret_key].presence ||
|
|
167
|
+
Rails.application.credentials.jwt_secret_key ||
|
|
168
|
+
ENV['JWT_SECRET_KEY'] ||
|
|
169
|
+
Rails.application.secret_key_base
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Otp
|
|
5
|
+
# A provider accepted a login code, then reported it as failed (e.g. the
|
|
6
|
+
# number is not on WhatsApp). Sends the code by email when the backup is
|
|
7
|
+
# on; otherwise marks the attempt failed.
|
|
8
|
+
class DeliveryFailedSubscriber < Spree::Subscriber
|
|
9
|
+
subscribes_to 'notification.delivery_failed', async: false
|
|
10
|
+
|
|
11
|
+
def call(event)
|
|
12
|
+
payload = event.payload.to_h.stringify_keys
|
|
13
|
+
attempt = find_attempt(payload)
|
|
14
|
+
return if attempt.nil? || attempt.verified? || attempt.expired? || attempt.delivery_status == 'fallback_sent'
|
|
15
|
+
|
|
16
|
+
reason = payload['reason'].to_s.truncate(120)
|
|
17
|
+
Rails.logger.info("[SpreeOtpAuth] Code #{attempt.provider_message_id} failed for OTP attempt #{attempt.prefixed_id}")
|
|
18
|
+
|
|
19
|
+
config = attempt.store.effective_otp_configuration
|
|
20
|
+
result = if config.email_fallback_enabled?
|
|
21
|
+
Spree::Otp::Fallback.call(store: attempt.store, attempt: attempt, reason: reason, enforce_cooldown: false)
|
|
22
|
+
end
|
|
23
|
+
return if result&.success?
|
|
24
|
+
|
|
25
|
+
attempt.update_columns(delivery_status: 'failed', fallback_reason: reason.presence)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def find_attempt(payload)
|
|
31
|
+
scope = Spree::OtpAttempt.login.where(store_id: payload['store_id'])
|
|
32
|
+
if payload['delivery_id'].present?
|
|
33
|
+
delivery = Spree::NotificationDelivery.find_by(id: payload['delivery_id'])
|
|
34
|
+
by_ref = scope.find_by(delivery_message_ref: delivery.message_ref) if delivery
|
|
35
|
+
return by_ref if by_ref
|
|
36
|
+
end
|
|
37
|
+
return nil if payload['provider_message_id'].blank?
|
|
38
|
+
|
|
39
|
+
scope.find_by(provider_message_id: payload['provider_message_id'])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<% content_for :title do %>
|
|
2
|
+
<%= Spree.t('otp_auth.admin.title', default: 'OTP Authentication Settings') %>
|
|
3
|
+
<% end %>
|
|
4
|
+
|
|
5
|
+
<% content_for :page_title do %>
|
|
6
|
+
<%= Spree.t('otp_auth.admin.title', default: 'OTP Authentication Settings') %>
|
|
7
|
+
<% end %>
|
|
8
|
+
|
|
9
|
+
<div class="grid grid-cols-12 gap-6">
|
|
10
|
+
<div class="col-span-12 lg:col-span-8 lg:col-start-3">
|
|
11
|
+
<%= render 'spree/admin/shared/error_messages', target: @otp_configuration %>
|
|
12
|
+
|
|
13
|
+
<div class="card mb-6">
|
|
14
|
+
<div class="card-body flex items-center justify-between">
|
|
15
|
+
<div>
|
|
16
|
+
<h5 class="card-title text-base font-semibold text-gray-900 mb-1">
|
|
17
|
+
Store Context: <%= current_store.name %>
|
|
18
|
+
</h5>
|
|
19
|
+
<p class="text-xs text-gray-500 mb-0">
|
|
20
|
+
Settings configured here are strictly isolated to <strong><%= current_store.name %></strong> (Code: <%= current_store.code %>).
|
|
21
|
+
</p>
|
|
22
|
+
</div>
|
|
23
|
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
|
24
|
+
<%= current_store.code.upcase %>
|
|
25
|
+
</span>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<%= form_with model: @otp_configuration, scope: :otp_configuration, url: spree.admin_otp_configuration_path, method: :patch, id: 'otp-configuration-form' do |f| %>
|
|
30
|
+
<!-- Channel & Timing Section -->
|
|
31
|
+
<div class="card mb-6">
|
|
32
|
+
<div class="card-header border-b border-gray-200 px-6 py-4">
|
|
33
|
+
<h5 class="card-title text-sm font-semibold text-gray-900">
|
|
34
|
+
<%= Spree.t('otp_auth.admin.channel_header', default: 'Channel & Template Configuration') %>
|
|
35
|
+
</h5>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="card-body px-6 py-4 space-y-4">
|
|
38
|
+
<div>
|
|
39
|
+
<% channel_options = [['WhatsApp', 'whatsapp'], ['SMS', 'mobile'], ['Email', 'email']] %>
|
|
40
|
+
<%= f.spree_select :active_channel,
|
|
41
|
+
options_for_select(channel_options, @otp_configuration.active_channel),
|
|
42
|
+
{ label: Spree.t('otp_auth.admin.active_channel', default: 'Primary Verification Channel') } %>
|
|
43
|
+
<p class="text-xs text-gray-500 mt-1">
|
|
44
|
+
Customers sign in with their mobile number. WhatsApp and SMS send the code to that number; Email sends it to the account's email.
|
|
45
|
+
</p>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<% %w[whatsapp mobile].each do |otp_channel| %>
|
|
49
|
+
<% missing = @otp_configuration.missing_requirements(otp_channel) %>
|
|
50
|
+
<% provider_channel = Spree::Otp::Delivery.provider_channel(otp_channel) %>
|
|
51
|
+
<div class="flex items-start justify-between gap-4 rounded-md border border-gray-200 px-4 py-3" id="otp-channel-status-<%= otp_channel %>">
|
|
52
|
+
<div>
|
|
53
|
+
<p class="text-sm font-medium text-gray-900 mb-1"><%= otp_channel == 'mobile' ? 'SMS' : 'WhatsApp' %> providers</p>
|
|
54
|
+
<% if missing.empty? %>
|
|
55
|
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Ready</span>
|
|
56
|
+
<span class="text-xs text-gray-500 ml-1">
|
|
57
|
+
<%= SpreeNotificationProviders.channel_status(current_store, provider_channel).select { |s| s[:status] == :ready }.map { |s| SpreeNotificationProviders.provider(s[:provider_key])&.label }.compact.join(' → ') %>
|
|
58
|
+
</span>
|
|
59
|
+
<% else %>
|
|
60
|
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Not set up</span>
|
|
61
|
+
<span class="text-xs text-gray-500 ml-1">Missing: <%= missing.join(', ') %></span>
|
|
62
|
+
<% end %>
|
|
63
|
+
</div>
|
|
64
|
+
<%= link_to 'Notification providers', spree.admin_notification_providers_path(channel: provider_channel),
|
|
65
|
+
class: 'text-sm text-blue-600 hover:underline font-medium whitespace-nowrap' %>
|
|
66
|
+
</div>
|
|
67
|
+
<% end %>
|
|
68
|
+
|
|
69
|
+
<div class="flex items-start gap-3">
|
|
70
|
+
<%= f.check_box :fallback_channel,
|
|
71
|
+
{ checked: @otp_configuration.email_fallback_enabled?, class: 'mt-1 h-4 w-4 rounded border-gray-300' },
|
|
72
|
+
'email', 'none' %>
|
|
73
|
+
<div>
|
|
74
|
+
<%= f.label :fallback_channel, Spree.t('otp_auth.admin.email_backup', default: 'Email backup'), class: 'text-sm font-medium text-gray-900' %>
|
|
75
|
+
<p class="text-xs text-gray-500 mb-0">
|
|
76
|
+
If WhatsApp or SMS can't deliver the code, send it to the customer's email automatically, and offer
|
|
77
|
+
"Send code by email instead" on the code screen after the resend wait.
|
|
78
|
+
</p>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<% template_model = SpreeOtpAuth.email_template_model %>
|
|
83
|
+
<% if template_model %>
|
|
84
|
+
<% email_templates = template_model.column_names.include?('store_id') ? template_model.where(store_id: [current_store.id, nil]) : template_model.all %>
|
|
85
|
+
<div>
|
|
86
|
+
<%= f.spree_select :email_template_id,
|
|
87
|
+
options_for_select(
|
|
88
|
+
[['Default Responsive OTP Mailer', '']] +
|
|
89
|
+
email_templates.map { |t| [t.name, t.id] },
|
|
90
|
+
@otp_configuration.email_template_id
|
|
91
|
+
),
|
|
92
|
+
{ label: Spree.t('otp_auth.admin.email_template', default: 'Transactional Email Template') } %>
|
|
93
|
+
<p class="text-xs text-gray-500 mt-1">
|
|
94
|
+
Select an email template configured in <a href="/admin/email_templates" class="text-blue-600 hover:underline font-medium" target="_blank">Email Templates</a> with <code>{{otp_code}}</code>, <code>{{store_name}}</code>, and <code>{{expiry_minutes}}</code> variables, or use the built-in responsive mailer.
|
|
95
|
+
</p>
|
|
96
|
+
</div>
|
|
97
|
+
<% end %>
|
|
98
|
+
|
|
99
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
|
|
100
|
+
<div>
|
|
101
|
+
<%= f.spree_select :otp_length,
|
|
102
|
+
options_for_select([
|
|
103
|
+
['6 Digits (Recommended)', 6],
|
|
104
|
+
['4 Digits', 4]
|
|
105
|
+
], @otp_configuration.otp_length),
|
|
106
|
+
{ label: Spree.t('otp_auth.admin.otp_length', default: 'OTP Length') } %>
|
|
107
|
+
</div>
|
|
108
|
+
<div>
|
|
109
|
+
<%= f.spree_number_field :otp_expiry_seconds,
|
|
110
|
+
label: Spree.t('otp_auth.admin.otp_expiry_seconds', default: 'OTP Expiry (seconds)'),
|
|
111
|
+
min: 60,
|
|
112
|
+
step: 10 %>
|
|
113
|
+
<p class="text-xs text-gray-400 mt-1">Duration before OTP expires (min 60s, default 600s).</p>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
118
|
+
<div>
|
|
119
|
+
<%= f.spree_number_field :resend_cooldown_seconds,
|
|
120
|
+
label: Spree.t('otp_auth.admin.resend_cooldown_seconds', default: 'Resend Cooldown (seconds)'),
|
|
121
|
+
min: 10,
|
|
122
|
+
step: 5 %>
|
|
123
|
+
<p class="text-xs text-gray-400 mt-1">Wait duration before resend OTP is unlocked (default 30s).</p>
|
|
124
|
+
</div>
|
|
125
|
+
<div>
|
|
126
|
+
<%= f.spree_number_field :fallback_delay_seconds,
|
|
127
|
+
label: Spree.t('otp_auth.admin.fallback_delay_seconds', default: 'Fallback Option Delay (seconds)'),
|
|
128
|
+
min: 10,
|
|
129
|
+
step: 5 %>
|
|
130
|
+
<p class="text-xs text-gray-400 mt-1">Threshold before secondary fallback option button is displayed (default 20s).</p>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div class="form-actions mt-6 flex justify-end">
|
|
137
|
+
<%= f.submit Spree.t(:update, default: 'Update Settings'), class: 'btn btn-primary' %>
|
|
138
|
+
</div>
|
|
139
|
+
<% end %>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title><%= @code %> is your <%= @store.name %> verification code</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
background-color: #f7f7f8;
|
|
12
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
13
|
+
color: #111827;
|
|
14
|
+
}
|
|
15
|
+
.container {
|
|
16
|
+
max-width: 520px;
|
|
17
|
+
margin: 40px auto;
|
|
18
|
+
background: #ffffff;
|
|
19
|
+
border-radius: 8px;
|
|
20
|
+
border: 1px solid #e5e7eb;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
}
|
|
23
|
+
.header {
|
|
24
|
+
padding: 24px;
|
|
25
|
+
text-align: center;
|
|
26
|
+
border-bottom: 1px solid #f3f4f6;
|
|
27
|
+
}
|
|
28
|
+
.store-name {
|
|
29
|
+
font-size: 20px;
|
|
30
|
+
font-weight: 700;
|
|
31
|
+
color: #111827;
|
|
32
|
+
margin: 0;
|
|
33
|
+
}
|
|
34
|
+
.content {
|
|
35
|
+
padding: 32px 24px;
|
|
36
|
+
text-align: center;
|
|
37
|
+
}
|
|
38
|
+
.title {
|
|
39
|
+
font-size: 18px;
|
|
40
|
+
font-weight: 600;
|
|
41
|
+
margin: 0 0 12px 0;
|
|
42
|
+
color: #111827;
|
|
43
|
+
}
|
|
44
|
+
.subtitle {
|
|
45
|
+
font-size: 14px;
|
|
46
|
+
color: #6b7280;
|
|
47
|
+
margin: 0 0 24px 0;
|
|
48
|
+
line-height: 1.5;
|
|
49
|
+
}
|
|
50
|
+
.otp-box {
|
|
51
|
+
display: inline-block;
|
|
52
|
+
font-size: 32px;
|
|
53
|
+
font-weight: 800;
|
|
54
|
+
letter-spacing: 6px;
|
|
55
|
+
padding: 14px 28px;
|
|
56
|
+
background: #f3f4f6;
|
|
57
|
+
border-radius: 6px;
|
|
58
|
+
color: #111827;
|
|
59
|
+
margin-bottom: 24px;
|
|
60
|
+
font-family: monospace;
|
|
61
|
+
}
|
|
62
|
+
.expiry-notice {
|
|
63
|
+
font-size: 13px;
|
|
64
|
+
color: #9ca3af;
|
|
65
|
+
margin: 0 0 8px 0;
|
|
66
|
+
}
|
|
67
|
+
.security-warning {
|
|
68
|
+
font-size: 12px;
|
|
69
|
+
color: #9ca3af;
|
|
70
|
+
margin: 0;
|
|
71
|
+
line-height: 1.4;
|
|
72
|
+
}
|
|
73
|
+
.footer {
|
|
74
|
+
padding: 16px 24px;
|
|
75
|
+
background: #f9fafb;
|
|
76
|
+
border-top: 1px solid #f3f4f6;
|
|
77
|
+
text-align: center;
|
|
78
|
+
font-size: 12px;
|
|
79
|
+
color: #9ca3af;
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
82
|
+
</head>
|
|
83
|
+
<body>
|
|
84
|
+
<div class="container">
|
|
85
|
+
<div class="header">
|
|
86
|
+
<h1 class="store-name"><%= @store.name %></h1>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="content">
|
|
89
|
+
<h2 class="title">Verification Code</h2>
|
|
90
|
+
<p class="subtitle">Use the code below to complete your sign-in to <%= @store.name %>.</p>
|
|
91
|
+
<div class="otp-box"><%= @code %></div>
|
|
92
|
+
<p class="expiry-notice">This code expires in <%= @expiry_minutes %> minutes.</p>
|
|
93
|
+
<p class="security-warning">If you did not request this login code, please safely disregard this email.</p>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="footer">
|
|
96
|
+
© <%= Time.current.year %> <%= @store.name %>. All rights reserved.
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</body>
|
|
100
|
+
</html>
|