beskar 0.0.2 → 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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +274 -0
  3. data/README.md +412 -204
  4. data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
  5. data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
  6. data/app/controllers/beskar/application_controller.rb +214 -0
  7. data/app/controllers/beskar/banned_ips_controller.rb +255 -0
  8. data/app/controllers/beskar/dashboard_controller.rb +62 -0
  9. data/app/controllers/beskar/security_events_controller.rb +164 -0
  10. data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
  11. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
  12. data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
  13. data/app/jobs/beskar/notification_job.rb +33 -0
  14. data/app/mailers/beskar/security_mailer.rb +59 -0
  15. data/app/models/beskar/administrative_action.rb +41 -0
  16. data/app/models/beskar/banned_ip.rb +105 -105
  17. data/app/models/beskar/security_event.rb +51 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +88 -0
  20. data/app/views/beskar/administrative_actions/index.html.erb +33 -0
  21. data/app/views/beskar/administrative_actions/show.html.erb +21 -0
  22. data/app/views/beskar/banned_ips/edit.html.erb +195 -0
  23. data/app/views/beskar/banned_ips/index.html.erb +319 -0
  24. data/app/views/beskar/banned_ips/new.html.erb +190 -0
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +304 -0
  27. data/app/views/beskar/dashboard/index.html.erb +280 -0
  28. data/app/views/beskar/security_events/index.html.erb +302 -0
  29. data/app/views/beskar/security_events/show.html.erb +293 -0
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  32. data/app/views/layouts/beskar/application.html.erb +581 -6
  33. data/config/routes.rb +30 -0
  34. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  35. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  36. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  37. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  38. data/docs/README.md +73 -0
  39. data/docs/archive/project-documentation.md +659 -0
  40. data/docs/audits/project-review.md +437 -0
  41. data/docs/audits/repair-status.md +216 -0
  42. data/docs/guides/audit-and-waf.md +175 -0
  43. data/docs/guides/audit-lifecycle.md +172 -0
  44. data/docs/guides/authentication.md +213 -0
  45. data/docs/guides/configuration.md +182 -0
  46. data/docs/guides/dashboard-and-search.md +251 -0
  47. data/docs/guides/notifications-and-recovery.md +157 -0
  48. data/docs/guides/risk-scoring.md +116 -0
  49. data/docs/operations/monitor-only-mode.md +85 -0
  50. data/docs/operations/security-hardening.md +167 -0
  51. data/docs/operations/state-storage.md +144 -0
  52. data/docs/research/rust-performance-assessment.md +69 -0
  53. data/lib/beskar/configuration.rb +105 -20
  54. data/lib/beskar/configuration_validator.rb +188 -0
  55. data/lib/beskar/devise_authentication.rb +24 -0
  56. data/lib/beskar/engine.rb +21 -88
  57. data/lib/beskar/logger.rb +288 -0
  58. data/lib/beskar/middleware/request_analyzer.rb +133 -99
  59. data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
  60. data/lib/beskar/models/security_trackable_devise.rb +34 -25
  61. data/lib/beskar/models/security_trackable_generic.rb +171 -214
  62. data/lib/beskar/risk_level.rb +22 -0
  63. data/lib/beskar/services/account_locker.rb +90 -81
  64. data/lib/beskar/services/administrative_audit.rb +36 -0
  65. data/lib/beskar/services/administrative_bans.rb +104 -0
  66. data/lib/beskar/services/audit_data.rb +72 -0
  67. data/lib/beskar/services/authentication.rb +31 -0
  68. data/lib/beskar/services/authentication_attempt.rb +141 -0
  69. data/lib/beskar/services/ban_expiry.rb +28 -0
  70. data/lib/beskar/services/device_detector.rb +32 -41
  71. data/lib/beskar/services/event_search.rb +58 -0
  72. data/lib/beskar/services/geolocation_service.rb +83 -114
  73. data/lib/beskar/services/ip_whitelist.rb +31 -40
  74. data/lib/beskar/services/location_assessment.rb +109 -0
  75. data/lib/beskar/services/native_account_lock.rb +82 -0
  76. data/lib/beskar/services/notifications.rb +46 -0
  77. data/lib/beskar/services/rate_limiter.rb +99 -125
  78. data/lib/beskar/services/request_context.rb +64 -0
  79. data/lib/beskar/services/risk_assessment.rb +58 -0
  80. data/lib/beskar/services/session_revocation.rb +62 -0
  81. data/lib/beskar/services/waf.rb +311 -198
  82. data/lib/beskar/services/waf_request.rb +60 -0
  83. data/lib/beskar/version.rb +1 -1
  84. data/lib/beskar/warden_authentication.rb +53 -0
  85. data/lib/beskar.rb +54 -4
  86. data/lib/generators/beskar/install/install_generator.rb +158 -0
  87. data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
  88. data/lib/tasks/beskar_tasks.rake +25 -20
  89. metadata +93 -12
  90. data/lib/beskar/templates/beskar_initializer.rb +0 -107
@@ -0,0 +1,195 @@
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, data: { beskar_ban_form: true } do |f| %>
14
+ <%= hidden_field_tag :expiry_precision, "milliseconds" %>
15
+ <% if @banned_ip.errors.any? %>
16
+ <div class="alert alert-danger">
17
+ <h4 style="font-size: 14px; font-weight: 600; margin-bottom: 0.5rem;">
18
+ <%= pluralize(@banned_ip.errors.count, "error") %> prohibited this ban from being saved:
19
+ </h4>
20
+ <ul style="margin: 0; padding-left: 1.5rem;">
21
+ <% @banned_ip.errors.full_messages.each do |message| %>
22
+ <li style="font-size: 13px;"><%= message %></li>
23
+ <% end %>
24
+ </ul>
25
+ </div>
26
+ <% end %>
27
+
28
+ <div style="display: grid; grid-template-columns: 1fr; gap: 1.5rem; max-width: 600px;">
29
+ <!-- IP Address (Read-only) -->
30
+ <div class="form-group">
31
+ <%= f.label :ip_address, "IP Address" %>
32
+ <%= f.text_field :ip_address,
33
+ disabled: true,
34
+ style: "font-family: monospace; background: #F4F5F7; cursor: not-allowed;" %>
35
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
36
+ IP addresses cannot be changed. To ban a different IP, create a new ban.
37
+ </div>
38
+ </div>
39
+
40
+ <!-- Current Status -->
41
+ <div class="form-group">
42
+ <label>Current Status</label>
43
+ <div style="display: flex; align-items: center; gap: 1rem;">
44
+ <% if @banned_ip.permanent? %>
45
+ <span class="badge badge-critical">Permanent Ban</span>
46
+ <% elsif @banned_ip.active? %>
47
+ <span class="badge badge-danger">Active</span>
48
+ <span style="font-size: 13px; color: #697386;">
49
+ Expires in <%= distance_of_time_in_words(Time.current, @banned_ip.expires_at) %>
50
+ </span>
51
+ <% elsif @banned_ip.expires_at %>
52
+ <span class="badge badge-neutral">Expired</span>
53
+ <span style="font-size: 13px; color: #697386;">
54
+ Expired <%= time_ago_in_words(@banned_ip.expires_at) %> ago
55
+ </span>
56
+ <% else %>
57
+ <span class="badge badge-warning">Expiry required for a temporary ban</span>
58
+ <% end %>
59
+ </div>
60
+ </div>
61
+
62
+ <!-- Reason -->
63
+ <div class="form-group">
64
+ <%= f.label :reason, "Ban Reason *" %>
65
+ <%= f.select :reason,
66
+ options_for_select(ban_reason_options, @banned_ip.reason),
67
+ { prompt: 'Select a reason...' },
68
+ { required: true } %>
69
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
70
+ Update the reason for this ban if needed.
71
+ </div>
72
+ </div>
73
+
74
+ <!-- Ban Duration -->
75
+ <div class="form-group">
76
+ <label>Ban Duration</label>
77
+ <div style="display: flex; flex-direction: column; gap: 0.5rem;">
78
+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
79
+ <%= f.radio_button :permanent, false,
80
+ checked: !@banned_ip.permanent? %>
81
+ <span style="font-weight: normal;">Temporary Ban</span>
82
+ </label>
83
+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
84
+ <%= f.radio_button :permanent, true,
85
+ checked: @banned_ip.permanent? %>
86
+ <span style="font-weight: normal; color: #D32F2F;">Permanent Ban</span>
87
+ </label>
88
+ </div>
89
+ <% if @banned_ip.permanent? %>
90
+ <div class="alert alert-warning" style="margin-top: 0.5rem;">
91
+ <strong>Warning:</strong> This is currently a permanent ban. Changing it to temporary will set an expiration date.
92
+ </div>
93
+ <% end %>
94
+ </div>
95
+
96
+ <!-- Temporary Ban Options -->
97
+ <div id="temporary-options">
98
+ <div class="form-group">
99
+ <%= f.label :expires_at, "Expiry Date/Time (UTC)" %>
100
+ <%= f.datetime_field :expires_at,
101
+ value: ban_expiry_input_value(@banned_ip.expires_at),
102
+ step: "0.001" %>
103
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
104
+ Enter the expiry in UTC, not your browser timezone. Temporary bans require an expiry.
105
+ </div>
106
+ </div>
107
+
108
+ <!-- Quick Extension Options -->
109
+ <div class="form-group">
110
+ <label>Quick Extension</label>
111
+ <noscript><p>Quick buttons require JavaScript. You can enter the UTC expiry directly or select Permanent Ban above.</p></noscript>
112
+ <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem;">
113
+ <button type="button" data-beskar-extend-hours="1" class="btn btn-sm btn-secondary">+1 Hour</button>
114
+ <button type="button" data-beskar-extend-hours="24" class="btn btn-sm btn-secondary">+1 Day</button>
115
+ <button type="button" data-beskar-extend-hours="168" class="btn btn-sm btn-secondary">+7 Days</button>
116
+ <button type="button" data-beskar-extend-hours="720" class="btn btn-sm btn-secondary">+30 Days</button>
117
+ <button type="button" data-beskar-extend-hours="2160" class="btn btn-sm btn-secondary">+90 Days</button>
118
+ <button type="button" data-beskar-make-permanent class="btn btn-sm btn-danger">Make Permanent</button>
119
+ </div>
120
+ </div>
121
+ </div>
122
+
123
+ <!-- Details -->
124
+ <div class="form-group">
125
+ <%= f.label :details, "Additional Details" %>
126
+ <%= f.text_area :details,
127
+ rows: 3,
128
+ placeholder: "Provide any additional context or details about this ban..." %>
129
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
130
+ Update any relevant information about why this IP is banned.
131
+ </div>
132
+ </div>
133
+
134
+ <!-- Violation Count -->
135
+ <div class="form-group">
136
+ <%= f.label :violation_count, "Violation Count" %>
137
+ <%= f.number_field :violation_count, min: 1 %>
138
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
139
+ Current violation count: <%= @banned_ip.violation_count %>. Higher counts may result in longer bans for repeat offenders.
140
+ </div>
141
+ </div>
142
+
143
+ <hr style="border: none; border-top: 1px solid #E3E8EE; margin: 1.5rem 0;">
144
+
145
+ <!-- Ban History -->
146
+ <div class="form-group">
147
+ <label>Ban History</label>
148
+ <div style="background: #FAFBFC; border: 1px solid #E3E8EE; border-radius: 6px; padding: 1rem;">
149
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem;">
150
+ <div>
151
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
152
+ Originally Banned
153
+ </div>
154
+ <div style="font-size: 13px; color: #1A1F36; margin-top: 0.25rem;">
155
+ <%= @banned_ip.banned_at.strftime("%Y-%m-%d %H:%M:%S") %>
156
+ </div>
157
+ </div>
158
+ <div>
159
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
160
+ Time Banned
161
+ </div>
162
+ <div style="font-size: 13px; color: #1A1F36; margin-top: 0.25rem;">
163
+ <%= time_ago_in_words(@banned_ip.banned_at) %>
164
+ </div>
165
+ </div>
166
+ <% if @banned_ip.updated_at != @banned_ip.created_at %>
167
+ <div>
168
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
169
+ Last Modified
170
+ </div>
171
+ <div style="font-size: 13px; color: #1A1F36; margin-top: 0.25rem;">
172
+ <%= @banned_ip.updated_at.strftime("%Y-%m-%d %H:%M:%S") %>
173
+ </div>
174
+ </div>
175
+ <% end %>
176
+ </div>
177
+ </div>
178
+ </div>
179
+
180
+ <div class="form-group">
181
+ <%= label_tag :audit_reason, "Administrative reason *" %>
182
+ <%= text_area_tag :audit_reason, params[:audit_reason], required: true, maxlength: 1000, rows: 2 %>
183
+ </div>
184
+
185
+ <!-- Submit Buttons -->
186
+ <div style="display: flex; gap: 0.5rem;">
187
+ <%= f.submit "Update Ban", class: "btn btn-primary" %>
188
+ <%= link_to "Cancel", banned_ip_path(@banned_ip), class: "btn btn-secondary" %>
189
+ <%= link_to "Delete Ban", review_banned_ip_path(@banned_ip, operation: "unban"),
190
+ class: "btn btn-danger" %>
191
+ </div>
192
+ </div>
193
+ <% end %>
194
+ </div>
195
+ </div>
@@ -0,0 +1,319 @@
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
+ <%= render "beskar/shared/export_form", export_url: export_banned_ips_path %>
7
+ </div>
8
+ <% end %>
9
+
10
+ <!-- Filters Card -->
11
+ <div class="card">
12
+ <div class="card-header">
13
+ <h3 class="card-title">Filters</h3>
14
+ <div class="card-subtitle">Search and filter banned IPs</div>
15
+ </div>
16
+ <div class="card-body">
17
+ <%= form_with url: banned_ips_path, method: :get, local: true do |f| %>
18
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
19
+ <!-- Status -->
20
+ <div class="form-group">
21
+ <label>Status</label>
22
+ <%= select_tag :status,
23
+ options_for_select([
24
+ ['All', ''],
25
+ ['Active', 'active'],
26
+ ['Expired', 'expired'],
27
+ ['Permanent', 'permanent'],
28
+ ['Temporary', 'temporary']
29
+ ], params[:status]),
30
+ style: "width: 100%;" %>
31
+ </div>
32
+
33
+ <!-- Reason -->
34
+ <div class="form-group">
35
+ <label>Reason</label>
36
+ <%= select_tag :reason,
37
+ options_for_select(@ban_reasons, params[:reason]),
38
+ prompt: 'All reasons',
39
+ style: "width: 100%;" %>
40
+ </div>
41
+
42
+ <!-- IP Search -->
43
+ <div class="form-group">
44
+ <label>IP Address</label>
45
+ <%= text_field_tag :ip_search, params[:ip_search],
46
+ placeholder: "Search IPs..." %>
47
+ </div>
48
+
49
+ <!-- Date Range -->
50
+ <div class="form-group">
51
+ <label>Banned After</label>
52
+ <%= date_field_tag :banned_after, params[:banned_after] %>
53
+ </div>
54
+
55
+ <div class="form-group">
56
+ <label>Banned Before</label>
57
+ <%= date_field_tag :banned_before, params[:banned_before] %>
58
+ </div>
59
+ </div>
60
+
61
+ <div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
62
+ <%= submit_tag "Apply Filters", class: "btn btn-primary" %>
63
+ <%= link_to "Clear Filters", banned_ips_path, class: "btn btn-secondary" %>
64
+ </div>
65
+ <% end %>
66
+ </div>
67
+ </div>
68
+
69
+ <!-- Results Summary -->
70
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
71
+ <div style="font-size: 14px; color: #697386;">
72
+ Showing <%= @pagination[:records].count %> of <%= number_with_delimiter(@pagination[:total_count]) %> banned IPs
73
+ </div>
74
+
75
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
76
+ <label for="per_page" style="margin: 0; font-size: 13px; color: #697386;">Per page:</label>
77
+ <%= form_with url: banned_ips_path, method: :get, local: true, style: "display: inline;" do |f| %>
78
+ <% request.query_parameters.except("per_page", "page").each do |key, value| %>
79
+ <%= hidden_field_tag key, value %>
80
+ <% end %>
81
+ <%= select_tag :per_page,
82
+ options_for_select([10, 25, 50, 100], params[:per_page]&.to_i || 25),
83
+ data: { beskar_auto_submit: true },
84
+ style: "padding: 0.25rem 0.5rem; font-size: 13px;" %>
85
+ <%= submit_tag "Apply page size", class: "btn btn-sm btn-secondary" %>
86
+ <% end %>
87
+ </div>
88
+ </div>
89
+
90
+ <!-- Bulk Actions Form -->
91
+ <%= form_with url: bulk_action_banned_ips_path, method: :post, local: true, id: "bulk-actions-form" do |f| %>
92
+ <!-- Bulk Actions Bar -->
93
+ <div class="card" id="bulk-actions-bar" style="margin-bottom: 1rem;">
94
+ <div class="card-body" style="padding: 0.75rem 1rem;">
95
+ <div style="display: flex; justify-content: space-between; align-items: center;">
96
+ <div style="display: flex; align-items: center; gap: 1rem;">
97
+ <span style="font-size: 14px; color: #697386;">
98
+ <span id="selected-count">0</span> items selected
99
+ </span>
100
+
101
+ <div class="form-group">
102
+ <%= label_tag :audit_reason, "Administrative reason (all selected bans)" %>
103
+ <%= text_field_tag :audit_reason, nil, required: true, maxlength: 1000 %>
104
+ </div>
105
+ <div style="display: flex; gap: 0.5rem;">
106
+ <%= button_tag "Unban Selected", name: "bulk_action", value: "unban",
107
+ class: "btn btn-sm btn-secondary",
108
+ data: { beskar_confirm: "Unban all selected IPs?" } %>
109
+ <%= button_tag "Make Permanent", name: "bulk_action", value: "make_permanent",
110
+ class: "btn btn-sm btn-danger",
111
+ data: { beskar_confirm: "Make all selected bans permanent?" } %>
112
+
113
+ <div style="display: flex; align-items: center; gap: 0.25rem;">
114
+ <%= select_tag :duration,
115
+ options_for_select([
116
+ ['Extend by 24h', '24h'],
117
+ ['Extend by 7d', '7d'],
118
+ ['Extend by 30d', '30d']
119
+ ]),
120
+ style: "padding: 0.25rem 0.5rem; font-size: 13px;" %>
121
+ <%= button_tag "Extend Selected", name: "bulk_action", value: "extend",
122
+ class: "btn btn-sm btn-warning" %>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <button type="button" data-beskar-clear-selection class="btn btn-sm btn-secondary">
128
+ Clear Selection
129
+ </button>
130
+ </div>
131
+ </div>
132
+ </div>
133
+
134
+ <!-- Banned IPs Table -->
135
+ <div class="card">
136
+ <div class="card-body" style="padding: 0;">
137
+ <div class="table-container">
138
+ <table>
139
+ <thead>
140
+ <tr>
141
+ <th style="width: 40px;">
142
+ <input type="checkbox" id="select-all" aria-label="Select all bans">
143
+ </th>
144
+ <th>IP Address</th>
145
+ <th>Reason</th>
146
+ <th>Banned At</th>
147
+ <th>Expires</th>
148
+ <th>Status</th>
149
+ <th>Violations</th>
150
+ <th>Actions</th>
151
+ </tr>
152
+ </thead>
153
+ <tbody>
154
+ <% if @banned_ips.any? %>
155
+ <% @banned_ips.each do |ban| %>
156
+ <tr>
157
+ <td>
158
+ <%= check_box_tag "ip_ids[]", ban.id, false,
159
+ class: "ban-checkbox", aria: { label: "Select ban #{ban.ip_address}" } %>
160
+ </td>
161
+ <td>
162
+ <div style="font-family: monospace; font-size: 14px; font-weight: 500;">
163
+ <%= ban.ip_address %>
164
+ </div>
165
+ <% if ban.metadata&.dig("geolocation", "country") %>
166
+ <div style="font-size: 11px; color: #697386; margin-top: 2px;">
167
+ <%= [ban.metadata.dig("geolocation", "city"),
168
+ ban.metadata.dig("geolocation", "country")].compact.join(", ") %>
169
+ </div>
170
+ <% end %>
171
+ </td>
172
+ <td>
173
+ <div style="font-size: 13px;">
174
+ <%= ban.reason.humanize %>
175
+ </div>
176
+ <% if ban.details.present? %>
177
+ <div style="font-size: 11px; color: #697386; margin-top: 2px;">
178
+ <%= truncate(ban.details, length: 50) %>
179
+ </div>
180
+ <% end %>
181
+ </td>
182
+ <td>
183
+ <div style="font-size: 13px;">
184
+ <%= ban.banned_at.strftime("%Y-%m-%d") %>
185
+ </div>
186
+ <div style="font-size: 11px; color: #697386;">
187
+ <%= ban.banned_at.strftime("%H:%M:%S") %>
188
+ </div>
189
+ </td>
190
+ <td>
191
+ <% if ban.permanent? %>
192
+ <span class="badge badge-critical">Never</span>
193
+ <% elsif ban.expires_at %>
194
+ <div style="font-size: 13px;">
195
+ <%= ban.expires_at.strftime("%Y-%m-%d %H:%M") %>
196
+ </div>
197
+ <div style="font-size: 11px; color: #697386;">
198
+ <%= distance_of_time_in_words(Time.current, ban.expires_at) %>
199
+ </div>
200
+ <% else %>
201
+ <span style="color: #C1C7D0;">-</span>
202
+ <% end %>
203
+ </td>
204
+ <td>
205
+ <% if ban.permanent? %>
206
+ <span class="badge badge-critical">Permanent</span>
207
+ <% elsif ban.active? %>
208
+ <span class="badge badge-danger">Active</span>
209
+ <% else %>
210
+ <span class="badge badge-neutral">Expired</span>
211
+ <% end %>
212
+ </td>
213
+ <td>
214
+ <div style="text-align: center;">
215
+ <% if ban.violation_count > 0 %>
216
+ <span style="font-size: 14px; font-weight: 600; color: <%= ban.violation_count > 3 ? '#D32F2F' : '#E91E63' %>;">
217
+ <%= ban.violation_count %>
218
+ </span>
219
+ <% else %>
220
+ <span style="color: #C1C7D0;">0</span>
221
+ <% end %>
222
+ </div>
223
+ </td>
224
+ <td>
225
+ <div style="display: flex; gap: 0.25rem; align-items: center;">
226
+ <%= link_to "View", banned_ip_path(ban),
227
+ class: "btn btn-sm btn-secondary" %>
228
+
229
+ <% if ban.active? && !ban.permanent? %>
230
+ <%= link_to "Extend", review_banned_ip_path(ban, operation: 'extend', duration: '24h'),
231
+ class: "btn btn-sm btn-warning" %>
232
+ <% end %>
233
+
234
+ <%= link_to "Unban", review_banned_ip_path(ban, operation: 'unban'),
235
+ class: "btn btn-sm btn-danger" %>
236
+ </div>
237
+ </td>
238
+ </tr>
239
+ <% end %>
240
+ <% else %>
241
+ <tr>
242
+ <td colspan="8" style="text-align: center; padding: 3rem;">
243
+ <div style="color: #697386;">
244
+ <svg width="48" height="48" fill="currentColor" viewBox="0 0 24 24" style="margin: 0 auto 1rem;">
245
+ <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"/>
246
+ </svg>
247
+ <div style="font-size: 16px; font-weight: 500; margin-bottom: 0.5rem;">No banned IPs found</div>
248
+ <div style="font-size: 14px;">Try adjusting your filters or add a new ban</div>
249
+ </div>
250
+ </td>
251
+ </tr>
252
+ <% end %>
253
+ </tbody>
254
+ </table>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ <% end %>
259
+
260
+ <!-- Pagination -->
261
+ <% if @pagination[:total_pages] > 1 %>
262
+ <div class="pagination">
263
+ <% if @pagination[:has_previous] %>
264
+ <%= link_to "← Previous", banned_ips_path(params.to_unsafe_h.merge(page: @pagination[:previous_page])),
265
+ class: "pagination-link" %>
266
+ <% else %>
267
+ <span class="pagination-link disabled">← Previous</span>
268
+ <% end %>
269
+
270
+ <% # Show page numbers %>
271
+ <% if @pagination[:total_pages] <= 7 %>
272
+ <% (1..@pagination[:total_pages]).each do |page| %>
273
+ <%= link_to page, banned_ips_path(params.to_unsafe_h.merge(page: page)),
274
+ class: "pagination-link #{'active' if page == @pagination[:current_page]}" %>
275
+ <% end %>
276
+ <% else %>
277
+ <% if @pagination[:current_page] <= 4 %>
278
+ <% (1..5).each do |page| %>
279
+ <%= link_to page, banned_ips_path(params.to_unsafe_h.merge(page: page)),
280
+ class: "pagination-link #{'active' if page == @pagination[:current_page]}" %>
281
+ <% end %>
282
+ <span class="pagination-link disabled">...</span>
283
+ <%= link_to @pagination[:total_pages], banned_ips_path(params.to_unsafe_h.merge(page: @pagination[:total_pages])),
284
+ class: "pagination-link" %>
285
+ <% elsif @pagination[:current_page] >= @pagination[:total_pages] - 3 %>
286
+ <%= link_to 1, banned_ips_path(params.to_unsafe_h.merge(page: 1)),
287
+ class: "pagination-link" %>
288
+ <span class="pagination-link disabled">...</span>
289
+ <% ((@pagination[:total_pages] - 4)..@pagination[:total_pages]).each do |page| %>
290
+ <%= link_to page, banned_ips_path(params.to_unsafe_h.merge(page: page)),
291
+ class: "pagination-link #{'active' if page == @pagination[:current_page]}" %>
292
+ <% end %>
293
+ <% else %>
294
+ <%= link_to 1, banned_ips_path(params.to_unsafe_h.merge(page: 1)),
295
+ class: "pagination-link" %>
296
+ <span class="pagination-link disabled">...</span>
297
+ <% ((@pagination[:current_page] - 1)..(@pagination[:current_page] + 1)).each do |page| %>
298
+ <%= link_to page, banned_ips_path(params.to_unsafe_h.merge(page: page)),
299
+ class: "pagination-link #{'active' if page == @pagination[:current_page]}" %>
300
+ <% end %>
301
+ <span class="pagination-link disabled">...</span>
302
+ <%= link_to @pagination[:total_pages], banned_ips_path(params.to_unsafe_h.merge(page: @pagination[:total_pages])),
303
+ class: "pagination-link" %>
304
+ <% end %>
305
+ <% end %>
306
+
307
+ <% if @pagination[:has_next] %>
308
+ <%= link_to "Next →", banned_ips_path(params.to_unsafe_h.merge(page: @pagination[:next_page])),
309
+ class: "pagination-link" %>
310
+ <% else %>
311
+ <span class="pagination-link disabled">Next →</span>
312
+ <% end %>
313
+ </div>
314
+
315
+ <div style="text-align: center; margin-top: 1rem; font-size: 13px; color: #697386;">
316
+ Page <%= @pagination[:current_page] %> of <%= @pagination[:total_pages] %> •
317
+ Total: <%= number_with_delimiter(@pagination[:total_count]) %> banned IPs
318
+ </div>
319
+ <% end %>