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,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 %>