maquina-generators 0.1.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/LICENSE.txt +21 -0
- data/README.md +158 -0
- data/Rakefile +10 -0
- data/lib/generators/maquina/clave/USAGE +11 -0
- data/lib/generators/maquina/clave/clave_generator.rb +184 -0
- data/lib/generators/maquina/clave/templates/app/controllers/concerns/authentication.rb.tt +63 -0
- data/lib/generators/maquina/clave/templates/app/controllers/registration/verification_resends_controller.rb.tt +38 -0
- data/lib/generators/maquina/clave/templates/app/controllers/registration/verifications_controller.rb.tt +51 -0
- data/lib/generators/maquina/clave/templates/app/controllers/registrations_controller.rb.tt +63 -0
- data/lib/generators/maquina/clave/templates/app/controllers/session/verification_resends_controller.rb.tt +44 -0
- data/lib/generators/maquina/clave/templates/app/controllers/session/verifications_controller.rb.tt +55 -0
- data/lib/generators/maquina/clave/templates/app/controllers/sessions_controller.rb.tt +56 -0
- data/lib/generators/maquina/clave/templates/app/helpers/authentication_helper.rb.tt +20 -0
- data/lib/generators/maquina/clave/templates/app/jobs/authentication_cleanup_job.rb.tt +13 -0
- data/lib/generators/maquina/clave/templates/app/mailers/verification_mailer.rb.tt +15 -0
- data/lib/generators/maquina/clave/templates/app/models/current.rb.tt +4 -0
- data/lib/generators/maquina/clave/templates/app/models/email_verification.rb.tt +40 -0
- data/lib/generators/maquina/clave/templates/app/models/session.rb.tt +18 -0
- data/lib/generators/maquina/clave/templates/app/models/user.rb.tt +38 -0
- data/lib/generators/maquina/clave/templates/app/views/registration/verifications/new.html.erb.tt +42 -0
- data/lib/generators/maquina/clave/templates/app/views/registrations/new.html.erb.tt +38 -0
- data/lib/generators/maquina/clave/templates/app/views/session/verifications/new.html.erb.tt +42 -0
- data/lib/generators/maquina/clave/templates/app/views/sessions/new.html.erb.tt +39 -0
- data/lib/generators/maquina/clave/templates/app/views/verification_mailer/verification_code.html.erb.tt +37 -0
- data/lib/generators/maquina/clave/templates/app/views/verification_mailer/verification_code.text.erb.tt +11 -0
- data/lib/generators/maquina/clave/templates/config/locales/clave.en.yml +101 -0
- data/lib/generators/maquina/clave/templates/config/locales/clave.es.yml +101 -0
- data/lib/generators/maquina/clave/templates/migration_create_email_verifications.rb.tt +17 -0
- data/lib/generators/maquina/clave/templates/migration_create_sessions.rb.tt +12 -0
- data/lib/generators/maquina/clave/templates/migration_create_users.rb.tt +16 -0
- data/lib/generators/maquina/clave/templates/test/test_helpers/session_test_helper.rb.tt +19 -0
- data/lib/generators/maquina/mission_control_jobs/USAGE +14 -0
- data/lib/generators/maquina/mission_control_jobs/mission_control_jobs_generator.rb +75 -0
- data/lib/generators/maquina/mission_control_jobs/templates/app/controllers/backstage_controller.rb.tt +4 -0
- data/lib/generators/maquina/mission_control_jobs/templates/config/initializers/mission_control.rb.tt +10 -0
- data/lib/generators/maquina/solid_errors/USAGE +15 -0
- data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +85 -0
- data/lib/generators/maquina/solid_errors/templates/app/controllers/backstage_controller.rb.tt +4 -0
- data/lib/generators/maquina/solid_errors/templates/config/initializers/solid_errors.rb.tt +10 -0
- data/lib/maquina_generators/version.rb +3 -0
- data/lib/maquina_generators.rb +1 -0
- data/test/generators/maquina/clave_generator_test.rb +187 -0
- data/test/generators/maquina/mission_control_jobs_generator_test.rb +97 -0
- data/test/generators/maquina/solid_errors_generator_test.rb +97 -0
- data/test/test_helper.rb +7 -0
- data/test/tmp/Gemfile +3 -0
- data/test/tmp/app/controllers/backstage_controller.rb +4 -0
- data/test/tmp/config/initializers/solid_errors.rb +10 -0
- data/test/tmp/config/routes.rb +3 -0
- metadata +134 -0
data/lib/generators/maquina/clave/templates/app/controllers/session/verifications_controller.rb.tt
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
class Session::VerificationsController < ApplicationController
|
|
2
|
+
allow_unauthenticated_access
|
|
3
|
+
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_verification_path, alert: t("flash.general.rate_limited") }
|
|
4
|
+
|
|
5
|
+
# GET /session/verification/new
|
|
6
|
+
def new
|
|
7
|
+
@verification = EmailVerification.find_by(id: session[:pending_verification_id])
|
|
8
|
+
@email = @verification&.email || session[:pending_login_email]
|
|
9
|
+
redirect_to new_session_path unless @email
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# POST /session/verification
|
|
13
|
+
def create
|
|
14
|
+
@verification = EmailVerification.find_by(id: session[:pending_verification_id])
|
|
15
|
+
@email = @verification&.email || session[:pending_login_email]
|
|
16
|
+
|
|
17
|
+
# Generic error if no verification (user doesn't exist or blocked)
|
|
18
|
+
unless @verification
|
|
19
|
+
flash.now[:alert] = t("flash.sessions.invalid_code")
|
|
20
|
+
render :new, status: :unprocessable_entity
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if @verification.expired?
|
|
25
|
+
flash.now[:alert] = t("flash.sessions.code_expired")
|
|
26
|
+
render :new, status: :unprocessable_entity
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if @verification.code != params[:code]&.upcase&.strip
|
|
31
|
+
@verification.increment!(:attempts)
|
|
32
|
+
Rails.logger.warn "[AUTH] Invalid verification code attempt for #{@email} from #{request.remote_ip} (attempt #{@verification.attempts})"
|
|
33
|
+
flash.now[:alert] = t("flash.sessions.invalid_code")
|
|
34
|
+
render :new, status: :unprocessable_entity
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
user = User.find_by(email_address: @verification.email)
|
|
39
|
+
|
|
40
|
+
# Double-check user exists and not blocked
|
|
41
|
+
if user.nil? || user.blocked?
|
|
42
|
+
flash.now[:alert] = t("flash.sessions.invalid_code")
|
|
43
|
+
render :new, status: :unprocessable_entity
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@verification.mark_verified!
|
|
48
|
+
session.delete(:pending_verification_id)
|
|
49
|
+
session.delete(:pending_login_email)
|
|
50
|
+
|
|
51
|
+
Rails.logger.info "[AUTH] Successful login for #{user.email_address} from #{request.remote_ip}"
|
|
52
|
+
start_new_session_for(user)
|
|
53
|
+
redirect_to after_authentication_url, notice: t("flash.sessions.create.success")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
class SessionsController < ApplicationController
|
|
2
|
+
allow_unauthenticated_access only: %i[new create]
|
|
3
|
+
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: t("flash.general.rate_limited") }
|
|
4
|
+
|
|
5
|
+
# GET /session/new
|
|
6
|
+
def new
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# POST /session
|
|
10
|
+
def create
|
|
11
|
+
email = params[:email_address]&.downcase&.strip
|
|
12
|
+
user = User.find_by(email_address: email)
|
|
13
|
+
|
|
14
|
+
# Check cooldown regardless of user existence
|
|
15
|
+
recent_verification = EmailVerification.for_email(email).recent.first
|
|
16
|
+
if recent_verification
|
|
17
|
+
session[:pending_verification_id] = recent_verification.id
|
|
18
|
+
session[:pending_login_email] = email
|
|
19
|
+
redirect_to new_session_verification_path,
|
|
20
|
+
notice: t("flash.sessions.code_already_sent", minutes: recent_verification.minutes_until_resend)
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if user && !user.blocked?
|
|
25
|
+
# User exists and not blocked - send verification
|
|
26
|
+
verification = EmailVerification.create!(
|
|
27
|
+
email: email,
|
|
28
|
+
code: SecureRandom.hex(3).upcase,
|
|
29
|
+
verification_type: "login",
|
|
30
|
+
locale: user.locale || I18n.default_locale.to_s,
|
|
31
|
+
expires_at: 15.minutes.from_now
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
Rails.logger.info "[AUTH] Verification code sent for login #{email} from #{request.remote_ip}"
|
|
35
|
+
VerificationMailer.verification_code(verification).deliver_later
|
|
36
|
+
session[:pending_verification_id] = verification.id
|
|
37
|
+
else
|
|
38
|
+
# No user OR blocked - don't send email, but show same UI
|
|
39
|
+
if user&.blocked?
|
|
40
|
+
Rails.logger.info "[AUTH] Login attempted for blocked user #{email} from #{request.remote_ip}"
|
|
41
|
+
else
|
|
42
|
+
Rails.logger.info "[AUTH] Login attempted for non-existent email #{email} from #{request.remote_ip}"
|
|
43
|
+
end
|
|
44
|
+
session[:pending_login_email] = email
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
redirect_to new_session_verification_path
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# DELETE /session
|
|
51
|
+
def destroy
|
|
52
|
+
Rails.logger.info "[AUTH] Logout for user #{Current.session.user.email_address} from #{request.remote_ip}"
|
|
53
|
+
terminate_session
|
|
54
|
+
redirect_to new_session_path, notice: t("flash.sessions.destroy.success"), status: :see_other
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module AuthenticationHelper
|
|
2
|
+
# Masks an email address for privacy display
|
|
3
|
+
# "mario@example.com" -> "ma***@example.com"
|
|
4
|
+
# "jo@example.com" -> "j*@example.com"
|
|
5
|
+
# "a@example.com" -> "a@example.com"
|
|
6
|
+
def mask_email(email)
|
|
7
|
+
return "" if email.blank?
|
|
8
|
+
|
|
9
|
+
local, domain = email.split("@")
|
|
10
|
+
return email if local.nil? || domain.nil?
|
|
11
|
+
|
|
12
|
+
masked_local = if local.length <= 2
|
|
13
|
+
local[0] + "*" * (local.length - 1)
|
|
14
|
+
else
|
|
15
|
+
local[0..1] + "*" * (local.length - 2)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
"#{masked_local}@#{domain}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class AuthenticationCleanupJob < ApplicationJob
|
|
2
|
+
queue_as :default
|
|
3
|
+
|
|
4
|
+
def perform
|
|
5
|
+
# Delete email verifications older than 24 hours
|
|
6
|
+
old_verifications_count = EmailVerification.where("created_at < ?", 24.hours.ago).delete_all
|
|
7
|
+
Rails.logger.info "AuthenticationCleanupJob: Deleted #{old_verifications_count} old email verifications"
|
|
8
|
+
|
|
9
|
+
# Delete expired sessions
|
|
10
|
+
expired_sessions_count = Session.cleanup_expired!
|
|
11
|
+
Rails.logger.info "AuthenticationCleanupJob: Deleted #{expired_sessions_count} expired sessions"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class VerificationMailer < ApplicationMailer
|
|
2
|
+
default from: "App <noreply@example.com>"
|
|
3
|
+
|
|
4
|
+
def verification_code(email_verification)
|
|
5
|
+
@verification = email_verification
|
|
6
|
+
locale = email_verification.locale || I18n.default_locale.to_s
|
|
7
|
+
|
|
8
|
+
I18n.with_locale(locale) do
|
|
9
|
+
mail(
|
|
10
|
+
to: email_verification.email,
|
|
11
|
+
subject: I18n.t("verification_mailer.verification_code.subject")
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
class EmailVerification < ApplicationRecord
|
|
2
|
+
VERIFICATION_TYPES = %w[signup login].freeze
|
|
3
|
+
COOLDOWN_MINUTES = 15
|
|
4
|
+
|
|
5
|
+
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
6
|
+
validates :code, presence: true
|
|
7
|
+
validates :verification_type, presence: true, inclusion: {in: VERIFICATION_TYPES}
|
|
8
|
+
validates :expires_at, presence: true
|
|
9
|
+
|
|
10
|
+
normalizes :email, with: ->(email) { email.strip.downcase }
|
|
11
|
+
normalizes :code, with: ->(code) { code.strip.upcase }
|
|
12
|
+
|
|
13
|
+
scope :pending, -> { where(verified_at: nil) }
|
|
14
|
+
scope :expired, -> { where("expires_at < ?", Time.current) }
|
|
15
|
+
scope :active, -> { pending.where("expires_at >= ?", Time.current) }
|
|
16
|
+
scope :for_email, ->(email) { where("LOWER(email) = ?", email.to_s.downcase) }
|
|
17
|
+
scope :recent, -> { pending.where("created_at > ?", COOLDOWN_MINUTES.minutes.ago) }
|
|
18
|
+
|
|
19
|
+
def expired?
|
|
20
|
+
expires_at < Time.current
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def verified?
|
|
24
|
+
verified_at.present?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def can_resend?
|
|
28
|
+
created_at <= COOLDOWN_MINUTES.minutes.ago
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def minutes_until_resend
|
|
32
|
+
return 0 if can_resend?
|
|
33
|
+
|
|
34
|
+
((created_at + COOLDOWN_MINUTES.minutes - Time.current) / 60).ceil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def mark_verified!
|
|
38
|
+
update!(verified_at: Time.current)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class Session < ApplicationRecord
|
|
2
|
+
belongs_to :user
|
|
3
|
+
|
|
4
|
+
scope :active, -> { where("expires_at > ? OR expires_at IS NULL", Time.current) }
|
|
5
|
+
scope :expired, -> { where("expires_at <= ? AND expires_at IS NOT NULL", Time.current) }
|
|
6
|
+
|
|
7
|
+
def self.cleanup_expired!
|
|
8
|
+
expired.delete_all
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def expired?
|
|
12
|
+
expires_at.present? && expires_at < Time.current
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def active?
|
|
16
|
+
!expired?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class User < ApplicationRecord
|
|
2
|
+
has_secure_password
|
|
3
|
+
has_many :sessions, dependent: :destroy
|
|
4
|
+
|
|
5
|
+
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
|
6
|
+
|
|
7
|
+
validates :email_address, uniqueness: true
|
|
8
|
+
validate :email_cannot_contain_plus
|
|
9
|
+
|
|
10
|
+
scope :active, -> { where(blocked_at: nil) }
|
|
11
|
+
scope :blocked, -> { where.not(blocked_at: nil) }
|
|
12
|
+
|
|
13
|
+
def self.generate_secure_password
|
|
14
|
+
SecureRandom.hex(10)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def blocked?
|
|
18
|
+
blocked_at.present?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def block!(reason: nil)
|
|
22
|
+
update!(blocked_at: Time.current, blocked_reason: reason)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def unblock!
|
|
26
|
+
update!(blocked_at: nil, blocked_reason: nil)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def email_cannot_contain_plus
|
|
32
|
+
return if email_address.blank?
|
|
33
|
+
|
|
34
|
+
if email_address.include?("+")
|
|
35
|
+
errors.add(:email_address, :plus_not_allowed)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/generators/maquina/clave/templates/app/views/registration/verifications/new.html.erb.tt
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<div class="flex flex-1 items-center justify-center px-4 py-8">
|
|
2
|
+
<div class="w-full max-w-md">
|
|
3
|
+
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-8">
|
|
4
|
+
<h1 class="text-2xl font-bold text-slate-800 text-center mb-4">
|
|
5
|
+
<%%= t(".title") %>
|
|
6
|
+
</h1>
|
|
7
|
+
|
|
8
|
+
<p class="text-center text-slate-600 mb-8">
|
|
9
|
+
<%%= t(".sent_to", email: mask_email(@email)) %>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<%%= form_with url: registration_verification_path, method: :post, class: "space-y-6" do |form| %>
|
|
13
|
+
<div>
|
|
14
|
+
<%%= form.label :code, t(".enter_code"), class: "block text-sm font-medium text-slate-700 mb-2 text-center" %>
|
|
15
|
+
<%%= form.text_field :code,
|
|
16
|
+
required: true,
|
|
17
|
+
autofocus: true,
|
|
18
|
+
maxlength: 6,
|
|
19
|
+
autocomplete: "one-time-code",
|
|
20
|
+
inputmode: "text",
|
|
21
|
+
placeholder: "ABC123",
|
|
22
|
+
class: "w-full px-4 py-4 text-center text-2xl font-mono tracking-widest uppercase border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" %>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<p class="text-sm text-slate-500 text-center">
|
|
26
|
+
<%%= t(".check_spam") %>
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
<div>
|
|
30
|
+
<%%= form.submit t(".submit"), class: "w-full px-4 py-3 text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors cursor-pointer" %>
|
|
31
|
+
</div>
|
|
32
|
+
<%% end %>
|
|
33
|
+
|
|
34
|
+
<div class="mt-6 text-center">
|
|
35
|
+
<p class="text-sm text-slate-500 mb-2">
|
|
36
|
+
<%%= t(".didnt_receive") %>
|
|
37
|
+
</p>
|
|
38
|
+
<%%= button_to t(".resend"), registration_verification_resend_path, method: :post, class: "text-sm font-medium text-indigo-600 hover:text-indigo-500" %>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<div class="flex flex-1 items-center justify-center px-4 py-8">
|
|
2
|
+
<div class="w-full max-w-md">
|
|
3
|
+
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-8">
|
|
4
|
+
<h1 class="text-2xl font-bold text-slate-800 text-center mb-8">
|
|
5
|
+
<%%= t(".title") %>
|
|
6
|
+
</h1>
|
|
7
|
+
|
|
8
|
+
<%%= form_with url: registration_path, method: :post, class: "space-y-6" do |form| %>
|
|
9
|
+
<div>
|
|
10
|
+
<%%= form.label :email_address, t(".email"), class: "block text-sm font-medium text-slate-700 mb-2" %>
|
|
11
|
+
<%%= form.email_field :email_address,
|
|
12
|
+
required: true,
|
|
13
|
+
autofocus: true,
|
|
14
|
+
autocomplete: "email",
|
|
15
|
+
placeholder: t(".email_placeholder"),
|
|
16
|
+
class: "w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<p class="text-sm text-slate-600">
|
|
20
|
+
<%%= t(".email_instructions") %>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<p class="text-sm text-slate-500">
|
|
24
|
+
<%%= t(".spam_note") %>
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<div>
|
|
28
|
+
<%%= form.submit t(".submit"), class: "w-full px-4 py-3 text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors cursor-pointer" %>
|
|
29
|
+
</div>
|
|
30
|
+
<%% end %>
|
|
31
|
+
|
|
32
|
+
<p class="mt-6 text-center text-sm text-slate-500">
|
|
33
|
+
<%%= t(".have_account") %>
|
|
34
|
+
<%%= link_to t(".sign_in"), new_session_path, class: "font-medium text-indigo-600 hover:text-indigo-500" %>
|
|
35
|
+
</p>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<div class="flex flex-1 items-center justify-center px-4 py-8">
|
|
2
|
+
<div class="w-full max-w-md">
|
|
3
|
+
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-8">
|
|
4
|
+
<h1 class="text-2xl font-bold text-slate-800 text-center mb-4">
|
|
5
|
+
<%%= t(".title") %>
|
|
6
|
+
</h1>
|
|
7
|
+
|
|
8
|
+
<p class="text-center text-slate-600 mb-8">
|
|
9
|
+
<%%= t(".sent_to", email: mask_email(@email)) %>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<%%= form_with url: session_verification_path, method: :post, class: "space-y-6" do |form| %>
|
|
13
|
+
<div>
|
|
14
|
+
<%%= form.label :code, t(".enter_code"), class: "block text-sm font-medium text-slate-700 mb-2 text-center" %>
|
|
15
|
+
<%%= form.text_field :code,
|
|
16
|
+
required: true,
|
|
17
|
+
autofocus: true,
|
|
18
|
+
maxlength: 6,
|
|
19
|
+
autocomplete: "one-time-code",
|
|
20
|
+
inputmode: "text",
|
|
21
|
+
placeholder: "ABC123",
|
|
22
|
+
class: "w-full px-4 py-4 text-center text-2xl font-mono tracking-widest uppercase border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" %>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<p class="text-sm text-slate-500 text-center">
|
|
26
|
+
<%%= t(".check_spam") %>
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
<div>
|
|
30
|
+
<%%= form.submit t(".submit"), class: "w-full px-4 py-3 text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors cursor-pointer" %>
|
|
31
|
+
</div>
|
|
32
|
+
<%% end %>
|
|
33
|
+
|
|
34
|
+
<div class="mt-6 text-center">
|
|
35
|
+
<p class="text-sm text-slate-500 mb-2">
|
|
36
|
+
<%%= t(".didnt_receive") %>
|
|
37
|
+
</p>
|
|
38
|
+
<%%= button_to t(".resend"), session_verification_resend_path, method: :post, class: "text-sm font-medium text-indigo-600 hover:text-indigo-500" %>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<div class="flex flex-1 items-center justify-center px-4 py-8">
|
|
2
|
+
<div class="w-full max-w-md">
|
|
3
|
+
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-8">
|
|
4
|
+
<h1 class="text-2xl font-bold text-slate-800 text-center mb-8">
|
|
5
|
+
<%%= t(".title") %>
|
|
6
|
+
</h1>
|
|
7
|
+
|
|
8
|
+
<%%= form_with url: session_path, method: :post, class: "space-y-6" do |form| %>
|
|
9
|
+
<div>
|
|
10
|
+
<%%= form.label :email_address, t(".email"), class: "block text-sm font-medium text-slate-700 mb-2" %>
|
|
11
|
+
<%%= form.email_field :email_address,
|
|
12
|
+
required: true,
|
|
13
|
+
autofocus: true,
|
|
14
|
+
autocomplete: "email",
|
|
15
|
+
placeholder: t(".email_placeholder"),
|
|
16
|
+
value: params[:email_address],
|
|
17
|
+
class: "w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" %>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<p class="text-sm text-slate-600">
|
|
21
|
+
<%%= t(".email_instructions") %>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<p class="text-sm text-slate-500">
|
|
25
|
+
<%%= t(".spam_note") %>
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
<div>
|
|
29
|
+
<%%= form.submit t(".submit"), class: "w-full px-4 py-3 text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors cursor-pointer" %>
|
|
30
|
+
</div>
|
|
31
|
+
<%% end %>
|
|
32
|
+
|
|
33
|
+
<p class="mt-6 text-center text-sm text-slate-500">
|
|
34
|
+
<%%= t(".no_account") %>
|
|
35
|
+
<%%= link_to t(".sign_up"), new_registration_path, class: "font-medium text-indigo-600 hover:text-indigo-500" %>
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
|
2
|
+
<div style="background-color: #ffffff; border-radius: 8px; padding: 40px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
|
|
3
|
+
<!-- Logo -->
|
|
4
|
+
<div style="text-align: center; margin-bottom: 32px;">
|
|
5
|
+
<span style="font-size: 28px; font-weight: 700; color: #4F46E5;"><%%= Rails.application.class.module_parent_name %></span>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<!-- Title -->
|
|
9
|
+
<h1 style="text-align: center; font-size: 24px; font-weight: 600; color: #1f2937; margin-bottom: 24px;">
|
|
10
|
+
<%%= t("verification_mailer.verification_code.title") %>
|
|
11
|
+
</h1>
|
|
12
|
+
|
|
13
|
+
<!-- Instructions -->
|
|
14
|
+
<p style="text-align: center; font-size: 16px; color: #4b5563; margin-bottom: 32px;">
|
|
15
|
+
<%%= t("verification_mailer.verification_code.instructions") %>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<!-- Code Display -->
|
|
19
|
+
<div style="background-color: #f3f4f6; border-radius: 8px; padding: 24px; text-align: center; margin-bottom: 32px;">
|
|
20
|
+
<span style="font-family: 'Courier New', Courier, monospace; font-size: 36px; font-weight: 700; letter-spacing: 8px; color: #1f2937;">
|
|
21
|
+
<%%= @verification.code %>
|
|
22
|
+
</span>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<!-- Expiry Note -->
|
|
26
|
+
<p style="text-align: center; font-size: 14px; color: #6b7280; margin-bottom: 32px;">
|
|
27
|
+
<%%= t("verification_mailer.verification_code.expires_in") %>
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<!-- Ignore Note -->
|
|
31
|
+
<div style="border-top: 1px solid #e5e7eb; padding-top: 24px;">
|
|
32
|
+
<p style="text-align: center; font-size: 13px; color: #9ca3af;">
|
|
33
|
+
<%%= t("verification_mailer.verification_code.ignore_if_not_you") %>
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%%= t("verification_mailer.verification_code.title") %>
|
|
2
|
+
|
|
3
|
+
<%%= t("verification_mailer.verification_code.instructions") %>
|
|
4
|
+
|
|
5
|
+
<%%= @verification.code %>
|
|
6
|
+
|
|
7
|
+
<%%= t("verification_mailer.verification_code.expires_in") %>
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<%%= t("verification_mailer.verification_code.ignore_if_not_you") %>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
en:
|
|
2
|
+
flash:
|
|
3
|
+
sessions:
|
|
4
|
+
create:
|
|
5
|
+
success: "You have signed in successfully."
|
|
6
|
+
destroy:
|
|
7
|
+
success: "You have signed out successfully."
|
|
8
|
+
code_sent: "We sent you a verification code."
|
|
9
|
+
invalid_code: "The code is incorrect. Please try again."
|
|
10
|
+
code_expired: "The code has expired. Request a new one."
|
|
11
|
+
code_resent: "We sent you a new code."
|
|
12
|
+
cooldown: "You must wait %{minutes} minutes before requesting another code."
|
|
13
|
+
code_already_sent: "We already sent you a code. Check your email (including spam). You can request a new one in %{minutes} minutes."
|
|
14
|
+
registrations:
|
|
15
|
+
create:
|
|
16
|
+
success: "Your account has been created. Welcome!"
|
|
17
|
+
code_sent: "We sent a verification code to your email."
|
|
18
|
+
email_plus_not_allowed: "Email addresses with '+' are not allowed. Use your main email without aliases."
|
|
19
|
+
email_taken: "An account with this email already exists. Would you like to sign in?"
|
|
20
|
+
invalid_code: "The code is incorrect. Please try again."
|
|
21
|
+
code_expired: "The code has expired. Request a new one."
|
|
22
|
+
code_resent: "We sent you a new code."
|
|
23
|
+
cooldown: "You must wait %{minutes} minutes before requesting another code."
|
|
24
|
+
code_already_sent: "We already sent you a code. Check your email (including spam). You can request a new one in %{minutes} minutes."
|
|
25
|
+
general:
|
|
26
|
+
unauthorized: "You must sign in to access this page."
|
|
27
|
+
forbidden: "You are not authorized to perform this action."
|
|
28
|
+
not_found: "The resource you're looking for doesn't exist."
|
|
29
|
+
rate_limited: "Too many attempts. Please wait a few minutes and try again."
|
|
30
|
+
|
|
31
|
+
sessions:
|
|
32
|
+
new:
|
|
33
|
+
title: "Sign in"
|
|
34
|
+
email: "Email address"
|
|
35
|
+
email_placeholder: "you@example.com"
|
|
36
|
+
email_instructions: "Enter your email and we'll send you a 6-digit code."
|
|
37
|
+
spam_note: "Check your spam folder if you don't receive the email."
|
|
38
|
+
submit: "Send code"
|
|
39
|
+
no_account: "Don't have an account?"
|
|
40
|
+
sign_up: "Sign up"
|
|
41
|
+
|
|
42
|
+
session:
|
|
43
|
+
verifications:
|
|
44
|
+
new:
|
|
45
|
+
title: "Verify your identity"
|
|
46
|
+
sent_to: "If you have an account, we sent a code to %{email}"
|
|
47
|
+
enter_code: "Enter the code"
|
|
48
|
+
submit: "Sign in"
|
|
49
|
+
check_spam: "Check your spam folder."
|
|
50
|
+
didnt_receive: "Didn't receive the code?"
|
|
51
|
+
resend: "Resend code"
|
|
52
|
+
|
|
53
|
+
registrations:
|
|
54
|
+
new:
|
|
55
|
+
title: "Create account"
|
|
56
|
+
email: "Email address"
|
|
57
|
+
email_placeholder: "you@example.com"
|
|
58
|
+
email_instructions: "Enter your email address. We'll send you a 6-digit code to verify your account."
|
|
59
|
+
spam_note: "Check your spam folder if you don't receive the email."
|
|
60
|
+
submit: "Send code"
|
|
61
|
+
have_account: "Already have an account?"
|
|
62
|
+
sign_in: "Sign in"
|
|
63
|
+
|
|
64
|
+
registration:
|
|
65
|
+
verifications:
|
|
66
|
+
new:
|
|
67
|
+
title: "Verify your email"
|
|
68
|
+
sent_to: "We sent a 6-digit code to %{email}"
|
|
69
|
+
enter_code: "Enter the code"
|
|
70
|
+
submit: "Verify"
|
|
71
|
+
check_spam: "Check your spam folder if you don't receive it."
|
|
72
|
+
didnt_receive: "Didn't receive the code?"
|
|
73
|
+
resend: "Resend code"
|
|
74
|
+
|
|
75
|
+
verification_mailer:
|
|
76
|
+
verification_code:
|
|
77
|
+
subject: "Your verification code"
|
|
78
|
+
title: "Verification code"
|
|
79
|
+
instructions: "Enter this code to continue:"
|
|
80
|
+
expires_in: "This code expires in 15 minutes."
|
|
81
|
+
ignore_if_not_you: "If you didn't request this code, you can ignore this email."
|
|
82
|
+
|
|
83
|
+
activerecord:
|
|
84
|
+
models:
|
|
85
|
+
user:
|
|
86
|
+
one: "User"
|
|
87
|
+
other: "Users"
|
|
88
|
+
attributes:
|
|
89
|
+
user:
|
|
90
|
+
email_address: "Email address"
|
|
91
|
+
name: "Name"
|
|
92
|
+
password: "Password"
|
|
93
|
+
locale: "Language"
|
|
94
|
+
blocked_at: "Blocked at"
|
|
95
|
+
blocked_reason: "Blocked reason"
|
|
96
|
+
errors:
|
|
97
|
+
models:
|
|
98
|
+
user:
|
|
99
|
+
attributes:
|
|
100
|
+
email_address:
|
|
101
|
+
plus_not_allowed: "Email addresses with '+' are not allowed. Please use your main email without aliases."
|