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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +42 -0
  3. data/LICENSE +29 -0
  4. data/README.md +190 -0
  5. data/app/controllers/spree/admin/otp_configurations_controller.rb +50 -0
  6. data/app/controllers/spree/api/v3/store/otp_controller.rb +301 -0
  7. data/app/mailers/spree/otp_mailer.rb +57 -0
  8. data/app/models/spree/otp_attempt.rb +82 -0
  9. data/app/models/spree/otp_configuration.rb +90 -0
  10. data/app/models/spree/otp_store_decorator.rb +15 -0
  11. data/app/models/spree/otp_user_decorator.rb +59 -0
  12. data/app/services/spree/otp/code_sent_payload.rb +55 -0
  13. data/app/services/spree/otp/delivery.rb +77 -0
  14. data/app/services/spree/otp/fallback.rb +86 -0
  15. data/app/services/spree/otp/generate.rb +198 -0
  16. data/app/services/spree/otp/identity.rb +81 -0
  17. data/app/services/spree/otp/marketing_consent.rb +35 -0
  18. data/app/services/spree/otp/rate_limiter.rb +82 -0
  19. data/app/services/spree/otp/resend.rb +27 -0
  20. data/app/services/spree/otp/verify.rb +173 -0
  21. data/app/subscribers/spree/otp/delivery_failed_subscriber.rb +43 -0
  22. data/app/views/spree/admin/otp_configurations/edit.html.erb +141 -0
  23. data/app/views/spree/otp_mailer/send_otp.html.erb +100 -0
  24. data/app/views/spree/otp_mailer/send_otp.text.erb +8 -0
  25. data/config/locales/en.yml +39 -0
  26. data/config/routes.rb +22 -0
  27. data/db/migrate/20260927000001_create_spree_otp_configurations.spree_otp_auth.rb +20 -0
  28. data/db/migrate/20260927000002_create_spree_otp_attempts.spree_otp_auth.rb +26 -0
  29. data/db/migrate/20260927000003_add_otp_fields_to_spree_users.spree_otp_auth.rb +12 -0
  30. data/db/migrate/20260927000004_update_otp_schema_for_mandatory_mobile_and_templates.spree_otp_auth.rb +28 -0
  31. data/db/migrate/20261018000001_add_gupshup_fields_to_spree_otp_configurations.spree_otp_auth.rb +18 -0
  32. data/db/migrate/20261018000002_add_delivery_fields_to_spree_otp_attempts.spree_otp_auth.rb +18 -0
  33. data/db/migrate/20261018000003_add_email_marketing_decided_at_to_spree_users.spree_otp_auth.rb +30 -0
  34. data/db/migrate/20261019000004_add_delivery_message_ref_to_spree_otp_attempts.spree_otp_auth.rb +8 -0
  35. data/lib/generators/spree_otp_auth/install/install_generator.rb +27 -0
  36. data/lib/spree_otp_auth/engine.rb +44 -0
  37. data/lib/spree_otp_auth/version.rb +5 -0
  38. data/lib/spree_otp_auth.rb +28 -0
  39. data/spree_otp_auth.gemspec +39 -0
  40. 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
+ &copy; <%= Time.current.year %> <%= @store.name %>. All rights reserved.
97
+ </div>
98
+ </div>
99
+ </body>
100
+ </html>