beskar 0.0.1 → 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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -0
  3. data/README.md +987 -21
  4. data/app/controllers/beskar/application_controller.rb +170 -0
  5. data/app/controllers/beskar/banned_ips_controller.rb +280 -0
  6. data/app/controllers/beskar/dashboard_controller.rb +70 -0
  7. data/app/controllers/beskar/security_events_controller.rb +182 -0
  8. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +70 -0
  9. data/app/models/beskar/banned_ip.rb +193 -0
  10. data/app/models/beskar/security_event.rb +64 -0
  11. data/app/services/beskar/banned_ip_manager.rb +78 -0
  12. data/app/views/beskar/banned_ips/edit.html.erb +259 -0
  13. data/app/views/beskar/banned_ips/index.html.erb +361 -0
  14. data/app/views/beskar/banned_ips/new.html.erb +310 -0
  15. data/app/views/beskar/banned_ips/show.html.erb +310 -0
  16. data/app/views/beskar/dashboard/index.html.erb +280 -0
  17. data/app/views/beskar/security_events/index.html.erb +309 -0
  18. data/app/views/beskar/security_events/show.html.erb +307 -0
  19. data/app/views/layouts/beskar/application.html.erb +647 -5
  20. data/config/locales/en.yml +10 -0
  21. data/config/routes.rb +41 -0
  22. data/db/migrate/20251016000001_create_beskar_security_events.rb +25 -0
  23. data/db/migrate/20251016000002_create_beskar_banned_ips.rb +23 -0
  24. data/lib/beskar/configuration.rb +214 -0
  25. data/lib/beskar/engine.rb +105 -0
  26. data/lib/beskar/logger.rb +293 -0
  27. data/lib/beskar/middleware/request_analyzer.rb +305 -0
  28. data/lib/beskar/middleware.rb +4 -0
  29. data/lib/beskar/models/security_trackable.rb +25 -0
  30. data/lib/beskar/models/security_trackable_authenticable.rb +167 -0
  31. data/lib/beskar/models/security_trackable_devise.rb +82 -0
  32. data/lib/beskar/models/security_trackable_generic.rb +355 -0
  33. data/lib/beskar/services/account_locker.rb +263 -0
  34. data/lib/beskar/services/device_detector.rb +250 -0
  35. data/lib/beskar/services/geolocation_service.rb +392 -0
  36. data/lib/beskar/services/ip_whitelist.rb +113 -0
  37. data/lib/beskar/services/rate_limiter.rb +257 -0
  38. data/lib/beskar/services/waf.rb +551 -0
  39. data/lib/beskar/version.rb +1 -1
  40. data/lib/beskar.rb +32 -1
  41. data/lib/generators/beskar/install/install_generator.rb +158 -0
  42. data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
  43. data/lib/tasks/beskar_tasks.rake +121 -4
  44. metadata +138 -5
@@ -0,0 +1,70 @@
1
+ module Beskar
2
+ module Controllers
3
+ # Controller concern for tracking Rails 8 authentication events
4
+ #
5
+ # Usage in SessionsController:
6
+ # class SessionsController < ApplicationController
7
+ # include Beskar::Controllers::SecurityTracking
8
+ #
9
+ # def create
10
+ # if user = User.authenticate_by(params.permit(:email_address, :password))
11
+ # track_authentication_success(user)
12
+ # start_new_session_for user
13
+ # redirect_to after_authentication_url
14
+ # else
15
+ # track_authentication_failure(User, :user)
16
+ # redirect_to new_session_path, alert: "Try another email address or password."
17
+ # end
18
+ # end
19
+ # end
20
+ module SecurityTracking
21
+ extend ActiveSupport::Concern
22
+
23
+ private
24
+
25
+ # Track successful authentication for a user
26
+ # This should be called after verifying credentials but before creating session
27
+ def track_authentication_success(user)
28
+ return unless user
29
+ return unless Beskar.configuration.track_successful_logins?
30
+
31
+ user.track_authentication_event(request, :success)
32
+ Beskar::Logger.info("Tracked successful authentication for user #{user.id}")
33
+ rescue => e
34
+ Beskar::Logger.error("Failed to track authentication success: #{e.message}")
35
+ end
36
+
37
+ # Track failed authentication attempt
38
+ # This should be called when authentication fails
39
+ def track_authentication_failure(model_class, scope = :user)
40
+ return unless Beskar.configuration.track_failed_logins?
41
+
42
+ model_class.track_failed_authentication(request, scope)
43
+ Beskar::Logger.info("Tracked failed authentication for scope #{scope}")
44
+ rescue => e
45
+ Beskar::Logger.error("Failed to track authentication failure: #{e.message}")
46
+ end
47
+
48
+ # Track logout event
49
+ def track_logout(user)
50
+ return unless user
51
+ return unless Beskar.configuration.security_tracking_enabled?
52
+
53
+ user.security_events.create!(
54
+ event_type: "logout",
55
+ ip_address: request.ip,
56
+ user_agent: request.user_agent,
57
+ metadata: {
58
+ timestamp: Time.current.iso8601,
59
+ session_id: request.session.id,
60
+ request_path: request.path
61
+ },
62
+ risk_score: 0
63
+ )
64
+ Beskar::Logger.info("Tracked logout for user #{user.id}")
65
+ rescue => e
66
+ Beskar::Logger.error("Failed to track logout: #{e.message}")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,193 @@
1
+ module Beskar
2
+ class BannedIp < ApplicationRecord
3
+ # Serialize metadata as JSON
4
+ serialize :metadata, coder: JSON
5
+
6
+ validates :ip_address, presence: true, uniqueness: true
7
+ validates :reason, presence: true
8
+ validates :banned_at, presence: true
9
+
10
+ # Ensure metadata is always a hash
11
+ after_initialize do
12
+ self.metadata ||= {}
13
+ end
14
+
15
+ # Cache management callbacks
16
+ after_save :update_cache
17
+ after_destroy :clear_cache
18
+
19
+ scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
20
+ scope :permanent, -> { where(permanent: true) }
21
+ scope :temporary, -> { where(permanent: false) }
22
+ scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
23
+ scope :by_reason, ->(reason) { where(reason: reason) }
24
+
25
+ # Check if a ban is currently active
26
+ def active?
27
+ permanent? || (expires_at.present? && expires_at > Time.current)
28
+ end
29
+
30
+ # Check if ban has expired
31
+ def expired?
32
+ !permanent? && expires_at.present? && expires_at <= Time.current
33
+ end
34
+
35
+ # Extend ban duration (for repeat offenders)
36
+ def extend_ban!(additional_time = nil)
37
+ self.violation_count += 1
38
+
39
+ if permanent?
40
+ # Already permanent, just increment violation count
41
+ save!
42
+ elsif additional_time
43
+ self.expires_at = [expires_at || Time.current, Time.current].max + additional_time
44
+ save!
45
+ else
46
+ # Calculate exponential backoff based on violation count
47
+ # 1 hour, 6 hours, 24 hours, 7 days, permanent
48
+ duration = case violation_count
49
+ when 1 then 1.hour
50
+ when 2 then 6.hours
51
+ when 3 then 24.hours
52
+ when 4 then 7.days
53
+ else
54
+ self.permanent = true
55
+ nil
56
+ end
57
+
58
+ if duration
59
+ self.expires_at = Time.current + duration
60
+ end
61
+ save!
62
+ end
63
+ end
64
+
65
+ # Unban an IP address
66
+ def unban!
67
+ destroy
68
+ end
69
+
70
+ # Class methods for ban management
71
+ class << self
72
+ # Ban an IP address
73
+ def ban!(ip_address, reason:, duration: nil, permanent: false, details: nil, metadata: {})
74
+ # Retry logic to handle race conditions when multiple requests try to ban the same IP
75
+ retries = 0
76
+ max_retries = 2
77
+
78
+ begin
79
+ banned_ip = find_or_initialize_by(ip_address: ip_address)
80
+
81
+ if banned_ip.persisted?
82
+ # Existing ban - extend it
83
+ banned_ip.extend_ban!(duration)
84
+ banned_ip.details = details if details
85
+ # Deep stringify keys to avoid duplicate key issues
86
+ if metadata.any?
87
+ banned_ip.metadata = banned_ip.metadata.deep_stringify_keys.merge(metadata.deep_stringify_keys)
88
+ end
89
+ banned_ip.save!
90
+ else
91
+ # New ban
92
+ banned_ip.assign_attributes(
93
+ reason: reason,
94
+ banned_at: Time.current,
95
+ expires_at: permanent ? nil : (Time.current + (duration || 1.hour)),
96
+ permanent: permanent,
97
+ details: details,
98
+ metadata: metadata
99
+ )
100
+ banned_ip.save!
101
+ end
102
+
103
+ # Update cache
104
+ cache_key = "beskar:banned_ip:#{ip_address}"
105
+ Rails.cache.write(cache_key, true, expires_in: permanent ? nil : (duration || 1.hour))
106
+
107
+ banned_ip
108
+ rescue ActiveRecord::RecordInvalid => e
109
+ # Race condition: another request created the record between find and save
110
+ if e.message.include?("Ip address has already been taken") && retries < max_retries
111
+ retries += 1
112
+ # Small random delay to reduce contention (1-10ms)
113
+ sleep(rand(1..10) / 1000.0)
114
+ retry
115
+ else
116
+ # Re-raise if it's a different validation error or we've exceeded retries
117
+ raise
118
+ end
119
+ end
120
+ end
121
+
122
+ # Check if an IP is banned (cache-first approach)
123
+ def banned?(ip_address)
124
+ # Check cache first for performance
125
+ cache_key = "beskar:banned_ip:#{ip_address}"
126
+ cached_result = Rails.cache.read(cache_key)
127
+ return true if cached_result == true
128
+ return false if cached_result == false
129
+
130
+ # Check database
131
+ banned_record = active.find_by(ip_address: ip_address)
132
+ is_banned = banned_record&.active? || false
133
+
134
+ # Update cache
135
+ if is_banned && banned_record
136
+ ttl = banned_record.permanent? ? 30.days : (banned_record.expires_at - Time.current).to_i
137
+ Rails.cache.write(cache_key, true, expires_in: ttl)
138
+ else
139
+ Rails.cache.write(cache_key, false, expires_in: 5.minutes)
140
+ end
141
+
142
+ is_banned
143
+ end
144
+
145
+ # Unban an IP address
146
+ def unban!(ip_address)
147
+ banned_ip = find_by(ip_address: ip_address)
148
+ if banned_ip
149
+ banned_ip.destroy
150
+ # Clear cache
151
+ Rails.cache.delete("beskar:banned_ip:#{ip_address}")
152
+ true
153
+ else
154
+ false
155
+ end
156
+ end
157
+
158
+ # Load all active bans into cache (called on app startup)
159
+ def preload_cache!
160
+ active.find_each do |banned_ip|
161
+ cache_key = "beskar:banned_ip:#{banned_ip.ip_address}"
162
+ ttl = banned_ip.permanent? ? 30.days : [(banned_ip.expires_at - Time.current).to_i, 60].max
163
+ Rails.cache.write(cache_key, true, expires_in: ttl)
164
+ end
165
+ end
166
+
167
+ # Clean up expired bans
168
+ def cleanup_expired!
169
+ expired.destroy_all
170
+ end
171
+ end
172
+
173
+ private
174
+
175
+ def update_cache
176
+ return unless active?
177
+
178
+ cache_key = "beskar:banned_ip:#{ip_address}"
179
+ ttl = calculate_cache_ttl
180
+ Rails.cache.write(cache_key, true, expires_in: ttl)
181
+ end
182
+
183
+ def clear_cache
184
+ Rails.cache.delete("beskar:banned_ip:#{ip_address}")
185
+ end
186
+
187
+ def calculate_cache_ttl
188
+ return nil if permanent? || expires_at.nil?
189
+
190
+ (expires_at - Time.current).to_i
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,64 @@
1
+ module Beskar
2
+ class SecurityEvent < ApplicationRecord
3
+ belongs_to :user, polymorphic: true, optional: true
4
+
5
+ validates :event_type, presence: true
6
+ validates :ip_address, presence: true
7
+ validates :risk_score, numericality: {in: 0..100}
8
+
9
+ scope :login_failures, -> { where(event_type: "login_failure") }
10
+ scope :login_successes, -> { where(event_type: "login_success") }
11
+ scope :recent, ->(time = 1.hour.ago) { where("created_at >= ?", time) }
12
+ scope :by_ip, ->(ip) { where(ip_address: ip) }
13
+ scope :high_risk, -> { where("risk_score >= ?", 70) }
14
+ scope :critical_risk, -> { where("risk_score >= ?", 90) }
15
+
16
+ def critical_threat?
17
+ risk_score >= 90
18
+ end
19
+
20
+ def high_risk?
21
+ risk_score >= 70
22
+ end
23
+
24
+ def login_failure?
25
+ event_type == "login_failure"
26
+ end
27
+
28
+ def login_success?
29
+ event_type == "login_success"
30
+ end
31
+
32
+ def attempted_email
33
+ read_attribute(:attempted_email) || metadata&.dig("attempted_email")
34
+ end
35
+
36
+ def attempted_email=(value)
37
+ write_attribute(:attempted_email, value)
38
+ # Also store in metadata for backwards compatibility
39
+ self.metadata = (metadata || {}).merge("attempted_email" => value) if value.present?
40
+ end
41
+
42
+ def device_info
43
+ metadata&.dig("device_info") || {}
44
+ end
45
+
46
+ def geolocation
47
+ metadata&.dig("geolocation") || {}
48
+ end
49
+
50
+ def details
51
+ # Extract details from metadata if available
52
+ # Check multiple possible fields where details might be stored
53
+ return nil unless metadata.present?
54
+
55
+ metadata["details"] ||
56
+ metadata["description"] ||
57
+ metadata["message"] ||
58
+ metadata["reason"] ||
59
+ metadata["error"] ||
60
+ metadata["info"] ||
61
+ nil
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,78 @@
1
+ module Beskar
2
+ class BannedIpManager
3
+ attr_reader :banned_ip, :errors
4
+
5
+ def initialize(params)
6
+ @params = params
7
+ @errors = []
8
+ @banned_ip = nil
9
+ end
10
+
11
+ def create
12
+ @banned_ip = BannedIp.new(base_attributes)
13
+ configure_ban_duration
14
+
15
+ @banned_ip.save
16
+ end
17
+
18
+ def success?
19
+ @banned_ip&.persisted?
20
+ end
21
+
22
+ private
23
+
24
+ def base_attributes
25
+ {
26
+ ip_address: @params[:ip_address],
27
+ reason: @params[:reason],
28
+ details: @params[:details],
29
+ violation_count: @params[:violation_count] || 1,
30
+ metadata: @params[:metadata] || {},
31
+ banned_at: Time.current
32
+ }
33
+ end
34
+
35
+ def configure_ban_duration
36
+ return set_permanent_ban if permanent_ban?
37
+
38
+ set_temporary_ban
39
+ end
40
+
41
+ def permanent_ban?
42
+ @params[:ban_type] == 'permanent'
43
+ end
44
+
45
+ def set_permanent_ban
46
+ @banned_ip.permanent = true
47
+ @banned_ip.expires_at = nil
48
+ end
49
+
50
+ def set_temporary_ban
51
+ @banned_ip.permanent = false
52
+ @banned_ip.expires_at = calculate_expiry_time
53
+ end
54
+
55
+ def calculate_expiry_time
56
+ return custom_expiry_time if custom_expiry_time.present?
57
+ return preset_duration_expiry if preset_duration.present?
58
+
59
+ default_expiry_time
60
+ end
61
+
62
+ def custom_expiry_time
63
+ @params[:expires_at]
64
+ end
65
+
66
+ def preset_duration
67
+ @params[:duration]
68
+ end
69
+
70
+ def preset_duration_expiry
71
+ Time.current + preset_duration.to_i.seconds
72
+ end
73
+
74
+ def default_expiry_time
75
+ 24.hours.from_now
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,259 @@
1
+ <% content_for :page_title, "Edit Ban: #{@banned_ip.ip_address}" %>
2
+
3
+ <% content_for :header_actions do %>
4
+ <%= link_to "← Cancel", banned_ip_path(@banned_ip), class: "btn btn-secondary" %>
5
+ <% end %>
6
+
7
+ <div class="card">
8
+ <div class="card-header">
9
+ <h3 class="card-title">Edit IP Ban</h3>
10
+ <div class="card-subtitle">Modify ban settings for <%= @banned_ip.ip_address %></div>
11
+ </div>
12
+ <div class="card-body">
13
+ <%= form_with model: @banned_ip, url: banned_ip_path(@banned_ip), method: :patch, local: true do |f| %>
14
+ <% if @banned_ip.errors.any? %>
15
+ <div class="alert alert-danger">
16
+ <h4 style="font-size: 14px; font-weight: 600; margin-bottom: 0.5rem;">
17
+ <%= pluralize(@banned_ip.errors.count, "error") %> prohibited this ban from being saved:
18
+ </h4>
19
+ <ul style="margin: 0; padding-left: 1.5rem;">
20
+ <% @banned_ip.errors.full_messages.each do |message| %>
21
+ <li style="font-size: 13px;"><%= message %></li>
22
+ <% end %>
23
+ </ul>
24
+ </div>
25
+ <% end %>
26
+
27
+ <div style="display: grid; grid-template-columns: 1fr; gap: 1.5rem; max-width: 600px;">
28
+ <!-- IP Address (Read-only) -->
29
+ <div class="form-group">
30
+ <%= f.label :ip_address, "IP Address" %>
31
+ <%= f.text_field :ip_address,
32
+ disabled: true,
33
+ style: "font-family: monospace; background: #F4F5F7; cursor: not-allowed;" %>
34
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
35
+ IP addresses cannot be changed. To ban a different IP, create a new ban.
36
+ </div>
37
+ </div>
38
+
39
+ <!-- Current Status -->
40
+ <div class="form-group">
41
+ <label>Current Status</label>
42
+ <div style="display: flex; align-items: center; gap: 1rem;">
43
+ <% if @banned_ip.permanent? %>
44
+ <span class="badge badge-critical">Permanent Ban</span>
45
+ <% elsif @banned_ip.active? %>
46
+ <span class="badge badge-danger">Active</span>
47
+ <span style="font-size: 13px; color: #697386;">
48
+ Expires in <%= distance_of_time_in_words(Time.current, @banned_ip.expires_at) %>
49
+ </span>
50
+ <% else %>
51
+ <span class="badge badge-neutral">Expired</span>
52
+ <span style="font-size: 13px; color: #697386;">
53
+ Expired <%= time_ago_in_words(@banned_ip.expires_at) %> ago
54
+ </span>
55
+ <% end %>
56
+ </div>
57
+ </div>
58
+
59
+ <!-- Reason -->
60
+ <div class="form-group">
61
+ <%= f.label :reason, "Ban Reason *" %>
62
+ <%= f.select :reason,
63
+ options_for_select([
64
+ ['Rate Limit Abuse', 'rate_limit_abuse'],
65
+ ['Authentication Abuse', 'authentication_abuse'],
66
+ ['WAF Violation', 'waf_violation'],
67
+ ['Brute Force Attack', 'brute_force_attack'],
68
+ ['Suspicious Activity', 'suspicious_activity'],
69
+ ['Manual Ban', 'manual_ban'],
70
+ ['Other', 'other']
71
+ ], @banned_ip.reason),
72
+ { prompt: 'Select a reason...' },
73
+ { required: true } %>
74
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
75
+ Update the reason for this ban if needed.
76
+ </div>
77
+ </div>
78
+
79
+ <!-- Ban Duration -->
80
+ <div class="form-group">
81
+ <label>Ban Duration</label>
82
+ <div style="display: flex; flex-direction: column; gap: 0.5rem;">
83
+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
84
+ <%= f.radio_button :permanent, false,
85
+ onchange: "toggleDurationFields()",
86
+ checked: !@banned_ip.permanent? %>
87
+ <span style="font-weight: normal;">Temporary Ban</span>
88
+ </label>
89
+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
90
+ <%= f.radio_button :permanent, true,
91
+ onchange: "toggleDurationFields()",
92
+ checked: @banned_ip.permanent? %>
93
+ <span style="font-weight: normal; color: #D32F2F;">Permanent Ban</span>
94
+ </label>
95
+ </div>
96
+ <% if @banned_ip.permanent? %>
97
+ <div class="alert alert-warning" style="margin-top: 0.5rem;">
98
+ <strong>Warning:</strong> This is currently a permanent ban. Changing it to temporary will set an expiration date.
99
+ </div>
100
+ <% end %>
101
+ </div>
102
+
103
+ <!-- Temporary Ban Options -->
104
+ <div id="temporary-options" style="<%= @banned_ip.permanent? ? 'display: none;' : '' %>">
105
+ <div class="form-group">
106
+ <%= f.label :expires_at, "Expiry Date/Time" %>
107
+ <%= f.datetime_field :expires_at,
108
+ value: @banned_ip.expires_at&.strftime('%Y-%m-%dT%H:%M'),
109
+ min: DateTime.now.strftime('%Y-%m-%dT%H:%M') %>
110
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
111
+ Set when this ban should expire. Leave empty for default duration.
112
+ </div>
113
+ </div>
114
+
115
+ <!-- Quick Extension Options -->
116
+ <div class="form-group">
117
+ <label>Quick Extension</label>
118
+ <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem;">
119
+ <button type="button" onclick="extendBan(1)" class="btn btn-sm btn-secondary">+1 Hour</button>
120
+ <button type="button" onclick="extendBan(24)" class="btn btn-sm btn-secondary">+1 Day</button>
121
+ <button type="button" onclick="extendBan(168)" class="btn btn-sm btn-secondary">+7 Days</button>
122
+ <button type="button" onclick="extendBan(720)" class="btn btn-sm btn-secondary">+30 Days</button>
123
+ <button type="button" onclick="extendBan(2160)" class="btn btn-sm btn-secondary">+90 Days</button>
124
+ <button type="button" onclick="makePermanent()" class="btn btn-sm btn-danger">Make Permanent</button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ <!-- Details -->
130
+ <div class="form-group">
131
+ <%= f.label :details, "Additional Details" %>
132
+ <%= f.text_area :details,
133
+ rows: 3,
134
+ placeholder: "Provide any additional context or details about this ban..." %>
135
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
136
+ Update any relevant information about why this IP is banned.
137
+ </div>
138
+ </div>
139
+
140
+ <!-- Violation Count -->
141
+ <div class="form-group">
142
+ <%= f.label :violation_count, "Violation Count" %>
143
+ <%= f.number_field :violation_count, min: 0 %>
144
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
145
+ Current violation count: <%= @banned_ip.violation_count %>. Higher counts may result in longer bans for repeat offenders.
146
+ </div>
147
+ </div>
148
+
149
+ <hr style="border: none; border-top: 1px solid #E3E8EE; margin: 1.5rem 0;">
150
+
151
+ <!-- Ban History -->
152
+ <div class="form-group">
153
+ <label>Ban History</label>
154
+ <div style="background: #FAFBFC; border: 1px solid #E3E8EE; border-radius: 6px; padding: 1rem;">
155
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem;">
156
+ <div>
157
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
158
+ Originally Banned
159
+ </div>
160
+ <div style="font-size: 13px; color: #1A1F36; margin-top: 0.25rem;">
161
+ <%= @banned_ip.banned_at.strftime("%Y-%m-%d %H:%M:%S") %>
162
+ </div>
163
+ </div>
164
+ <div>
165
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
166
+ Time Banned
167
+ </div>
168
+ <div style="font-size: 13px; color: #1A1F36; margin-top: 0.25rem;">
169
+ <%= time_ago_in_words(@banned_ip.banned_at) %>
170
+ </div>
171
+ </div>
172
+ <% if @banned_ip.updated_at != @banned_ip.created_at %>
173
+ <div>
174
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
175
+ Last Modified
176
+ </div>
177
+ <div style="font-size: 13px; color: #1A1F36; margin-top: 0.25rem;">
178
+ <%= @banned_ip.updated_at.strftime("%Y-%m-%d %H:%M:%S") %>
179
+ </div>
180
+ </div>
181
+ <% end %>
182
+ </div>
183
+ </div>
184
+ </div>
185
+
186
+ <!-- Submit Buttons -->
187
+ <div style="display: flex; gap: 0.5rem;">
188
+ <%= f.submit "Update Ban", class: "btn btn-primary" %>
189
+ <%= link_to "Cancel", banned_ip_path(@banned_ip), class: "btn btn-secondary" %>
190
+ <%= link_to "Delete Ban", banned_ip_path(@banned_ip),
191
+ data: {
192
+ "turbo-method": "delete",
193
+ "turbo-confirm": "Are you sure you want to unban #{@banned_ip.ip_address}?"
194
+ },
195
+ class: "btn btn-danger" %>
196
+ </div>
197
+ </div>
198
+ <% end %>
199
+ </div>
200
+ </div>
201
+
202
+ <script>
203
+ function toggleDurationFields() {
204
+ const isPermanent = document.querySelector('input[name="banned_ip[permanent]"]:checked').value === 'true';
205
+ const temporaryOptions = document.getElementById('temporary-options');
206
+
207
+ if (isPermanent) {
208
+ temporaryOptions.style.display = 'none';
209
+ document.getElementById('banned_ip_expires_at').value = '';
210
+ } else {
211
+ temporaryOptions.style.display = 'block';
212
+ // If switching from permanent to temporary, set a default expiry
213
+ if (!document.getElementById('banned_ip_expires_at').value) {
214
+ const defaultExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours from now
215
+ document.getElementById('banned_ip_expires_at').value = defaultExpiry.toISOString().slice(0, 16);
216
+ }
217
+ }
218
+ }
219
+
220
+ function extendBan(hours) {
221
+ const expiresField = document.getElementById('banned_ip_expires_at');
222
+ let currentExpiry;
223
+
224
+ if (expiresField.value) {
225
+ currentExpiry = new Date(expiresField.value);
226
+ } else {
227
+ // Use current ban expiry or current time
228
+ <% if @banned_ip.expires_at %>
229
+ currentExpiry = new Date('<%= @banned_ip.expires_at.iso8601 %>');
230
+ <% else %>
231
+ currentExpiry = new Date();
232
+ <% end %>
233
+ }
234
+
235
+ // Add the specified hours
236
+ currentExpiry.setHours(currentExpiry.getHours() + hours);
237
+
238
+ // Update the field
239
+ expiresField.value = currentExpiry.toISOString().slice(0, 16);
240
+
241
+ // Show a visual feedback
242
+ const originalBg = expiresField.style.background;
243
+ expiresField.style.background = '#E8F5E9';
244
+ setTimeout(() => {
245
+ expiresField.style.background = originalBg;
246
+ }, 500);
247
+ }
248
+
249
+ function makePermanent() {
250
+ document.querySelector('input[name="banned_ip[permanent]"][value="true"]').checked = true;
251
+ toggleDurationFields();
252
+ }
253
+
254
+ // Initialize on page load
255
+ document.addEventListener('DOMContentLoaded', function() {
256
+ // Set initial state
257
+ toggleDurationFields();
258
+ });
259
+ </script>