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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +274 -0
- data/README.md +412 -204
- data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
- data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
- data/app/controllers/beskar/application_controller.rb +214 -0
- data/app/controllers/beskar/banned_ips_controller.rb +255 -0
- data/app/controllers/beskar/dashboard_controller.rb +62 -0
- data/app/controllers/beskar/security_events_controller.rb +164 -0
- data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
- data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
- data/app/jobs/beskar/notification_job.rb +33 -0
- data/app/mailers/beskar/security_mailer.rb +59 -0
- data/app/models/beskar/administrative_action.rb +41 -0
- data/app/models/beskar/banned_ip.rb +105 -105
- data/app/models/beskar/security_event.rb +51 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +88 -0
- data/app/views/beskar/administrative_actions/index.html.erb +33 -0
- data/app/views/beskar/administrative_actions/show.html.erb +21 -0
- data/app/views/beskar/banned_ips/edit.html.erb +195 -0
- data/app/views/beskar/banned_ips/index.html.erb +319 -0
- data/app/views/beskar/banned_ips/new.html.erb +190 -0
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +304 -0
- data/app/views/beskar/dashboard/index.html.erb +280 -0
- data/app/views/beskar/security_events/index.html.erb +302 -0
- data/app/views/beskar/security_events/show.html.erb +293 -0
- data/app/views/beskar/shared/_export_form.html.erb +10 -0
- data/app/views/layouts/beskar/_behavior.html.erb +121 -0
- data/app/views/layouts/beskar/application.html.erb +581 -6
- data/config/routes.rb +30 -0
- data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
- data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
- data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
- data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
- data/docs/README.md +73 -0
- data/docs/archive/project-documentation.md +659 -0
- data/docs/audits/project-review.md +437 -0
- data/docs/audits/repair-status.md +216 -0
- data/docs/guides/audit-and-waf.md +175 -0
- data/docs/guides/audit-lifecycle.md +172 -0
- data/docs/guides/authentication.md +213 -0
- data/docs/guides/configuration.md +182 -0
- data/docs/guides/dashboard-and-search.md +251 -0
- data/docs/guides/notifications-and-recovery.md +157 -0
- data/docs/guides/risk-scoring.md +116 -0
- data/docs/operations/monitor-only-mode.md +85 -0
- data/docs/operations/security-hardening.md +167 -0
- data/docs/operations/state-storage.md +144 -0
- data/docs/research/rust-performance-assessment.md +69 -0
- data/lib/beskar/configuration.rb +105 -20
- data/lib/beskar/configuration_validator.rb +188 -0
- data/lib/beskar/devise_authentication.rb +24 -0
- data/lib/beskar/engine.rb +21 -88
- data/lib/beskar/logger.rb +288 -0
- data/lib/beskar/middleware/request_analyzer.rb +133 -99
- data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
- data/lib/beskar/models/security_trackable_devise.rb +34 -25
- data/lib/beskar/models/security_trackable_generic.rb +171 -214
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +90 -81
- data/lib/beskar/services/administrative_audit.rb +36 -0
- data/lib/beskar/services/administrative_bans.rb +104 -0
- data/lib/beskar/services/audit_data.rb +72 -0
- data/lib/beskar/services/authentication.rb +31 -0
- data/lib/beskar/services/authentication_attempt.rb +141 -0
- data/lib/beskar/services/ban_expiry.rb +28 -0
- data/lib/beskar/services/device_detector.rb +32 -41
- data/lib/beskar/services/event_search.rb +58 -0
- data/lib/beskar/services/geolocation_service.rb +83 -114
- data/lib/beskar/services/ip_whitelist.rb +31 -40
- data/lib/beskar/services/location_assessment.rb +109 -0
- data/lib/beskar/services/native_account_lock.rb +82 -0
- data/lib/beskar/services/notifications.rb +46 -0
- data/lib/beskar/services/rate_limiter.rb +99 -125
- data/lib/beskar/services/request_context.rb +64 -0
- data/lib/beskar/services/risk_assessment.rb +58 -0
- data/lib/beskar/services/session_revocation.rb +62 -0
- data/lib/beskar/services/waf.rb +311 -198
- data/lib/beskar/services/waf_request.rb +60 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar/warden_authentication.rb +53 -0
- data/lib/beskar.rb +54 -4
- data/lib/generators/beskar/install/install_generator.rb +158 -0
- data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
- data/lib/tasks/beskar_tasks.rake +25 -20
- metadata +93 -12
- data/lib/beskar/templates/beskar_initializer.rb +0 -107
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
<% content_for :page_title, "Security Event ##{@event.id}" %>
|
|
2
|
+
|
|
3
|
+
<% content_for :header_actions do %>
|
|
4
|
+
<%= link_to "← Back to Events", security_events_path, class: "btn btn-secondary" %>
|
|
5
|
+
<% unless Beskar::BannedIp.banned?(@event.ip_address) %>
|
|
6
|
+
<%= link_to "Ban This IP", new_banned_ip_path(ip_address: @event.ip_address, reason: "security_event_#{@event.id}"),
|
|
7
|
+
class: "btn btn-danger" %>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% end %>
|
|
10
|
+
|
|
11
|
+
<!-- Event Overview -->
|
|
12
|
+
<div class="card">
|
|
13
|
+
<div class="card-header">
|
|
14
|
+
<h3 class="card-title">Event Details</h3>
|
|
15
|
+
<div class="card-subtitle">Event ID: <%= @event.id %></div>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="card-body">
|
|
18
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem;">
|
|
19
|
+
<!-- Event Type -->
|
|
20
|
+
<div>
|
|
21
|
+
<div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
|
|
22
|
+
Event Type
|
|
23
|
+
</div>
|
|
24
|
+
<div style="font-size: 16px; font-weight: 500; color: #1A1F36;">
|
|
25
|
+
<%= format_event_type(@event.event_type) %>
|
|
26
|
+
</div>
|
|
27
|
+
<div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
|
|
28
|
+
<%= @event.event_type %>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<!-- Risk Score -->
|
|
33
|
+
<div>
|
|
34
|
+
<div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
|
|
35
|
+
Risk Score
|
|
36
|
+
</div>
|
|
37
|
+
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
|
38
|
+
<span style="font-size: 24px; font-weight: 600; color: <%= risk_level_color(@event.risk_score) %>;">
|
|
39
|
+
<%= @event.risk_score %>
|
|
40
|
+
</span>
|
|
41
|
+
<span class="badge badge-<%= risk_level_class(@event.risk_score) %>">
|
|
42
|
+
<%= risk_level_label(@event.risk_score) %>
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<!-- Timestamp -->
|
|
48
|
+
<div>
|
|
49
|
+
<div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
|
|
50
|
+
Occurred At
|
|
51
|
+
</div>
|
|
52
|
+
<div style="font-size: 14px; color: #1A1F36;">
|
|
53
|
+
<%= @event.created_at.strftime("%B %d, %Y") %>
|
|
54
|
+
</div>
|
|
55
|
+
<div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
|
|
56
|
+
<%= @event.created_at.strftime("%H:%M:%S %Z") %>
|
|
57
|
+
(<%= time_ago_in_words(@event.created_at) %> ago)
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<!-- IP Address -->
|
|
62
|
+
<div>
|
|
63
|
+
<div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
|
|
64
|
+
IP Address
|
|
65
|
+
</div>
|
|
66
|
+
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
|
67
|
+
<span style="font-family: monospace; font-size: 16px; color: #1A1F36;">
|
|
68
|
+
<%= @event.ip_address %>
|
|
69
|
+
</span>
|
|
70
|
+
<% if @ip_ban %>
|
|
71
|
+
<span class="badge badge-danger">Banned</span>
|
|
72
|
+
<% end %>
|
|
73
|
+
</div>
|
|
74
|
+
<% if @event.metadata&.dig("geolocation") %>
|
|
75
|
+
<div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
|
|
76
|
+
<%= [@event.metadata.dig("geolocation", "city"),
|
|
77
|
+
@event.metadata.dig("geolocation", "region"),
|
|
78
|
+
@event.metadata.dig("geolocation", "country")].compact.join(", ") %>
|
|
79
|
+
</div>
|
|
80
|
+
<% end %>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<!-- User -->
|
|
84
|
+
<div>
|
|
85
|
+
<div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
|
|
86
|
+
User
|
|
87
|
+
</div>
|
|
88
|
+
<% if @event.user %>
|
|
89
|
+
<div style="font-size: 14px; color: #1A1F36;">
|
|
90
|
+
<%= audit_user_label(@event) %>
|
|
91
|
+
</div>
|
|
92
|
+
<div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
|
|
93
|
+
ID: <%= @event.user_id %> • Type: <%= @event.user_type %>
|
|
94
|
+
</div>
|
|
95
|
+
<% elsif @event.attempted_email %>
|
|
96
|
+
<div style="font-size: 14px; color: #697386;">
|
|
97
|
+
<%= audit_user_label(@event) %> <span style="font-size: 12px;">(attempted)</span>
|
|
98
|
+
</div>
|
|
99
|
+
<% else %>
|
|
100
|
+
<div style="color: #C1C7D0;">No user associated</div>
|
|
101
|
+
<% end %>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<!-- Details -->
|
|
105
|
+
<% if @event.details.present? || @event.metadata&.dig("message").present? %>
|
|
106
|
+
<div>
|
|
107
|
+
<div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
|
|
108
|
+
Details
|
|
109
|
+
</div>
|
|
110
|
+
<div style="font-size: 14px; color: #1A1F36;">
|
|
111
|
+
<%= @event.details || @event.metadata&.dig("message") || "No additional details" %>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<% end %>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<!-- User Agent & Device Info -->
|
|
120
|
+
<div class="card">
|
|
121
|
+
<div class="card-header">
|
|
122
|
+
<h3 class="card-title">Device & Browser Information</h3>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="card-body">
|
|
125
|
+
<div style="display: grid; grid-template-columns: 1fr; gap: 1rem;">
|
|
126
|
+
<!-- User Agent -->
|
|
127
|
+
<div>
|
|
128
|
+
<div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
|
|
129
|
+
User Agent
|
|
130
|
+
</div>
|
|
131
|
+
<div style="font-family: monospace; font-size: 13px; color: #1A1F36; background: #F4F5F7; padding: 0.75rem; border-radius: 6px; word-break: break-all;">
|
|
132
|
+
<%= @event.user_agent || "Not recorded" %>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<% if @event.device_info.present? %>
|
|
137
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
|
138
|
+
<% @event.device_info.each do |key, value| %>
|
|
139
|
+
<div>
|
|
140
|
+
<div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
|
|
141
|
+
<%= key.to_s.humanize %>
|
|
142
|
+
</div>
|
|
143
|
+
<div style="font-size: 14px; color: #1A1F36; margin-top: 0.25rem;">
|
|
144
|
+
<%= value || "-" %>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
<% end %>
|
|
148
|
+
</div>
|
|
149
|
+
<% end %>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- Metadata -->
|
|
155
|
+
<% if @event.metadata.present? && @event.metadata.any? %>
|
|
156
|
+
<div class="card">
|
|
157
|
+
<div class="card-header">
|
|
158
|
+
<h3 class="card-title">Additional Metadata</h3>
|
|
159
|
+
</div>
|
|
160
|
+
<div class="card-body">
|
|
161
|
+
<pre style="font-family: monospace; font-size: 12px; background: #F4F5F7; padding: 1rem; border-radius: 6px; overflow-x: auto;"><%= JSON.pretty_generate(@event.metadata) %></pre>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
<% end %>
|
|
165
|
+
|
|
166
|
+
<!-- Related Events -->
|
|
167
|
+
<% if @related_events.any? %>
|
|
168
|
+
<div class="card">
|
|
169
|
+
<div class="card-header">
|
|
170
|
+
<h3 class="card-title">Related Events from Same IP</h3>
|
|
171
|
+
<div class="card-subtitle"><%= @related_events.count %> other events from <%= @event.ip_address %></div>
|
|
172
|
+
</div>
|
|
173
|
+
<div class="card-body" style="padding: 0;">
|
|
174
|
+
<div class="table-container">
|
|
175
|
+
<table>
|
|
176
|
+
<thead>
|
|
177
|
+
<tr>
|
|
178
|
+
<th>Time</th>
|
|
179
|
+
<th>Event Type</th>
|
|
180
|
+
<th>User</th>
|
|
181
|
+
<th>Risk Score</th>
|
|
182
|
+
<th>Actions</th>
|
|
183
|
+
</tr>
|
|
184
|
+
</thead>
|
|
185
|
+
<tbody>
|
|
186
|
+
<% @related_events.each do |event| %>
|
|
187
|
+
<tr>
|
|
188
|
+
<td>
|
|
189
|
+
<div style="font-size: 13px;">
|
|
190
|
+
<%= event.created_at.strftime("%Y-%m-%d %H:%M:%S") %>
|
|
191
|
+
</div>
|
|
192
|
+
<div style="font-size: 11px; color: #697386;">
|
|
193
|
+
<%= time_ago_in_words(event.created_at) %> ago
|
|
194
|
+
</div>
|
|
195
|
+
</td>
|
|
196
|
+
<td>
|
|
197
|
+
<div style="font-size: 13px;">
|
|
198
|
+
<%= format_event_type(event.event_type) %>
|
|
199
|
+
</div>
|
|
200
|
+
</td>
|
|
201
|
+
<td>
|
|
202
|
+
<% if event.user %>
|
|
203
|
+
<%= audit_user_label(event) %>
|
|
204
|
+
<% elsif event.attempted_email %>
|
|
205
|
+
<span style="color: #697386;"><%= audit_user_label(event) %></span>
|
|
206
|
+
<% else %>
|
|
207
|
+
<span style="color: #C1C7D0;">-</span>
|
|
208
|
+
<% end %>
|
|
209
|
+
</td>
|
|
210
|
+
<td>
|
|
211
|
+
<span class="badge badge-<%= risk_level_class(event.risk_score) %>">
|
|
212
|
+
<%= event.risk_score %>
|
|
213
|
+
</span>
|
|
214
|
+
</td>
|
|
215
|
+
<td>
|
|
216
|
+
<%= link_to "View", security_event_path(event), class: "btn btn-sm btn-secondary" %>
|
|
217
|
+
</td>
|
|
218
|
+
</tr>
|
|
219
|
+
<% end %>
|
|
220
|
+
</tbody>
|
|
221
|
+
</table>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
<% end %>
|
|
226
|
+
|
|
227
|
+
<!-- User Events -->
|
|
228
|
+
<% if @user_events&.any? %>
|
|
229
|
+
<div class="card">
|
|
230
|
+
<div class="card-header">
|
|
231
|
+
<h3 class="card-title">User's Recent Events</h3>
|
|
232
|
+
<div class="card-subtitle"><%= @user_events.count %> other events from this user</div>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="card-body" style="padding: 0;">
|
|
235
|
+
<div class="table-container">
|
|
236
|
+
<table>
|
|
237
|
+
<thead>
|
|
238
|
+
<tr>
|
|
239
|
+
<th>Time</th>
|
|
240
|
+
<th>Event Type</th>
|
|
241
|
+
<th>IP Address</th>
|
|
242
|
+
<th>Risk Score</th>
|
|
243
|
+
<th>Actions</th>
|
|
244
|
+
</tr>
|
|
245
|
+
</thead>
|
|
246
|
+
<tbody>
|
|
247
|
+
<% @user_events.each do |event| %>
|
|
248
|
+
<tr>
|
|
249
|
+
<td>
|
|
250
|
+
<div style="font-size: 13px;">
|
|
251
|
+
<%= event.created_at.strftime("%Y-%m-%d %H:%M:%S") %>
|
|
252
|
+
</div>
|
|
253
|
+
<div style="font-size: 11px; color: #697386;">
|
|
254
|
+
<%= time_ago_in_words(event.created_at) %> ago
|
|
255
|
+
</div>
|
|
256
|
+
</td>
|
|
257
|
+
<td>
|
|
258
|
+
<div style="font-size: 13px;">
|
|
259
|
+
<%= format_event_type(event.event_type) %>
|
|
260
|
+
</div>
|
|
261
|
+
</td>
|
|
262
|
+
<td>
|
|
263
|
+
<div style="font-family: monospace; font-size: 13px;">
|
|
264
|
+
<%= event.ip_address %>
|
|
265
|
+
</div>
|
|
266
|
+
</td>
|
|
267
|
+
<td>
|
|
268
|
+
<span class="badge badge-<%= risk_level_class(event.risk_score) %>">
|
|
269
|
+
<%= event.risk_score %>
|
|
270
|
+
</span>
|
|
271
|
+
</td>
|
|
272
|
+
<td>
|
|
273
|
+
<%= link_to "View", security_event_path(event), class: "btn btn-sm btn-secondary" %>
|
|
274
|
+
</td>
|
|
275
|
+
</tr>
|
|
276
|
+
<% end %>
|
|
277
|
+
</tbody>
|
|
278
|
+
</table>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
<% end %>
|
|
283
|
+
|
|
284
|
+
<!-- Actions -->
|
|
285
|
+
<div style="display: flex; gap: 1rem; margin-top: 2rem;">
|
|
286
|
+
<%= link_to "← Back to Events", security_events_path, class: "btn btn-secondary" %>
|
|
287
|
+
<% unless @ip_ban %>
|
|
288
|
+
<%= link_to "Ban This IP", new_banned_ip_path(ip_address: @event.ip_address, reason: "security_event_#{@event.id}"),
|
|
289
|
+
class: "btn btn-danger" %>
|
|
290
|
+
<% else %>
|
|
291
|
+
<%= link_to "View Ban Details", banned_ip_path(@ip_ban), class: "btn btn-primary" %>
|
|
292
|
+
<% end %>
|
|
293
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%= form_with url: export_url, method: :get, local: true do %>
|
|
2
|
+
<% request.query_parameters.except("format", "audit_reason", "page", "per_page").each do |key, value| %>
|
|
3
|
+
<%= hidden_field_tag key, value if value.is_a?(String) %>
|
|
4
|
+
<% end %>
|
|
5
|
+
<%= label_tag :export_audit_reason, "Export reason" %>
|
|
6
|
+
<%= text_field_tag :audit_reason, nil, id: :export_audit_reason, required: true, maxlength: 1000 %>
|
|
7
|
+
<%= label_tag :export_format, "Export format" %>
|
|
8
|
+
<%= select_tag :format, options_for_select([["CSV", "csv"], ["JSON", "json"]]), id: :export_format %>
|
|
9
|
+
<%= submit_tag "Export", class: "btn btn-secondary" %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<%= javascript_tag nonce: true do %>
|
|
2
|
+
(() => {
|
|
3
|
+
// One delegated installation per document, even if a host evaluates the
|
|
4
|
+
// layout again. Native navigation is the dashboard's supported boundary.
|
|
5
|
+
if (window.beskarDashboardBehaviorInstalled) return;
|
|
6
|
+
window.beskarDashboardBehaviorInstalled = true;
|
|
7
|
+
|
|
8
|
+
function isPermanent(form) {
|
|
9
|
+
return form.querySelector('[name="ban_type"]:checked')?.value === 'permanent' ||
|
|
10
|
+
form.querySelector('[name="banned_ip[permanent]"]:checked')?.value === 'true';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function updateBanForm(form) {
|
|
14
|
+
const permanent = isPermanent(form);
|
|
15
|
+
const options = form.querySelector('#temporary-options');
|
|
16
|
+
options.hidden = permanent;
|
|
17
|
+
options.querySelectorAll('input, select, button').forEach(input => { input.disabled = permanent; });
|
|
18
|
+
const expiry = form.querySelector('#banned_ip_expires_at');
|
|
19
|
+
expiry.required = !permanent && !!form.querySelector('[name="banned_ip[permanent]"]');
|
|
20
|
+
const preview = document.getElementById('ban-preview');
|
|
21
|
+
if (!preview) return;
|
|
22
|
+
const ip = form.querySelector('#banned_ip_ip_address');
|
|
23
|
+
const reason = form.querySelector('#banned_ip_reason');
|
|
24
|
+
preview.hidden = !(ip.value || reason.value);
|
|
25
|
+
document.getElementById('preview-ip').textContent = ip.value || '-';
|
|
26
|
+
document.getElementById('preview-reason').textContent = reason.selectedOptions[0]?.textContent || '-';
|
|
27
|
+
const duration = form.querySelector('[name="duration"]:checked');
|
|
28
|
+
document.getElementById('preview-duration').textContent = permanent ? 'Permanent' :
|
|
29
|
+
(expiry.value ? `${expiry.value.replace('T', ' ')} UTC` : (duration?.parentElement.textContent.trim() || '24 hours (server default)'));
|
|
30
|
+
const status = document.getElementById('preview-status');
|
|
31
|
+
status.textContent = permanent ? 'Permanent' : 'Temporary';
|
|
32
|
+
status.className = permanent ? 'badge badge-critical' : 'badge badge-danger';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function updateSelection(form) {
|
|
36
|
+
const boxes = Array.from(form.querySelectorAll('.ban-checkbox'));
|
|
37
|
+
const count = boxes.filter(box => box.checked).length;
|
|
38
|
+
form.querySelector('#selected-count').textContent = count;
|
|
39
|
+
form.querySelector('#bulk-actions-bar').hidden = count === 0;
|
|
40
|
+
const all = form.querySelector('#select-all');
|
|
41
|
+
all.checked = boxes.length > 0 && count === boxes.length;
|
|
42
|
+
all.indeterminate = count > 0 && count < boxes.length;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function initialize() {
|
|
46
|
+
if (!document.querySelector('[data-beskar-dashboard]')) return;
|
|
47
|
+
document.querySelectorAll('[data-beskar-ban-form]').forEach(updateBanForm);
|
|
48
|
+
document.querySelectorAll('#bulk-actions-form').forEach(updateSelection);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
document.addEventListener('change', event => {
|
|
52
|
+
if (!event.target.closest('[data-beskar-dashboard]')) return;
|
|
53
|
+
if (event.target.matches('[data-beskar-auto-submit]')) event.target.form.requestSubmit();
|
|
54
|
+
const form = event.target.closest('[data-beskar-ban-form]');
|
|
55
|
+
if (form) {
|
|
56
|
+
const expiry = form.querySelector('#banned_ip_expires_at');
|
|
57
|
+
if (event.target.name === 'duration') expiry.value = '';
|
|
58
|
+
if (event.target.name === 'banned_ip[permanent]' && !isPermanent(form) && !expiry.value) {
|
|
59
|
+
expiry.value = new Date(Date.now() + 86400000).toISOString().slice(0, -1);
|
|
60
|
+
}
|
|
61
|
+
updateBanForm(form);
|
|
62
|
+
}
|
|
63
|
+
const bulk = event.target.closest('#bulk-actions-form');
|
|
64
|
+
if (bulk) {
|
|
65
|
+
if (event.target.id === 'select-all') {
|
|
66
|
+
bulk.querySelectorAll('.ban-checkbox').forEach(box => { box.checked = event.target.checked; });
|
|
67
|
+
}
|
|
68
|
+
updateSelection(bulk);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
document.addEventListener('input', event => {
|
|
73
|
+
const form = event.target.closest('[data-beskar-ban-form]');
|
|
74
|
+
if (!form) return;
|
|
75
|
+
if (event.target.id === 'banned_ip_expires_at' && event.target.value) {
|
|
76
|
+
form.querySelectorAll('[name="duration"]').forEach(input => { input.checked = false; });
|
|
77
|
+
}
|
|
78
|
+
updateBanForm(form);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
document.addEventListener('click', event => {
|
|
82
|
+
if (!event.target.closest('[data-beskar-dashboard]')) return;
|
|
83
|
+
const clear = event.target.closest('[data-beskar-clear-selection]');
|
|
84
|
+
if (clear) {
|
|
85
|
+
const form = clear.closest('form');
|
|
86
|
+
form.querySelectorAll('.ban-checkbox').forEach(box => { box.checked = false; });
|
|
87
|
+
updateSelection(form);
|
|
88
|
+
}
|
|
89
|
+
const extend = event.target.closest('[data-beskar-extend-hours]');
|
|
90
|
+
if (extend) {
|
|
91
|
+
const form = extend.closest('form');
|
|
92
|
+
const expiry = form.querySelector('#banned_ip_expires_at');
|
|
93
|
+
// The displayed wall-clock value is UTC, never browser-local time.
|
|
94
|
+
const base = expiry.value ? Date.parse(`${expiry.value}Z`) : Date.now();
|
|
95
|
+
if (Number.isFinite(base)) {
|
|
96
|
+
expiry.value = new Date(Math.max(base, Date.now()) + Number(extend.dataset.beskarExtendHours) * 3600000).toISOString().slice(0, -1);
|
|
97
|
+
updateBanForm(form);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const permanent = event.target.closest('[data-beskar-make-permanent]');
|
|
101
|
+
if (permanent) {
|
|
102
|
+
const form = permanent.closest('form');
|
|
103
|
+
form.querySelector('[name="banned_ip[permanent]"][value="true"]').checked = true;
|
|
104
|
+
updateBanForm(form);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
document.addEventListener('submit', event => {
|
|
109
|
+
if (!event.target.closest('[data-beskar-dashboard]')) return;
|
|
110
|
+
const message = event.submitter?.dataset.beskarConfirm;
|
|
111
|
+
if (message && !window.confirm(message)) event.preventDefault();
|
|
112
|
+
// Do not synthesize forms, bypass validation, or convert duration presets
|
|
113
|
+
// to client-clock timestamps. Rails forms carry CSRF and method fields.
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initialize, { once: true });
|
|
117
|
+
else initialize();
|
|
118
|
+
document.addEventListener('pageshow', initialize);
|
|
119
|
+
document.addEventListener('turbo:load', initialize);
|
|
120
|
+
})();
|
|
121
|
+
<% end %>
|