beskar 0.0.2 → 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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -0
  3. data/README.md +298 -110
  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 +6 -6
  9. data/app/models/beskar/banned_ip.rb +68 -27
  10. data/app/models/beskar/security_event.rb +14 -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/routes.rb +41 -0
  21. data/lib/beskar/configuration.rb +24 -10
  22. data/lib/beskar/engine.rb +4 -4
  23. data/lib/beskar/logger.rb +293 -0
  24. data/lib/beskar/middleware/request_analyzer.rb +128 -53
  25. data/lib/beskar/models/security_trackable_authenticable.rb +11 -11
  26. data/lib/beskar/models/security_trackable_devise.rb +5 -5
  27. data/lib/beskar/models/security_trackable_generic.rb +12 -12
  28. data/lib/beskar/services/account_locker.rb +12 -12
  29. data/lib/beskar/services/geolocation_service.rb +8 -8
  30. data/lib/beskar/services/ip_whitelist.rb +2 -2
  31. data/lib/beskar/services/waf.rb +307 -78
  32. data/lib/beskar/version.rb +1 -1
  33. data/lib/beskar.rb +1 -0
  34. data/lib/generators/beskar/install/install_generator.rb +158 -0
  35. data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
  36. data/lib/tasks/beskar_tasks.rake +11 -2
  37. metadata +35 -6
  38. data/lib/beskar/templates/beskar_initializer.rb +0 -107
@@ -0,0 +1,361 @@
1
+ <% content_for :page_title, "Banned IPs" %>
2
+
3
+ <% content_for :header_actions do %>
4
+ <div style="display: flex; gap: 0.5rem;">
5
+ <%= link_to "+ New Ban", new_banned_ip_path, class: "btn btn-primary" %>
6
+ <%= link_to "Export CSV", export_banned_ips_path(format: :csv, params: request.query_parameters),
7
+ class: "btn btn-secondary" %>
8
+ <%= link_to "Export JSON", export_banned_ips_path(format: :json, params: request.query_parameters),
9
+ class: "btn btn-secondary" %>
10
+ </div>
11
+ <% end %>
12
+
13
+ <!-- Filters Card -->
14
+ <div class="card">
15
+ <div class="card-header">
16
+ <h3 class="card-title">Filters</h3>
17
+ <div class="card-subtitle">Search and filter banned IPs</div>
18
+ </div>
19
+ <div class="card-body">
20
+ <%= form_with url: banned_ips_path, method: :get, local: true do |f| %>
21
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
22
+ <!-- Status -->
23
+ <div class="form-group">
24
+ <label>Status</label>
25
+ <%= select_tag :status,
26
+ options_for_select([
27
+ ['All', ''],
28
+ ['Active', 'active'],
29
+ ['Expired', 'expired'],
30
+ ['Permanent', 'permanent'],
31
+ ['Temporary', 'temporary']
32
+ ], params[:status]),
33
+ style: "width: 100%;" %>
34
+ </div>
35
+
36
+ <!-- Reason -->
37
+ <div class="form-group">
38
+ <label>Reason</label>
39
+ <%= select_tag :reason,
40
+ options_for_select(@ban_reasons, params[:reason]),
41
+ prompt: 'All reasons',
42
+ style: "width: 100%;" %>
43
+ </div>
44
+
45
+ <!-- IP Search -->
46
+ <div class="form-group">
47
+ <label>IP Address</label>
48
+ <%= text_field_tag :ip_search, params[:ip_search],
49
+ placeholder: "Search IPs..." %>
50
+ </div>
51
+
52
+ <!-- Date Range -->
53
+ <div class="form-group">
54
+ <label>Banned After</label>
55
+ <%= date_field_tag :banned_after, params[:banned_after] %>
56
+ </div>
57
+
58
+ <div class="form-group">
59
+ <label>Banned Before</label>
60
+ <%= date_field_tag :banned_before, params[:banned_before] %>
61
+ </div>
62
+ </div>
63
+
64
+ <div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
65
+ <%= submit_tag "Apply Filters", class: "btn btn-primary" %>
66
+ <%= link_to "Clear Filters", banned_ips_path, class: "btn btn-secondary" %>
67
+ </div>
68
+ <% end %>
69
+ </div>
70
+ </div>
71
+
72
+ <!-- Results Summary -->
73
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
74
+ <div style="font-size: 14px; color: #697386;">
75
+ Showing <%= @pagination[:records].count %> of <%= number_with_delimiter(@pagination[:total_count]) %> banned IPs
76
+ </div>
77
+
78
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
79
+ <label style="margin: 0; font-size: 13px; color: #697386;">Per page:</label>
80
+ <%= form_with url: banned_ips_path, method: :get, local: true, style: "display: inline;" do |f| %>
81
+ <% request.query_parameters.except(:per_page).each do |key, value| %>
82
+ <%= hidden_field_tag key, value %>
83
+ <% end %>
84
+ <%= select_tag :per_page,
85
+ options_for_select([10, 25, 50, 100], params[:per_page]&.to_i || 25),
86
+ onchange: "this.form.submit();",
87
+ style: "padding: 0.25rem 0.5rem; font-size: 13px;" %>
88
+ <% end %>
89
+ </div>
90
+ </div>
91
+
92
+ <!-- Bulk Actions Form -->
93
+ <%= form_with url: bulk_action_banned_ips_path, method: :post, local: true, id: "bulk-actions-form" do |f| %>
94
+ <!-- Bulk Actions Bar -->
95
+ <div class="card" id="bulk-actions-bar" style="display: none; margin-bottom: 1rem;">
96
+ <div class="card-body" style="padding: 0.75rem 1rem;">
97
+ <div style="display: flex; justify-content: space-between; align-items: center;">
98
+ <div style="display: flex; align-items: center; gap: 1rem;">
99
+ <span style="font-size: 14px; color: #697386;">
100
+ <span id="selected-count">0</span> items selected
101
+ </span>
102
+
103
+ <div style="display: flex; gap: 0.5rem;">
104
+ <%= submit_tag "Unban Selected", name: "bulk_action", value: "unban",
105
+ class: "btn btn-sm btn-secondary",
106
+ onclick: "return confirm('Are you sure you want to unban the selected IPs?');" %>
107
+ <%= submit_tag "Make Permanent", name: "bulk_action", value: "make_permanent",
108
+ class: "btn btn-sm btn-danger",
109
+ onclick: "return confirm('Are you sure you want to make the selected bans permanent?');" %>
110
+
111
+ <div style="display: flex; align-items: center; gap: 0.25rem;">
112
+ <%= select_tag :duration,
113
+ options_for_select([
114
+ ['Extend by 24h', '24h'],
115
+ ['Extend by 7d', '7d'],
116
+ ['Extend by 30d', '30d']
117
+ ]),
118
+ style: "padding: 0.25rem 0.5rem; font-size: 13px;" %>
119
+ <%= submit_tag "Extend", name: "bulk_action", value: "extend",
120
+ class: "btn btn-sm btn-warning" %>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ <button type="button" onclick="clearSelection()" class="btn btn-sm btn-secondary">
126
+ Clear Selection
127
+ </button>
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ <!-- Banned IPs Table -->
133
+ <div class="card">
134
+ <div class="card-body" style="padding: 0;">
135
+ <div class="table-container">
136
+ <table>
137
+ <thead>
138
+ <tr>
139
+ <th style="width: 40px;">
140
+ <input type="checkbox" id="select-all" onchange="toggleSelectAll(this)">
141
+ </th>
142
+ <th>IP Address</th>
143
+ <th>Reason</th>
144
+ <th>Banned At</th>
145
+ <th>Expires</th>
146
+ <th>Status</th>
147
+ <th>Violations</th>
148
+ <th>Actions</th>
149
+ </tr>
150
+ </thead>
151
+ <tbody>
152
+ <% if @banned_ips.any? %>
153
+ <% @banned_ips.each do |ban| %>
154
+ <tr>
155
+ <td>
156
+ <%= check_box_tag "ip_ids[]", ban.id, false,
157
+ class: "ban-checkbox",
158
+ onchange: "updateBulkActions()" %>
159
+ </td>
160
+ <td>
161
+ <div style="font-family: monospace; font-size: 14px; font-weight: 500;">
162
+ <%= ban.ip_address %>
163
+ </div>
164
+ <% if ban.metadata&.dig("geolocation", "country") %>
165
+ <div style="font-size: 11px; color: #697386; margin-top: 2px;">
166
+ <%= [ban.metadata.dig("geolocation", "city"),
167
+ ban.metadata.dig("geolocation", "country")].compact.join(", ") %>
168
+ </div>
169
+ <% end %>
170
+ </td>
171
+ <td>
172
+ <div style="font-size: 13px;">
173
+ <%= ban.reason.humanize %>
174
+ </div>
175
+ <% if ban.details.present? %>
176
+ <div style="font-size: 11px; color: #697386; margin-top: 2px;">
177
+ <%= truncate(ban.details, length: 50) %>
178
+ </div>
179
+ <% end %>
180
+ </td>
181
+ <td>
182
+ <div style="font-size: 13px;">
183
+ <%= ban.banned_at.strftime("%Y-%m-%d") %>
184
+ </div>
185
+ <div style="font-size: 11px; color: #697386;">
186
+ <%= ban.banned_at.strftime("%H:%M:%S") %>
187
+ </div>
188
+ </td>
189
+ <td>
190
+ <% if ban.permanent? %>
191
+ <span class="badge badge-critical">Never</span>
192
+ <% elsif ban.expires_at %>
193
+ <div style="font-size: 13px;">
194
+ <%= ban.expires_at.strftime("%Y-%m-%d %H:%M") %>
195
+ </div>
196
+ <div style="font-size: 11px; color: #697386;">
197
+ <%= distance_of_time_in_words(Time.current, ban.expires_at) %>
198
+ </div>
199
+ <% else %>
200
+ <span style="color: #C1C7D0;">-</span>
201
+ <% end %>
202
+ </td>
203
+ <td>
204
+ <% if ban.permanent? %>
205
+ <span class="badge badge-critical">Permanent</span>
206
+ <% elsif ban.active? %>
207
+ <span class="badge badge-danger">Active</span>
208
+ <% else %>
209
+ <span class="badge badge-neutral">Expired</span>
210
+ <% end %>
211
+ </td>
212
+ <td>
213
+ <div style="text-align: center;">
214
+ <% if ban.violation_count > 0 %>
215
+ <span style="font-size: 14px; font-weight: 600; color: <%= ban.violation_count > 3 ? '#D32F2F' : '#E91E63' %>;">
216
+ <%= ban.violation_count %>
217
+ </span>
218
+ <% else %>
219
+ <span style="color: #C1C7D0;">0</span>
220
+ <% end %>
221
+ </div>
222
+ </td>
223
+ <td>
224
+ <div style="display: flex; gap: 0.25rem; align-items: center;">
225
+ <%= link_to "View", banned_ip_path(ban),
226
+ class: "btn btn-sm btn-secondary" %>
227
+
228
+ <% if ban.active? && !ban.permanent? %>
229
+ <%= link_to "Extend", extend_banned_ip_path(ban, duration: '24h'),
230
+ data: {
231
+ "turbo-method": "post",
232
+ "turbo-confirm": "Extend this ban by 24 hours?"
233
+ },
234
+ class: "btn btn-sm btn-warning" %>
235
+ <% end %>
236
+
237
+ <%= link_to "Unban", banned_ip_path(ban),
238
+ data: {
239
+ "turbo-method": "delete",
240
+ "turbo-confirm": "Are you sure you want to unban #{ban.ip_address}?"
241
+ },
242
+ class: "btn btn-sm btn-danger" %>
243
+ </div>
244
+ </td>
245
+ </tr>
246
+ <% end %>
247
+ <% else %>
248
+ <tr>
249
+ <td colspan="8" style="text-align: center; padding: 3rem;">
250
+ <div style="color: #697386;">
251
+ <svg width="48" height="48" fill="currentColor" viewBox="0 0 24 24" style="margin: 0 auto 1rem;">
252
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
253
+ </svg>
254
+ <div style="font-size: 16px; font-weight: 500; margin-bottom: 0.5rem;">No banned IPs found</div>
255
+ <div style="font-size: 14px;">Try adjusting your filters or add a new ban</div>
256
+ </div>
257
+ </td>
258
+ </tr>
259
+ <% end %>
260
+ </tbody>
261
+ </table>
262
+ </div>
263
+ </div>
264
+ </div>
265
+ <% end %>
266
+
267
+ <!-- Pagination -->
268
+ <% if @pagination[:total_pages] > 1 %>
269
+ <div class="pagination">
270
+ <% if @pagination[:has_previous] %>
271
+ <%= link_to "← Previous", banned_ips_path(params.to_unsafe_h.merge(page: @pagination[:previous_page])),
272
+ class: "pagination-link" %>
273
+ <% else %>
274
+ <span class="pagination-link disabled">← Previous</span>
275
+ <% end %>
276
+
277
+ <% # Show page numbers %>
278
+ <% if @pagination[:total_pages] <= 7 %>
279
+ <% (1..@pagination[:total_pages]).each do |page| %>
280
+ <%= link_to page, banned_ips_path(params.to_unsafe_h.merge(page: page)),
281
+ class: "pagination-link #{'active' if page == @pagination[:current_page]}" %>
282
+ <% end %>
283
+ <% else %>
284
+ <% if @pagination[:current_page] <= 4 %>
285
+ <% (1..5).each do |page| %>
286
+ <%= link_to page, banned_ips_path(params.to_unsafe_h.merge(page: page)),
287
+ class: "pagination-link #{'active' if page == @pagination[:current_page]}" %>
288
+ <% end %>
289
+ <span class="pagination-link disabled">...</span>
290
+ <%= link_to @pagination[:total_pages], banned_ips_path(params.to_unsafe_h.merge(page: @pagination[:total_pages])),
291
+ class: "pagination-link" %>
292
+ <% elsif @pagination[:current_page] >= @pagination[:total_pages] - 3 %>
293
+ <%= link_to 1, banned_ips_path(params.to_unsafe_h.merge(page: 1)),
294
+ class: "pagination-link" %>
295
+ <span class="pagination-link disabled">...</span>
296
+ <% ((@pagination[:total_pages] - 4)..@pagination[:total_pages]).each do |page| %>
297
+ <%= link_to page, banned_ips_path(params.to_unsafe_h.merge(page: page)),
298
+ class: "pagination-link #{'active' if page == @pagination[:current_page]}" %>
299
+ <% end %>
300
+ <% else %>
301
+ <%= link_to 1, banned_ips_path(params.to_unsafe_h.merge(page: 1)),
302
+ class: "pagination-link" %>
303
+ <span class="pagination-link disabled">...</span>
304
+ <% ((@pagination[:current_page] - 1)..(@pagination[:current_page] + 1)).each do |page| %>
305
+ <%= link_to page, banned_ips_path(params.to_unsafe_h.merge(page: page)),
306
+ class: "pagination-link #{'active' if page == @pagination[:current_page]}" %>
307
+ <% end %>
308
+ <span class="pagination-link disabled">...</span>
309
+ <%= link_to @pagination[:total_pages], banned_ips_path(params.to_unsafe_h.merge(page: @pagination[:total_pages])),
310
+ class: "pagination-link" %>
311
+ <% end %>
312
+ <% end %>
313
+
314
+ <% if @pagination[:has_next] %>
315
+ <%= link_to "Next →", banned_ips_path(params.to_unsafe_h.merge(page: @pagination[:next_page])),
316
+ class: "pagination-link" %>
317
+ <% else %>
318
+ <span class="pagination-link disabled">Next →</span>
319
+ <% end %>
320
+ </div>
321
+
322
+ <div style="text-align: center; margin-top: 1rem; font-size: 13px; color: #697386;">
323
+ Page <%= @pagination[:current_page] %> of <%= @pagination[:total_pages] %> •
324
+ Total: <%= number_with_delimiter(@pagination[:total_count]) %> banned IPs
325
+ </div>
326
+ <% end %>
327
+
328
+ <script>
329
+ function toggleSelectAll(checkbox) {
330
+ const checkboxes = document.querySelectorAll('.ban-checkbox');
331
+ checkboxes.forEach(cb => {
332
+ cb.checked = checkbox.checked;
333
+ });
334
+ updateBulkActions();
335
+ }
336
+
337
+ function updateBulkActions() {
338
+ const checkboxes = document.querySelectorAll('.ban-checkbox:checked');
339
+ const bulkBar = document.getElementById('bulk-actions-bar');
340
+ const selectedCount = document.getElementById('selected-count');
341
+
342
+ if (checkboxes.length > 0) {
343
+ bulkBar.style.display = 'block';
344
+ selectedCount.textContent = checkboxes.length;
345
+ } else {
346
+ bulkBar.style.display = 'none';
347
+ }
348
+
349
+ // Update select-all checkbox state
350
+ const selectAll = document.getElementById('select-all');
351
+ const allCheckboxes = document.querySelectorAll('.ban-checkbox');
352
+ if (allCheckboxes.length > 0) {
353
+ selectAll.checked = checkboxes.length === allCheckboxes.length;
354
+ }
355
+ }
356
+
357
+ function clearSelection() {
358
+ document.getElementById('select-all').checked = false;
359
+ toggleSelectAll(document.getElementById('select-all'));
360
+ }
361
+ </script>
@@ -0,0 +1,310 @@
1
+ <% content_for :page_title, "New IP Ban" %>
2
+
3
+ <% content_for :header_actions do %>
4
+ <%= link_to "← Cancel", banned_ips_path, class: "btn btn-secondary" %>
5
+ <% end %>
6
+
7
+ <div class="card">
8
+ <div class="card-header">
9
+ <h3 class="card-title">Ban IP Address</h3>
10
+ <div class="card-subtitle">Add a new IP address to the ban list</div>
11
+ </div>
12
+ <div class="card-body">
13
+ <%= form_with model: @banned_ip, url: banned_ips_path, 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 -->
29
+ <div class="form-group">
30
+ <%= f.label :ip_address, "IP Address *" %>
31
+ <%= f.text_field :ip_address,
32
+ value: @suggested_ip || @banned_ip.ip_address,
33
+ placeholder: "e.g., 192.168.1.1",
34
+ required: true,
35
+ style: "font-family: monospace;" %>
36
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
37
+ Enter the IP address to ban. IPv4 and IPv6 formats are supported.
38
+ </div>
39
+ </div>
40
+
41
+ <!-- Reason -->
42
+ <div class="form-group">
43
+ <%= f.label :reason, "Ban Reason *" %>
44
+ <%= f.select :reason,
45
+ options_for_select([
46
+ ['Rate Limit Abuse', 'rate_limit_abuse'],
47
+ ['Authentication Abuse', 'authentication_abuse'],
48
+ ['WAF Violation', 'waf_violation'],
49
+ ['Brute Force Attack', 'brute_force_attack'],
50
+ ['Suspicious Activity', 'suspicious_activity'],
51
+ ['Manual Ban', 'manual_ban'],
52
+ ['Other', 'other']
53
+ ], @suggested_reason || @banned_ip.reason),
54
+ { prompt: 'Select a reason...' },
55
+ { required: true } %>
56
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
57
+ Select the primary reason for banning this IP address.
58
+ </div>
59
+ </div>
60
+
61
+ <!-- Ban Duration -->
62
+ <div class="form-group">
63
+ <label>Ban Duration *</label>
64
+ <div style="display: flex; flex-direction: column; gap: 0.5rem;">
65
+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
66
+ <%= radio_button_tag :ban_type, 'temporary', !@banned_ip.permanent?,
67
+ onchange: "toggleDurationFields()" %>
68
+ <span style="font-weight: normal;">Temporary Ban</span>
69
+ </label>
70
+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
71
+ <%= radio_button_tag :ban_type, 'permanent', @banned_ip.permanent?,
72
+ onchange: "toggleDurationFields()" %>
73
+ <span style="font-weight: normal; color: #D32F2F;">Permanent Ban</span>
74
+ </label>
75
+ </div>
76
+ </div>
77
+
78
+ <!-- Temporary Ban Options -->
79
+ <div id="temporary-options" style="<%= @banned_ip.permanent? ? 'display: none;' : '' %>">
80
+ <div class="form-group">
81
+ <label>Duration</label>
82
+ <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem;">
83
+ <% [
84
+ ['1 Hour', 1.hour],
85
+ ['6 Hours', 6.hours],
86
+ ['24 Hours', 24.hours],
87
+ ['7 Days', 7.days],
88
+ ['30 Days', 30.days],
89
+ ['90 Days', 90.days]
90
+ ].each do |label, duration| %>
91
+ <label style="display: flex; align-items: center; padding: 0.75rem; background: #FAFBFC; border: 1px solid #E3E8EE; border-radius: 6px; cursor: pointer;">
92
+ <%= radio_button_tag :duration, duration.to_i, false %>
93
+ <span style="margin-left: 0.5rem; font-size: 13px; font-weight: normal;"><%= label %></span>
94
+ </label>
95
+ <% end %>
96
+ </div>
97
+ <div style="font-size: 12px; color: #697386; margin-top: 0.5rem;">
98
+ Or specify a custom expiry date and time:
99
+ </div>
100
+ </div>
101
+
102
+ <div class="form-group">
103
+ <%= f.label :expires_at, "Custom Expiry Date/Time" %>
104
+ <%= f.datetime_field :expires_at,
105
+ value: @banned_ip.expires_at&.strftime('%Y-%m-%dT%H:%M'),
106
+ min: DateTime.now.strftime('%Y-%m-%dT%H:%M') %>
107
+ </div>
108
+ </div>
109
+
110
+ <!-- Details -->
111
+ <div class="form-group">
112
+ <%= f.label :details, "Additional Details" %>
113
+ <%= f.text_area :details,
114
+ rows: 3,
115
+ placeholder: "Provide any additional context or details about this ban..." %>
116
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
117
+ Optional. Add any relevant information about why this IP was banned.
118
+ </div>
119
+ </div>
120
+
121
+ <!-- Violation Count -->
122
+ <div class="form-group">
123
+ <%= f.label :violation_count, "Initial Violation Count" %>
124
+ <%= f.number_field :violation_count,
125
+ value: @banned_ip.violation_count || 1,
126
+ min: 0 %>
127
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
128
+ Set the initial violation count. This affects ban escalation for repeat offenders.
129
+ </div>
130
+ </div>
131
+
132
+ <hr style="border: none; border-top: 1px solid #E3E8EE; margin: 1.5rem 0;">
133
+
134
+ <!-- Related Security Event (if coming from an event) -->
135
+ <% if params[:event_id].present? %>
136
+ <div class="alert alert-info">
137
+ <strong>Related Event:</strong> This ban is being created in response to security event #<%= params[:event_id] %>.
138
+ <%= link_to "View Event", security_event_path(params[:event_id]), target: "_blank" %>
139
+ </div>
140
+ <% end %>
141
+
142
+ <!-- Submit Buttons -->
143
+ <div style="display: flex; gap: 0.5rem;">
144
+ <%= f.submit "Ban IP Address", class: "btn btn-danger" %>
145
+ <%= link_to "Cancel", banned_ips_path, class: "btn btn-secondary" %>
146
+ </div>
147
+ </div>
148
+ <% end %>
149
+ </div>
150
+ </div>
151
+
152
+ <!-- Preview Card -->
153
+ <div class="card" id="ban-preview" style="display: none;">
154
+ <div class="card-header">
155
+ <h3 class="card-title">Ban Preview</h3>
156
+ <div class="card-subtitle">This is how the ban will appear</div>
157
+ </div>
158
+ <div class="card-body">
159
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
160
+ <div>
161
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
162
+ IP Address
163
+ </div>
164
+ <div style="font-family: monospace; font-size: 14px; color: #1A1F36; margin-top: 0.25rem;" id="preview-ip">
165
+ -
166
+ </div>
167
+ </div>
168
+ <div>
169
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
170
+ Reason
171
+ </div>
172
+ <div style="font-size: 14px; color: #1A1F36; margin-top: 0.25rem;" id="preview-reason">
173
+ -
174
+ </div>
175
+ </div>
176
+ <div>
177
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
178
+ Duration
179
+ </div>
180
+ <div style="font-size: 14px; color: #1A1F36; margin-top: 0.25rem;" id="preview-duration">
181
+ -
182
+ </div>
183
+ </div>
184
+ <div>
185
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
186
+ Status
187
+ </div>
188
+ <div style="margin-top: 0.25rem;">
189
+ <span class="badge badge-danger" id="preview-status">Active</span>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </div>
195
+
196
+ <script>
197
+ function toggleDurationFields() {
198
+ const isPermanent = document.querySelector('input[name="ban_type"]:checked').value === 'permanent';
199
+ const temporaryOptions = document.getElementById('temporary-options');
200
+
201
+ if (isPermanent) {
202
+ temporaryOptions.style.display = 'none';
203
+ // Clear duration selections
204
+ document.querySelectorAll('input[name="duration"]').forEach(input => {
205
+ input.checked = false;
206
+ });
207
+ document.getElementById('banned_ip_expires_at').value = '';
208
+ } else {
209
+ temporaryOptions.style.display = 'block';
210
+ }
211
+
212
+ updatePreview();
213
+ }
214
+
215
+ function updatePreview() {
216
+ const previewCard = document.getElementById('ban-preview');
217
+ const ipInput = document.getElementById('banned_ip_ip_address');
218
+ const reasonSelect = document.getElementById('banned_ip_reason');
219
+
220
+ if (ipInput.value || reasonSelect.value) {
221
+ previewCard.style.display = 'block';
222
+
223
+ // Update IP
224
+ document.getElementById('preview-ip').textContent = ipInput.value || '-';
225
+
226
+ // Update reason
227
+ const reasonText = reasonSelect.options[reasonSelect.selectedIndex]?.text || '-';
228
+ document.getElementById('preview-reason').textContent = reasonText;
229
+
230
+ // Update duration
231
+ const isPermanent = document.querySelector('input[name="ban_type"]:checked')?.value === 'permanent';
232
+ if (isPermanent) {
233
+ document.getElementById('preview-duration').textContent = 'Permanent';
234
+ document.getElementById('preview-duration').style.color = '#D32F2F';
235
+ document.getElementById('preview-status').className = 'badge badge-critical';
236
+ document.getElementById('preview-status').textContent = 'Permanent';
237
+ } else {
238
+ const durationRadio = document.querySelector('input[name="duration"]:checked');
239
+ const customExpiry = document.getElementById('banned_ip_expires_at').value;
240
+
241
+ if (customExpiry) {
242
+ const expiryDate = new Date(customExpiry);
243
+ document.getElementById('preview-duration').textContent = expiryDate.toLocaleString();
244
+ } else if (durationRadio) {
245
+ const label = durationRadio.parentElement.textContent.trim();
246
+ document.getElementById('preview-duration').textContent = label;
247
+ } else {
248
+ document.getElementById('preview-duration').textContent = 'Not specified';
249
+ }
250
+ document.getElementById('preview-duration').style.color = '#1A1F36';
251
+ document.getElementById('preview-status').className = 'badge badge-danger';
252
+ document.getElementById('preview-status').textContent = 'Active';
253
+ }
254
+ } else {
255
+ previewCard.style.display = 'none';
256
+ }
257
+ }
258
+
259
+ // Set up event listeners
260
+ document.addEventListener('DOMContentLoaded', function() {
261
+ // Update preview on input changes
262
+ document.getElementById('banned_ip_ip_address').addEventListener('input', updatePreview);
263
+ document.getElementById('banned_ip_reason').addEventListener('change', updatePreview);
264
+ document.getElementById('banned_ip_expires_at').addEventListener('change', updatePreview);
265
+
266
+ document.querySelectorAll('input[name="duration"]').forEach(input => {
267
+ input.addEventListener('change', function() {
268
+ // Clear custom expiry when selecting a preset duration
269
+ document.getElementById('banned_ip_expires_at').value = '';
270
+ updatePreview();
271
+ });
272
+ });
273
+
274
+ // Clear preset duration when entering custom expiry
275
+ document.getElementById('banned_ip_expires_at').addEventListener('input', function() {
276
+ if (this.value) {
277
+ document.querySelectorAll('input[name="duration"]').forEach(input => {
278
+ input.checked = false;
279
+ });
280
+ }
281
+ });
282
+
283
+ // Initial preview update
284
+ updatePreview();
285
+
286
+ // Handle form submission
287
+ document.querySelector('form').addEventListener('submit', function(e) {
288
+ const isPermanent = document.querySelector('input[name="ban_type"]:checked').value === 'permanent';
289
+
290
+ if (isPermanent) {
291
+ // Set permanent flag
292
+ const permanentField = document.createElement('input');
293
+ permanentField.type = 'hidden';
294
+ permanentField.name = 'banned_ip[permanent]';
295
+ permanentField.value = 'true';
296
+ this.appendChild(permanentField);
297
+ } else {
298
+ // Calculate expires_at based on duration if not custom
299
+ const durationRadio = document.querySelector('input[name="duration"]:checked');
300
+ const customExpiry = document.getElementById('banned_ip_expires_at').value;
301
+
302
+ if (!customExpiry && durationRadio) {
303
+ const durationSeconds = parseInt(durationRadio.value);
304
+ const expiresAt = new Date(Date.now() + durationSeconds * 1000);
305
+ document.getElementById('banned_ip_expires_at').value = expiresAt.toISOString().slice(0, 16);
306
+ }
307
+ }
308
+ });
309
+ });
310
+ </script>