PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.5
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
vigilante / assets / js / admin.js

admin.js in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 3.0.5, at assets/js/admin.js

4,384 lines 196.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Vigilante Admin JavaScript
3 *
4 * @package Vigilante
5 */
6
7 (function($) {
8 'use strict';
9
10 var Vigilante_Admin = {
11
12 /**
13 * Initialize
14 */
15 init: function() {
16 this.bindEvents();
17 this.initModuleToggles();
18 this.initFileIntegrityPagination();
19
20 // Activity log pagination state
21 this.logPage = 1;
22 this.logPerPage = 20;
23 this.logSearchTimer = null;
24 },
25
26 /**
27 * Bind events
28 */
29 bindEvents: function() {
30 // Save settings forms
31 $(document).on('submit', '.vigilante-settings-form', this.handleFormSubmit.bind(this));
32
33 // Module toggles in dashboard
34
35 // Apply preset
36 $(document).on('click', '.vigilante-preset-btn', this.handleApplyPreset.bind(this));
37
38 // Reset section to defaults
39 $(document).on('click', '.vigilante-reset-section-btn', this.handleResetSection.bind(this));
40
41 // Clear lockouts
42 $(document).on('click', '.vigilante-clear-lockout', this.handleClearLockout.bind(this));
43 $(document).on('click', '.vigilante-clear-all-lockouts', this.handleClearAllLockouts.bind(this));
44
45 // Unblock firewall rate-limited IPs
46 $(document).on('click', '.vigilante-unblock-firewall-ip', this.handleUnblockFirewallIp.bind(this));
47
48 // Clear logs
49 $(document).on('click', '.vigilante-clear-logs', this.handleClearLogs.bind(this));
50
51 // Run scan
52 $(document).on('click', '.vigilante-run-scan', this.handleRunScan.bind(this));
53 $(document).on('click', '.vigilante-ignore-file', this.handleIgnoreFile.bind(this));
54 $(document).on('click', '.vigilante-unignore-file', this.handleUnignoreFile.bind(this));
55 $(document).on('click', '.vigilante-clear-ignored', this.handleClearIgnored.bind(this));
56
57 // Closed plugins ignore controls (separate slug-based list, not file paths).
58 $(document).on('click', '.vigilante-ignore-closed-plugin', this.handleIgnoreClosedPlugin.bind(this));
59 $(document).on('click', '.vigilante-unignore-closed-plugin', this.handleUnignoreClosedPlugin.bind(this));
60 $(document).on('click', '.vigilante-clear-ignored-closed-plugins', this.handleClearIgnoredClosedPlugins.bind(this));
61
62 // Bulk selection inside file integrity tables
63 $(document).on('change', '.vigilante-fi-cb-all', this.handleFiSelectAll.bind(this));
64 $(document).on('change', '.vigilante-fi-cb', this.handleFiCheckboxChange.bind(this));
65 $(document).on('click', '.vigilante-bulk-ignore', this.handleBulkIgnore.bind(this));
66 $(document).on('click', '.vigilante-bulk-unignore', this.handleBulkUnignore.bind(this));
67
68 // Clear scan results
69 $(document).on('click', '.vigilante-clear-scan', this.handleClearScan.bind(this));
70
71 // Critical config file actions
72 $(document).on('click', '.vigilante-approve-critical-file', this.handleApproveCriticalFile.bind(this));
73 $(document).on('click', '.vigilante-toggle-critical-content', this.handleToggleCriticalContent.bind(this));
74
75 // Export/Import settings
76 $(document).on('click', '.vigilante-export-settings', this.handleExportSettings.bind(this));
77 $(document).on('click', '.vigilante-import-settings', this.handleImportSettings.bind(this));
78 $(document).on('change', '#vigilante-import-file', this.handleImportFile.bind(this));
79
80 // Reset settings
81 $(document).on('click', '.vigilante-reset-settings', this.handleResetSettings.bind(this));
82
83 // Test headers
84 $(document).on('click', '.vigilante-test-headers', this.handleTestHeaders.bind(this));
85
86 // Login URL notification
87 $(document).on('click', '#vigilante_notify_login_url', this.handleNotifyLoginUrl.bind(this));
88 $(document).on('input', '#vigilante_custom_login_url', this.handleLoginUrlToggle.bind(this));
89
90 // Activity log filters
91 $(document).on('change', '#vigilante-log-type-filter, #vigilante-log-severity-filter, #vigilante-log-method-filter', this.handleLogFilter.bind(this));
92 $(document).on('click', '#vigilante-log-refresh', this.handleLogFilter.bind(this));
93
94 // Activity log search (debounced, min 3 chars)
95 $(document).on('input', '#vigilante-log-search', this.handleLogSearch.bind(this));
96
97 // Activity log pagination
98 $(document).on('click', '#vigilante-log-pagination .vigilante-page-first', function() { Vigilante_Admin.goToLogPage('first'); });
99 $(document).on('click', '#vigilante-log-pagination .vigilante-page-prev', function() { Vigilante_Admin.goToLogPage('prev'); });
100 $(document).on('click', '#vigilante-log-pagination .vigilante-page-next', function() { Vigilante_Admin.goToLogPage('next'); });
101 $(document).on('click', '#vigilante-log-pagination .vigilante-page-last', function() { Vigilante_Admin.goToLogPage('last'); });
102
103 // File integrity client-side pagination
104 $(document).on('click', '.vigilante-fi-pagination .vigilante-page-first', function() { Vigilante_Admin.goToFilePage($(this).closest('.vigilante-paginated-section'), 'first'); });
105 $(document).on('click', '.vigilante-fi-pagination .vigilante-page-prev', function() { Vigilante_Admin.goToFilePage($(this).closest('.vigilante-paginated-section'), 'prev'); });
106 $(document).on('click', '.vigilante-fi-pagination .vigilante-page-next', function() { Vigilante_Admin.goToFilePage($(this).closest('.vigilante-paginated-section'), 'next'); });
107 $(document).on('click', '.vigilante-fi-pagination .vigilante-page-last', function() { Vigilante_Admin.goToFilePage($(this).closest('.vigilante-paginated-section'), 'last'); });
108
109 // Export logs
110 $(document).on('click', '.vigilante-export-logs', this.handleExportLogs.bind(this));
111
112 // Create backup
113 $(document).on('click', '.vigilante-create-backup', this.handleCreateBackup.bind(this));
114
115 // View log details modal
116 $(document).on('click', '.vigilante-view-log-details', this.handleViewLogDetails.bind(this));
117 $(document).on('click', '.vigilante-modal-close, .vigilante-modal', this.handleCloseModal.bind(this));
118 $(document).on('click', '.vigilante-add-to-list', this.handleAddToFirewallList.bind(this));
119 $(document).on('click', '.vigilante-modal-content', function(e) {
120 e.stopPropagation();
121 });
122
123 // Under Attack mode
124 $(document).on('click', '.vigilante-ua-activate', this.handleUnderAttackActivate.bind(this));
125 $(document).on('click', '.vigilante-ua-deactivate', this.handleUnderAttackDeactivate.bind(this));
126
127 // Database backup
128 $(document).on('click', '.vigilante-db-backup-toggle', this.handleDbBackupToggle.bind(this));
129 $(document).on('click', '.vigilante-db-backup-download', this.handleDbBackupDownload.bind(this));
130 $(document).on('change', '#vigilante-db-select-all', this.handleDbSelectAll.bind(this));
131 $(document).on('change', '.vigilante-db-table-check', this.handleDbTableCheck.bind(this));
132
133 // Database prefix
134 $(document).on('click', '.vigilante-db-regenerate-prefix', this.handleDbRegeneratePrefix.bind(this));
135 $(document).on('change', '#vigilante-prefix-backup-confirm', this.handleDbPrefixConfirmToggle.bind(this));
136 $(document).on('click', '.vigilante-db-change-prefix', this.handleDbChangePrefix.bind(this));
137
138 // Settings search
139 $(document).on('input', '#vigilante-settings-search', this.handleSettingsSearch.bind(this));
140 $(document).on('focus', '#vigilante-settings-search', this.handleSettingsSearch.bind(this));
141 $(document).on('keydown', '#vigilante-settings-search', this.handleSettingsSearchKey.bind(this));
142 $(document).on('mouseenter', '.vigilante-search-item', this.handleSettingsSearchHover.bind(this));
143 $(document).on('click', function(e) {
144 if (!$(e.target).closest('.vigilante-search-wrapper').length) {
145 $('#vigilante-settings-search-results').attr('hidden', true);
146 }
147 });
148 // Global "/" shortcut to focus search
149 $(document).on('keydown', function(e) {
150 if (e.key !== '/') return;
151 var $t = $(e.target);
152 if ($t.is('input, textarea, select') || $t.is('[contenteditable="true"]')) return;
153 var $search = $('#vigilante-settings-search');
154 if (!$search.length) return;
155 e.preventDefault();
156 $search.focus().select();
157 });
158
159 // Start Under Attack countdown if active
160 this.initUnderAttackCountdown();
161 },
162
163 /**
164 * Initialize module toggles
165 *
166 * The only handler of a module switch. It saves and reloads, so the
167 * configuration level, the recommendations and the Security Check are
168 * drawn again by the server, which is the one place where they are
169 * worked out. There used to be a second handler on the same event: it
170 * saved the same change again and rewrote both score circles with a
171 * figure of its own (modules on, out of eight) for the 0.8 seconds
172 * before the reload brought the real one back.
173 */
174 initModuleToggles: function() {
175 $(document).on('change', '.vigilante-module-item input[type="checkbox"]', function() {
176 var $item = $(this).closest('.vigilante-module-item');
177 var module = $(this).data('module');
178 var enabled = $(this).is(':checked');
179
180 $item.toggleClass('enabled', enabled).toggleClass('disabled', !enabled);
181
182 // Save module state
183 Vigilante_Admin.saveModuleState(module, enabled);
184 });
185 },
186
187 /**
188 * Put a module switch back where it was when its save did not go through
189 */
190 revertModuleToggle: function($item, module, enabled) {
191 $item.removeClass('vigilante-saving')
192 .toggleClass('enabled', !enabled)
193 .toggleClass('disabled', enabled);
194 $item.find('input[data-module="' + module + '"]').prop('checked', !enabled);
195 },
196
197 /**
198 * Save module state
199 */
200 saveModuleState: function(module, enabled) {
201 var $item = $('.vigilante-module-item input[data-module="' + module + '"]').closest('.vigilante-module-item');
202
203 // Show saving state
204 $item.addClass('vigilante-saving');
205
206 $.ajax({
207 url: vigilanteAdmin.ajaxUrl,
208 type: 'POST',
209 data: {
210 action: 'vigilante_save_settings',
211 nonce: vigilanteAdmin.nonce,
212 section: 'modules',
213 data: 'modules[' + module + ']=' + (enabled ? '1' : '0')
214 },
215 success: function(response) {
216 if (response.success) {
217 $item.removeClass('vigilante-saving').addClass('vigilante-saved');
218 // Show success then reload to update recommendations and all UI
219 Vigilante_Admin.showNotice('success', vigilanteAdmin.strings.saved);
220 setTimeout(function() {
221 location.reload();
222 }, 800);
223 } else {
224 Vigilante_Admin.revertModuleToggle($item, module, enabled);
225 Vigilante_Admin.showNotice('error', response.data || vigilanteAdmin.strings.error);
226 }
227 },
228 error: function() {
229 Vigilante_Admin.revertModuleToggle($item, module, enabled);
230 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.error);
231 }
232 });
233 },
234
235 /**
236 * Handle form submit
237 */
238 handleFormSubmit: function(e) {
239 e.preventDefault();
240
241 var $form = $(e.currentTarget);
242 var $btn = $form.find('.vigilante-save-btn');
243 var section = $form.data('section');
244
245 $btn.prop('disabled', true).text(vigilanteAdmin.strings.saving);
246 $form.addClass('vigilante-loading');
247
248 // Build form data including unchecked checkboxes
249 var formData = Vigilante_Admin.serializeFormWithCheckboxes($form);
250
251 $.ajax({
252 url: vigilanteAdmin.ajaxUrl,
253 type: 'POST',
254 data: {
255 action: 'vigilante_save_settings',
256 nonce: vigilanteAdmin.nonce,
257 section: section,
258 data: formData
259 },
260 success: function(response) {
261 if (response.success) {
262 var msg = response.data && response.data.message ? response.data.message : (response.data || vigilanteAdmin.strings.saved);
263 Vigilante_Admin.showNotice('success', msg);
264 } else {
265 Vigilante_Admin.showNotice('error', response.data || vigilanteAdmin.strings.error);
266 }
267 },
268 error: function(xhr, status, error) {
269 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.error);
270 },
271 complete: function() {
272 $btn.prop('disabled', false).text($btn.data('original-text') || vigilanteAdmin.strings.saved);
273 $form.removeClass('vigilante-loading');
274
275 // Reset button text after delay
276 setTimeout(function() {
277 $btn.text($btn.data('original-text') || vigilanteAdmin.strings.saveSettings || 'Save Settings');
278 }, 2000);
279 }
280 });
281 },
282
283 /**
284 * Serialize form including unchecked checkboxes
285 *
286 * A single checkbox that is unticked sends nothing, so it is sent as 0
287 * or the saved value would stay on. A GROUP of checkboxes that share one
288 * name[] is a different thing: the ticked ones are the whole value, and
289 * a 0 is not one of them. Adding it wrote a literal "0" into the list of
290 * roles, which is what sites saving 3.0.0 have in enforced_roles, and
291 * because names were de-duplicated it only happened when the FIRST box
292 * of the group was unticked, which is why it looked random. The empty
293 * group now travels through the hidden field the group prints.
294 */
295 serializeFormWithCheckboxes: function($form) {
296 var data = $form.serializeArray();
297 var checkboxNames = [];
298
299 // Find all checkboxes and add unchecked ones with value 0
300 $form.find('input[type="checkbox"]').each(function() {
301 var name = $(this).attr('name');
302 if (name && name.slice(-2) === '[]') {
303 return;
304 }
305 if (name && checkboxNames.indexOf(name) === -1) {
306 checkboxNames.push(name);
307 if (!$(this).is(':checked')) {
308 data.push({ name: name, value: '0' });
309 }
310 }
311 });
312
313 // Convert to query string
314 return $.param(data);
315 },
316
317 /**
318 * Handle apply preset
319 */
320 handleApplyPreset: function(e) {
321 e.preventDefault();
322
323 var preset = $(e.currentTarget).data('preset');
324
325 if (!confirm(vigilanteAdmin.strings.confirm + ' ' + (vigilanteAdmin.strings.confirmApplyPreset || 'Apply the "%s" preset?').replace('%s', preset))) {
326 return;
327 }
328
329 $.ajax({
330 url: vigilanteAdmin.ajaxUrl,
331 type: 'POST',
332 data: {
333 action: 'vigilante_apply_preset',
334 nonce: vigilanteAdmin.nonce,
335 preset: preset
336 },
337 success: function(response) {
338 if (response.success) {
339 Vigilante_Admin.showNotice('success', response.data);
340 setTimeout(function() {
341 location.reload();
342 }, 1000);
343 } else {
344 Vigilante_Admin.showNotice('error', response.data);
345 }
346 },
347 error: function() {
348 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.error);
349 }
350 });
351 },
352
353 /**
354 * Handle Under Attack mode activation
355 */
356 handleUnderAttackActivate: function(e) {
357 e.preventDefault();
358
359 if (!confirm(vigilanteAdmin.strings.underAttackConfirmActivate)) {
360 return;
361 }
362
363 var $btn = $(e.currentTarget);
364 $btn.prop('disabled', true).text(vigilanteAdmin.strings.underAttackActivating);
365
366 $.ajax({
367 url: vigilanteAdmin.ajaxUrl,
368 type: 'POST',
369 data: {
370 action: 'vigilante_activate_under_attack',
371 nonce: vigilanteAdmin.nonce
372 },
373 success: function(response) {
374 if (response.success) {
375 Vigilante_Admin.showNotice('success', response.data.message);
376 setTimeout(function() {
377 location.reload();
378 }, 1000);
379 } else {
380 Vigilante_Admin.showNotice('error', response.data);
381 $btn.prop('disabled', false).text(vigilanteAdmin.strings.underAttackActivate || 'Activate for 4 hours');
382 }
383 },
384 error: function() {
385 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.error);
386 $btn.prop('disabled', false).text(vigilanteAdmin.strings.underAttackActivate || 'Activate for 4 hours');
387 }
388 });
389 },
390
391 /**
392 * Handle Under Attack mode deactivation
393 */
394 handleUnderAttackDeactivate: function(e) {
395 e.preventDefault();
396
397 if (!confirm(vigilanteAdmin.strings.underAttackConfirmDeactivate)) {
398 return;
399 }
400
401 var $btn = $(e.currentTarget);
402 $btn.prop('disabled', true).text(vigilanteAdmin.strings.underAttackDeactivating);
403
404 $.ajax({
405 url: vigilanteAdmin.ajaxUrl,
406 type: 'POST',
407 data: {
408 action: 'vigilante_deactivate_under_attack',
409 nonce: vigilanteAdmin.nonce
410 },
411 success: function(response) {
412 if (response.success) {
413 Vigilante_Admin.showNotice('success', response.data);
414 setTimeout(function() {
415 location.reload();
416 }, 1000);
417 } else {
418 Vigilante_Admin.showNotice('error', response.data);
419 $btn.prop('disabled', false).text(vigilanteAdmin.strings.deactivate || 'Deactivate');
420 }
421 },
422 error: function() {
423 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.error);
424 $btn.prop('disabled', false).text(vigilanteAdmin.strings.deactivate || 'Deactivate');
425 }
426 });
427 },
428
429 /**
430 * Initialize Under Attack countdown timer
431 */
432 initUnderAttackCountdown: function() {
433 var $countdown = $('.vigilante-ua-countdown');
434 if (!$countdown.length) {
435 return;
436 }
437
438 var expiresAt = parseInt($countdown.data('expires'), 10);
439 if (!expiresAt) {
440 return;
441 }
442
443 var $timeEl = $countdown.find('.vigilante-ua-time');
444
445 var updateCountdown = function() {
446 var now = Math.floor(Date.now() / 1000);
447 var remaining = expiresAt - now;
448
449 if (remaining <= 0) {
450 location.reload();
451 return;
452 }
453
454 var hours = Math.floor(remaining / 3600);
455 var mins = Math.floor((remaining % 3600) / 60);
456
457 var text = vigilanteAdmin.strings.underAttackRemaining || '%1$dh %2$dm remaining';
458 text = text.replace('%1$d', hours).replace('%2$d', mins);
459 $timeEl.text(text);
460 };
461
462 // Run immediately, then update every minute
463 updateCountdown();
464 setInterval(updateCountdown, 60000);
465 },
466
467 /**
468 * Handle reset section to defaults
469 */
470 handleResetSection: function(e) {
471 e.preventDefault();
472
473 var $btn = $(e.currentTarget);
474 var $form = $btn.closest('form');
475 var section = $form.data('section');
476
477 if (!section) {
478 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.couldNotDetermineSection || 'Could not determine section.');
479 return;
480 }
481
482 if (!confirm(vigilanteAdmin.strings.confirmResetSection || 'Reset this section to default values? This cannot be undone.')) {
483 return;
484 }
485
486 var originalText = $btn.data('original-text') || $btn.text();
487 $btn.prop('disabled', true).html(vigilanteAdmin.strings.loading + ' <span class="vigilante-spinner"></span>');
488
489 $.ajax({
490 url: vigilanteAdmin.ajaxUrl,
491 type: 'POST',
492 data: {
493 action: 'vigilante_reset_section',
494 nonce: vigilanteAdmin.nonce,
495 section: section
496 },
497 success: function(response) {
498 if (response.success) {
499 Vigilante_Admin.showNotice('success', response.data.message || vigilanteAdmin.strings.sectionResetDefaults || 'Section reset to defaults.');
500 setTimeout(function() {
501 location.reload();
502 }, 1000);
503 } else {
504 Vigilante_Admin.showNotice('error', response.data);
505 }
506 },
507 error: function() {
508 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.error);
509 },
510 complete: function() {
511 $btn.prop('disabled', false).text(originalText);
512 }
513 });
514 },
515
516 /**
517 * Handle clear single lockout
518 */
519 handleClearLockout: function(e) {
520 e.preventDefault();
521
522 var $btn = $(e.currentTarget);
523 var ip = $btn.data('ip');
524
525 $.ajax({
526 url: vigilanteAdmin.ajaxUrl,
527 type: 'POST',
528 data: {
529 action: 'vigilante_clear_lockouts',
530 nonce: vigilanteAdmin.nonce,
531 ip: ip
532 },
533 success: function(response) {
534 if (response.success) {
535 var $container = $btn.closest('.vigilante-paginated-section');
536 $btn.closest('tr').fadeOut(function() {
537 $(this).remove();
538 Vigilante_Admin.refreshFilePagination($container);
539 });
540 } else {
541 Vigilante_Admin.showNotice('error', response.data);
542 }
543 }
544 });
545 },
546
547 /**
548 * Handle clear all lockouts
549 */
550 handleClearAllLockouts: function(e) {
551 e.preventDefault();
552
553 if (!confirm(vigilanteAdmin.strings.confirm)) {
554 return;
555 }
556
557 $.ajax({
558 url: vigilanteAdmin.ajaxUrl,
559 type: 'POST',
560 data: {
561 action: 'vigilante_clear_lockouts',
562 nonce: vigilanteAdmin.nonce
563 },
564 success: function(response) {
565 if (response.success) {
566 location.reload();
567 } else {
568 Vigilante_Admin.showNotice('error', response.data);
569 }
570 }
571 });
572 },
573
574 /**
575 * Handle clear logs
576 */
577 handleClearLogs: function(e) {
578 e.preventDefault();
579
580 if (!confirm(vigilanteAdmin.strings.confirm + ' ' + (vigilanteAdmin.strings.confirmClearLogs || 'This will delete all activity logs.'))) {
581 return;
582 }
583
584 $.ajax({
585 url: vigilanteAdmin.ajaxUrl,
586 type: 'POST',
587 data: {
588 action: 'vigilante_clear_logs',
589 nonce: vigilanteAdmin.nonce
590 },
591 success: function(response) {
592 if (response.success) {
593 location.reload();
594 } else {
595 Vigilante_Admin.showNotice('error', response.data);
596 }
597 }
598 });
599 },
600
601 /**
602 * Handle run scan
603 */
604 handleRunScan: function(e) {
605 e.preventDefault();
606 e.stopPropagation();
607
608 var $btn = $(e.currentTarget);
609 var $results = $('#vigilante-scan-results');
610 var $lastResults = $('#vigilante-last-scan-results').closest('.vigilante-settings-section');
611
612 // Hide previous results section
613 $lastResults.hide();
614 $results.empty().hide();
615
616 $btn.prop('disabled', true).html(vigilanteAdmin.strings.scanning + ' <span class="vigilante-spinner"></span>');
617
618 $.ajax({
619 url: vigilanteAdmin.ajaxUrl,
620 type: 'POST',
621 timeout: 120000,
622 data: {
623 action: 'vigilante_run_scan',
624 nonce: vigilanteAdmin.nonce
625 },
626 success: function(response) {
627 if (response.success) {
628 // Reload the page so the server-side render shows ALL findings
629 // in their canonical sections (Suspicious, Extra, Critical Config,
630 // Closed + Removed Plugins, Modified). The legacy in-place JS
631 // render only knows the file-level categories and would leave
632 // the Closed + Removed subsection hidden after a scan.
633 Vigilante_Admin.showNotice('success', vigilanteAdmin.strings.scanComplete);
634 setTimeout(function() { location.reload(); }, 600);
635 } else {
636 Vigilante_Admin.showNotice('error', response.data || vigilanteAdmin.strings.scanFailed || 'Scan failed');
637 $btn.prop('disabled', false).text(vigilanteAdmin.strings.runScanNow || 'Run Scan Now');
638 }
639 },
640 error: function(xhr, status, error) {
641 Vigilante_Admin.showNotice('error', (vigilanteAdmin.strings.scanError || 'Scan error: %s').replace('%s', error));
642 $btn.prop('disabled', false).text(vigilanteAdmin.strings.runScanNow || 'Run Scan Now');
643 }
644 });
645 },
646
647 /**
648 * Handle clear scan results
649 */
650 handleClearScan: function(e) {
651 e.preventDefault();
652 e.stopPropagation();
653
654 if (!confirm(vigilanteAdmin.strings.confirmClearScan || 'Are you sure you want to clear all scan results?')) {
655 return;
656 }
657
658 var $btn = $(e.currentTarget);
659 var originalText = $btn.text();
660 $btn.prop('disabled', true).text(vigilanteAdmin.strings.clearing || 'Clearing...');
661
662 $.ajax({
663 url: vigilanteAdmin.ajaxUrl,
664 type: 'POST',
665 data: {
666 action: 'vigilante_clear_scan',
667 nonce: vigilanteAdmin.nonce
668 },
669 success: function(response) {
670 if (response.success) {
671 // Hide all scan results
672 $('#vigilante-scan-results').empty().hide();
673 $('#vigilante-last-scan-results').closest('.vigilante-settings-section').fadeOut();
674 Vigilante_Admin.showNotice('success', vigilanteAdmin.strings.scanResultsCleared || 'Scan results cleared. Page will reload...');
675 // Reload page to reflect changes
676 setTimeout(function() {
677 location.reload();
678 }, 1000);
679 } else {
680 Vigilante_Admin.showNotice('error', response.data || vigilanteAdmin.strings.failedClearResults || 'Failed to clear results');
681 $btn.prop('disabled', false).text(originalText);
682 }
683 },
684 error: function(xhr, status, error) {
685 Vigilante_Admin.showNotice('error', (vigilanteAdmin.strings.ajaxError || 'AJAX Error: %s').replace('%s', error));
686 $btn.prop('disabled', false).text(originalText);
687 },
688 complete: function() {
689 // Button text restored in success/error handlers
690 }
691 });
692 },
693
694 /**
695 * Handle toggle critical file content viewer
696 */
697 handleToggleCriticalContent: function(e) {
698 e.preventDefault();
699 var $btn = $(e.currentTarget);
700 var targetId = $btn.data('target');
701 var $content = $('#' + targetId);
702
703 // Use toggle instead of slideUp/slideDown because target can be a TR
704 // (animations on table rows are inconsistent across browsers)
705 if ($content.is(':visible')) {
706 $content.hide();
707 $btn.text($btn.data('label-show'));
708 } else {
709 $content.show();
710 $btn.text($btn.data('label-hide'));
711 }
712 },
713
714 /**
715 * Handle approve critical file button click
716 */
717 handleApproveCriticalFile: function(e) {
718 e.preventDefault();
719 var $btn = $(e.currentTarget);
720 var file = $btn.data('file');
721 var strings = vigilanteAdmin.strings;
722 var originalText = $btn.text();
723
724 // The POST carries an opaque key, never the file name: WAFs like
725 // ModSecurity (OWASP CRS 930130) block any request argument
726 // containing the literal "wp-config.php", which made Approve fail
727 // with a generic AJAX error on hardened hostings. The PHP handler
728 // maps the key back against its own whitelist.
729 var fileKey = { 'wp-config.php': 'cfg', '.htaccess': 'hta' }[ file ] || '';
730
731 $btn.prop('disabled', true).text(strings.approving || 'Approving...');
732
733 $.ajax({
734 url: vigilanteAdmin.ajaxUrl,
735 type: 'POST',
736 data: {
737 action: 'vigilante_approve_critical_file',
738 nonce: vigilanteAdmin.nonce,
739 file_key: fileKey
740 },
741 success: function(response) {
742 if (response.success) {
743 // Remove the approved file row and its diff row from the table.
744 // The diff row carries an id derived from the file name (same as
745 // sanitize_html_class() on PHP side), so we target it directly
746 // instead of relying on adjacency in the DOM.
747 var $row = $btn.closest('tr');
748 var fileId = String(file).replace(/[^a-z0-9_-]/gi, '-');
749 $('#vigilante-critical-content-' + fileId).remove();
750 $row.fadeOut(300, function() {
751 $(this).remove();
752 // If no more critical config rows, remove the entire section
753 if ($('.vigilante-critical-config-files tbody tr').length === 0) {
754 $('.vigilante-critical-config-files').fadeOut(300, function() { $(this).remove(); });
755 }
756 });
757 Vigilante_Admin.showNotice('success', strings.criticalApproved || response.data.message);
758 } else {
759 Vigilante_Admin.showNotice('error', response.data || 'Failed to approve.');
760 $btn.prop('disabled', false).text(originalText);
761 }
762 },
763 error: function(xhr, status, error) {
764 Vigilante_Admin.showNotice('error', (strings.ajaxError || 'AJAX Error: %s').replace('%s', error));
765 $btn.prop('disabled', false).text(originalText);
766 }
767 });
768 },
769
770 /**
771 * Handle ignore file button click
772 */
773 handleIgnoreFile: function(e) {
774 e.preventDefault();
775 var $btn = $(e.currentTarget);
776 var file = $btn.data('file');
777 var strings = vigilanteAdmin.strings;
778
779 $btn.prop('disabled', true).text(strings.ignoring || 'Ignoring...');
780
781 $.ajax({
782 url: vigilanteAdmin.ajaxUrl,
783 type: 'POST',
784 data: {
785 action: 'vigilante_ignore_file',
786 nonce: vigilanteAdmin.nonce,
787 file: file
788 },
789 success: function(response) {
790 if (response.success) {
791 var $container = $btn.closest('.vigilante-paginated-section');
792 // Remove the row from the table
793 $btn.closest('tr').fadeOut(300, function() {
794 $(this).remove();
795 // Refresh pagination after row removal
796 Vigilante_Admin.refreshFilePagination($container);
797 });
798 Vigilante_Admin.showNotice('success', strings.fileIgnored || 'File added to ignored list.');
799 } else {
800 Vigilante_Admin.showNotice('error', response.data || 'Failed to ignore file');
801 $btn.prop('disabled', false).text(strings.ignore || 'Ignore');
802 }
803 },
804 error: function() {
805 $btn.prop('disabled', false).text(strings.ignore || 'Ignore');
806 }
807 });
808 },
809
810 /**
811 * Handle stop ignoring file
812 */
813 handleUnignoreFile: function(e) {
814 e.preventDefault();
815 var $btn = $(e.currentTarget);
816 var file = $btn.data('file');
817 var strings = vigilanteAdmin.strings;
818
819 $.ajax({
820 url: vigilanteAdmin.ajaxUrl,
821 type: 'POST',
822 data: {
823 action: 'vigilante_unignore_file',
824 nonce: vigilanteAdmin.nonce,
825 file: file
826 },
827 success: function(response) {
828 if (response.success) {
829 var $container = $btn.closest('.vigilante-paginated-section');
830 $btn.closest('tr').fadeOut(300, function() {
831 $(this).remove();
832 Vigilante_Admin.refreshFilePagination($container);
833 });
834 Vigilante_Admin.showNotice('success', strings.fileUnignored || 'File removed from ignored list.');
835 } else {
836 Vigilante_Admin.showNotice('error', response.data || 'Failed');
837 }
838 }
839 });
840 },
841
842 /**
843 * Handle clear all ignored files
844 */
845 handleClearIgnored: function(e) {
846 e.preventDefault();
847 var strings = vigilanteAdmin.strings;
848
849 if (!confirm(strings.confirmClearIgnored || 'Remove all files from the ignored list?')) {
850 return;
851 }
852
853 $.ajax({
854 url: vigilanteAdmin.ajaxUrl,
855 type: 'POST',
856 data: {
857 action: 'vigilante_clear_ignored',
858 nonce: vigilanteAdmin.nonce
859 },
860 success: function(response) {
861 if (response.success) {
862 Vigilante_Admin.showNotice('success', strings.ignoredCleared || 'Ignored files list cleared. Page will reload...');
863 setTimeout(function() {
864 location.reload();
865 }, 1000);
866 }
867 }
868 });
869 },
870
871 /**
872 * Ignore a closed/removed plugin slug. Reloads to refresh the section.
873 */
874 handleIgnoreClosedPlugin: function(e) {
875 e.preventDefault();
876 var $btn = $(e.currentTarget);
877 var slug = $btn.data('slug');
878 var strings = vigilanteAdmin.strings || {};
879
880 $btn.prop('disabled', true).text(strings.ignoring || 'Ignoring…');
881
882 $.ajax({
883 url: vigilanteAdmin.ajaxUrl,
884 type: 'POST',
885 data: {
886 action: 'vigilante_ignore_closed_plugin',
887 nonce: vigilanteAdmin.nonce,
888 slug: slug
889 },
890 success: function(response) {
891 if (response && response.success) {
892 Vigilante_Admin.showNotice('success', (response.data) || 'Plugin ignored.');
893 setTimeout(function() { location.reload(); }, 600);
894 } else {
895 Vigilante_Admin.showNotice('error', (response && response.data) || 'Failed.');
896 $btn.prop('disabled', false).text(strings.ignore || 'Ignore');
897 }
898 },
899 error: function(xhr, status, error) {
900 Vigilante_Admin.showNotice('error', (strings.ajaxError || 'AJAX Error: %s').replace('%s', error));
901 $btn.prop('disabled', false).text(strings.ignore || 'Ignore');
902 }
903 });
904 },
905
906 /**
907 * Stop ignoring a closed/removed plugin slug.
908 */
909 handleUnignoreClosedPlugin: function(e) {
910 e.preventDefault();
911 var $btn = $(e.currentTarget);
912 var slug = $btn.data('slug');
913 var strings = vigilanteAdmin.strings || {};
914
915 $btn.prop('disabled', true).text(strings.unignoring || 'Restoring…');
916
917 $.ajax({
918 url: vigilanteAdmin.ajaxUrl,
919 type: 'POST',
920 data: {
921 action: 'vigilante_unignore_closed_plugin',
922 nonce: vigilanteAdmin.nonce,
923 slug: slug
924 },
925 success: function(response) {
926 if (response && response.success) {
927 Vigilante_Admin.showNotice('success', (response.data) || 'Plugin restored to the active list.');
928 setTimeout(function() { location.reload(); }, 600);
929 } else {
930 Vigilante_Admin.showNotice('error', (response && response.data) || 'Failed.');
931 $btn.prop('disabled', false).text(strings.stopIgnoring || 'Stop ignoring');
932 }
933 },
934 error: function(xhr, status, error) {
935 Vigilante_Admin.showNotice('error', (strings.ajaxError || 'AJAX Error: %s').replace('%s', error));
936 $btn.prop('disabled', false).text(strings.stopIgnoring || 'Stop ignoring');
937 }
938 });
939 },
940
941 /**
942 * Clear the entire ignored closed plugins list.
943 */
944 handleClearIgnoredClosedPlugins: function(e) {
945 e.preventDefault();
946 var strings = vigilanteAdmin.strings || {};
947
948 if (!confirm(strings.confirmClearIgnoredClosedPlugins || 'Remove all plugins from the ignored closed plugins list? They will reappear in the main list and in email alerts.')) {
949 return;
950 }
951
952 $.ajax({
953 url: vigilanteAdmin.ajaxUrl,
954 type: 'POST',
955 data: {
956 action: 'vigilante_clear_ignored_closed_plugins',
957 nonce: vigilanteAdmin.nonce
958 },
959 success: function(response) {
960 if (response && response.success) {
961 Vigilante_Admin.showNotice('success', (response.data) || 'Ignored closed plugins list cleared.');
962 setTimeout(function() { location.reload(); }, 1000);
963 }
964 }
965 });
966 },
967
968 /**
969 * Toggle all checkboxes inside a file integrity table when the
970 * header "select all" checkbox changes. Only operates on visible
971 * rows so it plays nice with client-side pagination.
972 */
973 handleFiSelectAll: function(e) {
974 var $cb = $(e.currentTarget);
975 var $section = $cb.closest('.vigilante-paginated-section');
976 $section.find('table.vigilante-fi-paginated tbody tr:visible .vigilante-fi-cb').prop('checked', $cb.prop('checked'));
977 this.refreshBulkBar($section);
978 },
979
980 /**
981 * Refresh bulk bar state after individual checkbox change.
982 */
983 handleFiCheckboxChange: function(e) {
984 var $section = $(e.currentTarget).closest('.vigilante-paginated-section');
985 this.refreshBulkBar($section);
986
987 // Sync the header "select all" with the current visible state
988 var $allCb = $section.find('.vigilante-fi-cb-all');
989 var $visible = $section.find('table.vigilante-fi-paginated tbody tr:visible .vigilante-fi-cb');
990 var $checked = $visible.filter(':checked');
991 $allCb.prop('checked', $visible.length > 0 && $checked.length === $visible.length);
992 },
993
994 /**
995 * Update the count label and enable/disable the bulk action button.
996 */
997 refreshBulkBar: function($section) {
998 var strings = vigilanteAdmin.strings;
999 var mode = $section.data('bulk-mode'); // 'ignore' or 'unignore'
1000 var $checked = $section.find('.vigilante-fi-cb:checked');
1001 var count = $checked.length;
1002 var $btn = $section.find(mode === 'unignore' ? '.vigilante-bulk-unignore' : '.vigilante-bulk-ignore');
1003 var $count = $section.find('.vigilante-fi-bulk-count');
1004
1005 $btn.prop('disabled', count === 0);
1006
1007 if (count === 0) {
1008 $count.text('');
1009 } else {
1010 var template = (strings.bulkSelectedCount || '%d selected');
1011 $count.text(template.replace('%d', count));
1012 }
1013 },
1014
1015 /**
1016 * Collect the file paths checked inside a section.
1017 */
1018 collectSelectedFiles: function($section) {
1019 var files = [];
1020 $section.find('.vigilante-fi-cb:checked').each(function() {
1021 var v = $(this).val();
1022 if (v) {
1023 files.push(v);
1024 }
1025 });
1026 return files;
1027 },
1028
1029 /**
1030 * Bulk add to ignored list.
1031 */
1032 handleBulkIgnore: function(e) {
1033 e.preventDefault();
1034 var self = this;
1035 var strings = vigilanteAdmin.strings;
1036 var $btn = $(e.currentTarget);
1037 var $section = $btn.closest('.vigilante-paginated-section');
1038 var files = this.collectSelectedFiles($section);
1039
1040 if (files.length === 0) {
1041 Vigilante_Admin.showNotice('error', strings.bulkNoSelection || 'Select at least one file first.');
1042 return;
1043 }
1044
1045 if (!confirm(strings.bulkConfirmIgnore || 'Ignore the selected files?')) {
1046 return;
1047 }
1048
1049 var originalText = $btn.text();
1050 $btn.prop('disabled', true).text(strings.bulkProcessing || 'Processing...');
1051
1052 $.ajax({
1053 url: vigilanteAdmin.ajaxUrl,
1054 type: 'POST',
1055 data: {
1056 action: 'vigilante_bulk_ignore_files',
1057 nonce: vigilanteAdmin.nonce,
1058 files: files
1059 },
1060 success: function(response) {
1061 if (response.success) {
1062 // Remove processed rows from this section
1063 files.forEach(function(file) {
1064 $section.find('.vigilante-fi-cb').filter(function() {
1065 return $(this).val() === file;
1066 }).closest('tr').remove();
1067 });
1068 $section.find('.vigilante-fi-cb-all').prop('checked', false);
1069 Vigilante_Admin.refreshFilePagination($section);
1070 self.refreshBulkBar($section);
1071 Vigilante_Admin.showNotice('success', (response.data && response.data.message) || strings.fileIgnored);
1072 } else {
1073 Vigilante_Admin.showNotice('error', (response.data && response.data.message) || response.data || 'Failed to ignore files');
1074 $btn.prop('disabled', false).text(originalText);
1075 }
1076 },
1077 error: function() {
1078 $btn.prop('disabled', false).text(originalText);
1079 },
1080 // Restore the label on success too. refreshBulkBar() re-derives the
1081 // disabled state from the remaining selection but never rewrites the
1082 // button text, so without this the button stays on "Processing...".
1083 complete: function() {
1084 $btn.text(originalText);
1085 }
1086 });
1087 },
1088
1089 /**
1090 * Bulk remove from ignored list.
1091 */
1092 handleBulkUnignore: function(e) {
1093 e.preventDefault();
1094 var self = this;
1095 var strings = vigilanteAdmin.strings;
1096 var $btn = $(e.currentTarget);
1097 var $section = $btn.closest('.vigilante-paginated-section');
1098 var files = this.collectSelectedFiles($section);
1099
1100 if (files.length === 0) {
1101 Vigilante_Admin.showNotice('error', strings.bulkNoSelection || 'Select at least one file first.');
1102 return;
1103 }
1104
1105 if (!confirm(strings.bulkConfirmUnignore || 'Remove the selected files from ignored list?')) {
1106 return;
1107 }
1108
1109 var originalText = $btn.text();
1110 $btn.prop('disabled', true).text(strings.bulkProcessing || 'Processing...');
1111
1112 $.ajax({
1113 url: vigilanteAdmin.ajaxUrl,
1114 type: 'POST',
1115 data: {
1116 action: 'vigilante_bulk_unignore_files',
1117 nonce: vigilanteAdmin.nonce,
1118 files: files
1119 },
1120 success: function(response) {
1121 if (response.success) {
1122 files.forEach(function(file) {
1123 $section.find('.vigilante-fi-cb').filter(function() {
1124 return $(this).val() === file;
1125 }).closest('tr').remove();
1126 });
1127 $section.find('.vigilante-fi-cb-all').prop('checked', false);
1128 Vigilante_Admin.refreshFilePagination($section);
1129 self.refreshBulkBar($section);
1130 Vigilante_Admin.showNotice('success', (response.data && response.data.message) || strings.fileUnignored);
1131
1132 // If the ignored table is now empty, hide its section.
1133 if ($section.find('table.vigilante-fi-paginated tbody tr').length === 0) {
1134 $('#vigilante-section-fi-ignored').fadeOut(300, function() { $(this).remove(); });
1135 }
1136 } else {
1137 Vigilante_Admin.showNotice('error', (response.data && response.data.message) || response.data || 'Failed');
1138 $btn.prop('disabled', false).text(originalText);
1139 }
1140 },
1141 error: function() {
1142 $btn.prop('disabled', false).text(originalText);
1143 },
1144 // Restore the label on success too. refreshBulkBar() re-derives the
1145 // disabled state from the remaining selection but never rewrites the
1146 // button text, so without this the button stays on "Processing...".
1147 complete: function() {
1148 $btn.text(originalText);
1149 }
1150 });
1151 },
1152
1153 /**
1154 * Display scan results
1155 */
1156 displayScanResults: function(results, $container, ignoredCount) {
1157 // Ensure arrays exist
1158 results.modified = results.modified || [];
1159 results.suspicious = results.suspicious || [];
1160 results.extra = results.extra || [];
1161 results.missing = results.missing || [];
1162
1163 var strings = vigilanteAdmin.strings;
1164 var esc = Vigilante_Admin.escapeHtml;
1165
1166 var html = '<h2>' + strings.scanResults + '</h2>';
1167 html += '<div class="vigilante-scan-summary">';
1168 html += '<div class="vigilante-scan-stat vigilante-stat-ok">';
1169 html += '<span class="vigilante-stat-number">' + (results.ok || 0) + '</span>';
1170 html += '<span class="vigilante-stat-label">' + strings.ok + '</span>';
1171 html += '</div>';
1172 html += '<div class="vigilante-scan-stat vigilante-stat-modified">';
1173 html += '<span class="vigilante-stat-number">' + results.modified.length + '</span>';
1174 html += '<span class="vigilante-stat-label">' + strings.modified + '</span>';
1175 html += '</div>';
1176 html += '<div class="vigilante-scan-stat vigilante-stat-suspicious">';
1177 html += '<span class="vigilante-stat-number">' + results.suspicious.length + '</span>';
1178 html += '<span class="vigilante-stat-label">' + strings.suspicious + '</span>';
1179 html += '</div>';
1180 html += '<div class="vigilante-scan-stat vigilante-stat-extra">';
1181 html += '<span class="vigilante-stat-number">' + results.extra.length + '</span>';
1182 html += '<span class="vigilante-stat-label">' + (strings.extra || 'Extra') + '</span>';
1183 html += '</div>';
1184 if (ignoredCount > 0) {
1185 html += '<div class="vigilante-scan-stat vigilante-stat-ignored">';
1186 html += '<span class="vigilante-stat-number">' + ignoredCount + '</span>';
1187 html += '<span class="vigilante-stat-label">' + (strings.ignored || 'Ignored') + '</span>';
1188 html += '</div>';
1189 }
1190 html += '<div class="vigilante-scan-stat">';
1191 var computedTotal = (results.ok || 0) + results.modified.length + results.suspicious.length + results.extra.length + (ignoredCount || 0);
1192 html += '<span class="vigilante-stat-number">' + computedTotal + '</span>';
1193 html += '<span class="vigilante-stat-label">' + strings.totalScanned + '</span>';
1194 html += '</div>';
1195 html += '</div>';
1196
1197 var bulkBar = '<div class="vigilante-fi-bulk-bar">'
1198 + '<button type="button" class="button vigilante-bulk-ignore" disabled>' + (strings.bulkIgnoreSelected || 'Ignore selected') + '</button>'
1199 + '<span class="vigilante-fi-bulk-count" aria-live="polite"></span>'
1200 + '</div>';
1201 var selectAllCell = '<td class="manage-column column-cb check-column"><input type="checkbox" class="vigilante-fi-cb-all" aria-label="' + (strings.selectAll || 'Select all') + '"></td>';
1202
1203 // Suspicious files table
1204 if (results.suspicious.length > 0) {
1205 html += '<div class="vigilante-file-list vigilante-suspicious-files vigilante-paginated-section" data-bulk-mode="ignore">';
1206 html += '<h3 style="color: #d63638;">' + strings.suspiciousFiles + '</h3>';
1207 html += '<p class="description" style="color: #d63638;">' + strings.suspiciousWarning + '</p>';
1208 html += bulkBar;
1209 html += '<div class="vigilante-fi-pagination-wrap"></div>';
1210 html += '<table class="wp-list-table widefat fixed striped vigilante-fi-paginated">';
1211 html += '<thead><tr>' + selectAllCell + '<th>' + strings.file + '</th><th style="width: 250px;">' + strings.reason + '</th><th style="width: 120px;">' + strings.type + '</th><th style="width: 80px;">' + (strings.actions || 'Actions') + '</th></tr></thead>';
1212 html += '<tbody>';
1213 results.suspicious.forEach(function(file) {
1214 html += '<tr>';
1215 html += '<th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="' + esc(file.file) + '"></th>';
1216 html += '<td><code style="color: #d63638;">' + esc(file.file) + '</code></td>';
1217 html += '<td>' + esc(file.reason || strings.unknown) + '</td>';
1218 html += '<td>' + esc(file.type === 'vigilante_self' ? (strings.selfType || 'Vigilant (self)') : (file.type || strings.unknown)) + '</td>';
1219 html += '<td><button type="button" class="button button-small vigilante-ignore-file" data-file="' + esc(file.file) + '">' + (strings.ignore || 'Ignore') + '</button></td>';
1220 html += '</tr>';
1221 });
1222 html += '</tbody></table>';
1223 html += '</div>';
1224 }
1225
1226 // Extra files table
1227 if (results.extra.length > 0) {
1228 html += '<div class="vigilante-file-list vigilante-extra-files vigilante-paginated-section" data-bulk-mode="ignore">';
1229 html += '<h3 style="color: #b32d2e;">' + (strings.extraFiles || 'Extra Files') + '</h3>';
1230 html += '<p class="description">' + (strings.extraDescription || 'PHP files not in original distribution.') + '</p>';
1231 html += bulkBar;
1232 html += '<div class="vigilante-fi-pagination-wrap"></div>';
1233 html += '<table class="wp-list-table widefat fixed striped vigilante-fi-paginated">';
1234 html += '<thead><tr>' + selectAllCell + '<th>' + strings.file + '</th><th style="width: 250px;">' + strings.reason + '</th><th style="width: 120px;">' + strings.type + '</th><th style="width: 80px;">' + (strings.actions || 'Actions') + '</th></tr></thead>';
1235 html += '<tbody>';
1236 results.extra.forEach(function(file) {
1237 html += '<tr>';
1238 html += '<th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="' + esc(file.file) + '"></th>';
1239 html += '<td><code style="color: #b32d2e;">' + esc(file.file) + '</code></td>';
1240 html += '<td>' + esc(file.reason || strings.unknown) + '</td>';
1241 html += '<td>' + esc(file.type === 'vigilante_self' ? (strings.selfType || 'Vigilant (self)') : (file.type || strings.unknown)) + '</td>';
1242 html += '<td><button type="button" class="button button-small vigilante-ignore-file" data-file="' + esc(file.file) + '">' + (strings.ignore || 'Ignore') + '</button></td>';
1243 html += '</tr>';
1244 });
1245 html += '</tbody></table>';
1246 html += '</div>';
1247 }
1248
1249 // Split critical config from regular modified
1250 var criticalModified = [];
1251 var regularModified = [];
1252 results.modified.forEach(function(file) {
1253 if (file.type === 'critical_config') {
1254 criticalModified.push(file);
1255 } else {
1256 regularModified.push(file);
1257 }
1258 });
1259 // A missing file of Vigilant is critical and the generic missing
1260 // files have no table: list it with the modified files, or the
1261 // screen said "All clear" with a module deleted.
1262 results.missing.forEach(function(file) {
1263 if (file && file.type === 'vigilante_self') {
1264 regularModified.push(file);
1265 }
1266 });
1267
1268 // Critical config files section (same visual treatment as suspicious files)
1269 if (criticalModified.length > 0) {
1270 html += '<div class="vigilante-file-list vigilante-critical-config-files">';
1271 html += '<h3 style="color: #e36210;">' + (strings.criticalConfigTitle || 'Critical config files modified') + '</h3>';
1272 html += '<p class="description">' + (strings.criticalConfigDesc || '') + '</p>';
1273 html += '<table class="wp-list-table widefat fixed striped">';
1274 html += '<thead><tr><th>' + strings.file + '</th><th style="width: 200px;">' + (strings.changes || 'Changes') + '</th><th style="width: 220px;">' + (strings.actions || 'Actions') + '</th></tr></thead>';
1275 html += '<tbody>';
1276
1277 // Delega en el escapador del objeto, que codifica comillas. La
1278 // version anterior era $('<span>').text(s).html(), que no las toca:
1279 // aqui solo se usa en posicion de texto, pero es el patron del
1280 // CVE-2026-81754 esperando a que alguien lo mueva a un atributo.
1281 var escHtml = Vigilante_Admin.escapeHtml;
1282
1283 criticalModified.forEach(function(file) {
1284 var fileId = file.file.replace(/[^a-z0-9]/gi, '-');
1285 var baselineSize = file.baseline_size ? Number(file.baseline_size).toLocaleString() : '?';
1286 var currentSize = file.current_size ? Number(file.current_size).toLocaleString() : '?';
1287 var diff = file.diff || {};
1288 var added = diff.added || [];
1289 var removed = diff.removed || [];
1290 var diffUnavailable = !!diff.unavailable;
1291
1292 // Main row
1293 html += '<tr>';
1294 html += '<td><code style="color: #e36210;">' + esc(file.file) + '</code></td>';
1295 html += '<td>';
1296 if (!diffUnavailable) {
1297 html += '<span style="color: #007017;">+' + added.length + '</span> <span style="color: #b32d2e;">-' + removed.length + '</span> ' + (strings.diffLines || 'lines') + '<br>';
1298 }
1299 html += '<small style="color: #50575e;">' + baselineSize + ' &rarr; ' + currentSize + ' bytes</small>';
1300 html += '</td>';
1301 html += '<td>';
1302 html += '<button type="button" class="button button-small vigilante-toggle-critical-content" data-target="vigilante-critical-content-' + fileId + '" data-label-show="' + (strings.reviewChanges || 'Review changes') + '" data-label-hide="' + (strings.hideChanges || 'Hide changes') + '">' + (strings.reviewChanges || 'Review changes') + '</button> ';
1303
1304 // Same gate as the AJAX handler behind the button, and as the
1305 // first render in PHP. Since 2.11.3 approving these two files
1306 // takes a network administrator, so painting the button to
1307 // anybody else buys them a Permission denied and no reason why.
1308 //
1309 // Truthy test on purpose: wp_localize_script() casts every
1310 // scalar to a string, so the PHP boolean arrives here as "1"
1311 // or "" and never as true. Measured on the network, admin of
1312 // a subsite against network admin. A === true would gate
1313 // nobody and would look right.
1314 if (vigilanteAdmin.approvalLocked) {
1315 html += '<span class="description" style="display:block;margin-top:4px;">' + escHtml(strings.approvalLockedNotice || '') + '</span>';
1316 } else {
1317 html += '<button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="' + esc(file.file) + '">' + (strings.approve || 'Approve') + '</button>';
1318 }
1319 html += '</td>';
1320 html += '</tr>';
1321
1322 // Expandable diff row
1323 html += '<tr id="vigilante-critical-content-' + fileId + '" class="vigilante-critical-content-row" style="display:none;">';
1324 html += '<td colspan="3" style="padding: 0;">';
1325 html += '<div class="vigilante-critical-content" style="max-height: 400px; overflow: auto; background: #fff; padding: 10px; font-size: 12px; line-height: 1.5; font-family: Consolas, Monaco, monospace; border-top: 1px solid #c3c4c7;">';
1326 if (diff.network) {
1327 html += '<p style="color: #50575e; font-style: italic; margin: 0;">' + escHtml(strings.diffNetwork || '') + '</p>';
1328 } else if (diff.rescan) {
1329 html += '<p style="color: #50575e; font-style: italic; margin: 0;">' + escHtml(strings.diffRescan || '') + '</p>';
1330 } else if (diff.redaction) {
1331 html += '<p style="color: #50575e; font-style: italic; margin: 0;">' + escHtml(strings.diffRedaction || '') + '</p>';
1332 } else if (diffUnavailable) {
1333 html += '<p style="color: #50575e; font-style: italic; margin: 0;">' + (strings.diffUnavailable || 'Diff not available.') + '</p>';
1334 } else if (added.length === 0 && removed.length === 0) {
1335 html += '<p style="color: #50575e; font-style: italic; margin: 0;">' + (strings.diffEmpty || 'No line changes detected.') + '</p>';
1336 } else {
1337 removed.forEach(function(rline) {
1338 html += '<div style="background: #fbeaea; color: #b32d2e; padding: 1px 4px; white-space: pre-wrap; word-wrap: break-word;"><span style="display: inline-block; width: 50px; color: #999; user-select: none;">' + (rline.line || 0) + '</span>- ' + escHtml(rline.content || '') + '</div>';
1339 });
1340 added.forEach(function(aline) {
1341 html += '<div style="background: #e6f4e9; color: #007017; padding: 1px 4px; white-space: pre-wrap; word-wrap: break-word;"><span style="display: inline-block; width: 50px; color: #999; user-select: none;">' + (aline.line || 0) + '</span>+ ' + escHtml(aline.content || '') + '</div>';
1342 });
1343 }
1344 html += '</div>';
1345 html += '</td>';
1346 html += '</tr>';
1347 });
1348
1349 html += '</tbody></table>';
1350 html += '</div>';
1351 }
1352
1353 // Regular modified files table
1354 if (regularModified.length > 0) {
1355 html += '<div class="vigilante-file-list vigilante-paginated-section" data-bulk-mode="ignore">';
1356 html += '<h3>' + strings.modifiedFiles + '</h3>';
1357 html += '<p class="description">' + strings.modifiedDescription + '</p>';
1358 html += bulkBar;
1359 html += '<div class="vigilante-fi-pagination-wrap"></div>';
1360 html += '<table class="wp-list-table widefat fixed striped vigilante-fi-paginated">';
1361 html += '<thead><tr>' + selectAllCell + '<th>' + strings.file + '</th><th style="width: 100px;">' + strings.type + '</th><th style="width: 80px;">' + (strings.actions || 'Actions') + '</th></tr></thead>';
1362 html += '<tbody>';
1363 regularModified.forEach(function(file) {
1364 html += '<tr>';
1365 html += '<th scope="row" class="check-column"><input type="checkbox" class="vigilante-fi-cb" value="' + esc(file.file) + '"></th>';
1366 html += '<td><code>' + esc(file.file) + '</code></td>';
1367 html += '<td>' + esc(file.type === 'vigilante_self' ? (strings.selfType || 'Vigilant (self)') : (file.type || strings.unknown)) + '</td>';
1368 html += '<td><button type="button" class="button button-small vigilante-ignore-file" data-file="' + esc(file.file) + '">' + (strings.ignore || 'Ignore') + '</button></td>';
1369 html += '</tr>';
1370 });
1371 html += '</tbody></table>';
1372 html += '</div>';
1373 }
1374
1375 if (criticalModified.length === 0 && regularModified.length === 0 && results.suspicious.length === 0 && results.extra.length === 0) {
1376 html += '<div class="vigilante-all-clear"><span class="dashicons dashicons-yes-alt"></span> ' + strings.allClear + '</div>';
1377 }
1378
1379 $container.html(html).show();
1380
1381 // Initialize client-side pagination for file integrity tables
1382 this.initFileIntegrityPagination($container);
1383 },
1384
1385 /**
1386 * Initialize client-side pagination for file integrity tables
1387 * Works on both PHP-rendered (last results) and JS-rendered (live scan) tables
1388 *
1389 * @param {jQuery} $scope Optional scope to limit search (for live scan results)
1390 */
1391 initFileIntegrityPagination: function($scope) {
1392 var perPage = 20;
1393 var $context = $scope || $(document);
1394
1395 $context.find('table.vigilante-fi-paginated').each(function() {
1396 var $table = $(this);
1397 var $rows = $table.find('tbody tr');
1398 var total = $rows.length;
1399
1400 if (total === 0) {
1401 return;
1402 }
1403
1404 // Store state on the parent container
1405 var $container = $table.closest('.vigilante-paginated-section');
1406 $container.data('fi-page', 1);
1407 $container.data('fi-per-page', perPage);
1408 $container.data('fi-total', total);
1409
1410 // Hide rows beyond first page (only if more than perPage)
1411 if (total > perPage) {
1412 $rows.each(function(index) {
1413 $(this).toggle(index < perPage);
1414 });
1415 }
1416
1417 // Build pagination controls (always show count, buttons only when needed)
1418 var $wrap = $container.find('.vigilante-fi-pagination-wrap');
1419 var paginationHtml = Vigilante_Admin.buildFilePaginationHtml(1, perPage, total);
1420 $wrap.html(paginationHtml);
1421 });
1422 },
1423
1424 /**
1425 * Build pagination HTML for file integrity tables
1426 */
1427 buildFilePaginationHtml: function(page, perPage, total) {
1428 var totalPages = Math.ceil(total / perPage) || 1;
1429 var from = total > 0 ? ((page - 1) * perPage) + 1 : 0;
1430 var to = Math.min(page * perPage, total);
1431 var needsNav = total > perPage;
1432
1433 var text = total > 0
1434 ? (vigilanteAdmin.strings.paginationOf || '%1$d–%2$d of %3$d').replace('%1$d', from).replace('%2$d', to).replace('%3$d', total)
1435 : (vigilanteAdmin.strings.paginationEmpty || '0 items');
1436
1437 var prevDisabled = page <= 1 ? ' disabled' : '';
1438 var nextDisabled = page >= totalPages ? ' disabled' : '';
1439
1440 var html = '<span class="vigilante-pagination vigilante-fi-pagination">';
1441 if (needsNav) {
1442 html += '<button type="button" class="vigilante-page-first" title="First"' + prevDisabled + '>&laquo;</button>';
1443 html += '<button type="button" class="vigilante-page-prev" title="Previous"' + prevDisabled + '>&lsaquo;</button>';
1444 }
1445 html += '<span class="vigilante-page-info">' + text + '</span>';
1446 if (needsNav) {
1447 html += '<button type="button" class="vigilante-page-next" title="Next"' + nextDisabled + '>&rsaquo;</button>';
1448 html += '<button type="button" class="vigilante-page-last" title="Last"' + nextDisabled + '>&raquo;</button>';
1449 }
1450 html += '</span>';
1451
1452 return html;
1453 },
1454
1455 /**
1456 * Navigate file integrity table pages (client-side)
1457 */
1458 goToFilePage: function($container, direction) {
1459 var page = $container.data('fi-page') || 1;
1460 var perPage = $container.data('fi-per-page') || 20;
1461 var total = $container.data('fi-total') || 0;
1462 var totalPages = Math.ceil(total / perPage) || 1;
1463
1464 switch (direction) {
1465 case 'first': page = 1; break;
1466 case 'prev': page = Math.max(1, page - 1); break;
1467 case 'next': page = Math.min(totalPages, page + 1); break;
1468 case 'last': page = totalPages; break;
1469 }
1470
1471 $container.data('fi-page', page);
1472
1473 // Show/hide rows
1474 var start = (page - 1) * perPage;
1475 var end = start + perPage;
1476 $container.find('table.vigilante-fi-paginated tbody tr').each(function(index) {
1477 $(this).toggle(index >= start && index < end);
1478 });
1479
1480 // Update controls
1481 var $wrap = $container.find('.vigilante-fi-pagination-wrap');
1482 $wrap.html(this.buildFilePaginationHtml(page, perPage, total));
1483
1484 // Sync the "select all" header with what is now visible.
1485 // Selections in other pages remain checked and will be included
1486 // in any bulk action, but the header reflects only this page.
1487 var $allCb = $container.find('.vigilante-fi-cb-all');
1488 if ($allCb.length) {
1489 var $visible = $container.find('table.vigilante-fi-paginated tbody tr:visible .vigilante-fi-cb');
1490 var $checked = $visible.filter(':checked');
1491 $allCb.prop('checked', $visible.length > 0 && $checked.length === $visible.length);
1492 }
1493 },
1494
1495 /**
1496 * Refresh file integrity pagination after row removal (ignore)
1497 */
1498 refreshFilePagination: function($container) {
1499 var perPage = $container.data('fi-per-page') || 20;
1500 var $rows = $container.find('table.vigilante-fi-paginated tbody tr');
1501 var total = $rows.length;
1502
1503 $container.data('fi-total', total);
1504
1505 if (total === 0) {
1506 $container.find('.vigilante-fi-pagination-wrap').empty();
1507 return;
1508 }
1509
1510 if (total <= perPage) {
1511 // Show all rows, display count only
1512 $rows.show();
1513 $container.data('fi-page', 1);
1514 var $wrap = $container.find('.vigilante-fi-pagination-wrap');
1515 $wrap.html(this.buildFilePaginationHtml(1, perPage, total));
1516 return;
1517 }
1518
1519 // Ensure current page is still valid
1520 var page = $container.data('fi-page') || 1;
1521 var totalPages = Math.ceil(total / perPage);
1522 if (page > totalPages) {
1523 page = totalPages;
1524 }
1525 $container.data('fi-page', page);
1526
1527 // Show/hide rows for current page
1528 var start = (page - 1) * perPage;
1529 var end = start + perPage;
1530 $rows.each(function(index) {
1531 $(this).toggle(index >= start && index < end);
1532 });
1533
1534 var $wrap = $container.find('.vigilante-fi-pagination-wrap');
1535 $wrap.html(this.buildFilePaginationHtml(page, perPage, total));
1536 },
1537
1538 /**
1539 * Handle export settings
1540 */
1541 handleExportSettings: function(e) {
1542 e.preventDefault();
1543
1544 $.ajax({
1545 url: vigilanteAdmin.ajaxUrl,
1546 type: 'POST',
1547 data: {
1548 action: 'vigilante_export_settings',
1549 nonce: vigilanteAdmin.nonce
1550 },
1551 success: function(response) {
1552 if (response.success) {
1553 var blob = new Blob([response.data.content], { type: 'application/json' });
1554 var url = URL.createObjectURL(blob);
1555 var a = document.createElement('a');
1556 a.href = url;
1557 a.download = response.data.filename;
1558 document.body.appendChild(a);
1559 a.click();
1560 document.body.removeChild(a);
1561 URL.revokeObjectURL(url);
1562 } else {
1563 Vigilante_Admin.showNotice('error', response.data);
1564 }
1565 }
1566 });
1567 },
1568
1569 /**
1570 * Handle import settings click
1571 */
1572 handleImportSettings: function(e) {
1573 e.preventDefault();
1574 $('#vigilante-import-file').trigger('click');
1575 },
1576
1577 /**
1578 * Handle import file selection
1579 */
1580 handleImportFile: function(e) {
1581 var file = e.target.files[0];
1582 if (!file) return;
1583
1584 var reader = new FileReader();
1585 reader.onload = function(e) {
1586 if (!confirm(vigilanteAdmin.strings.confirm + ' ' + (vigilanteAdmin.strings.confirmOverwrite || 'This will overwrite your current settings.'))) {
1587 return;
1588 }
1589
1590 $.ajax({
1591 url: vigilanteAdmin.ajaxUrl,
1592 type: 'POST',
1593 data: {
1594 action: 'vigilante_import_settings',
1595 nonce: vigilanteAdmin.nonce,
1596 settings: e.target.result
1597 },
1598 success: function(response) {
1599 if (response.success) {
1600 Vigilante_Admin.showNotice('success', response.data);
1601 setTimeout(function() {
1602 location.reload();
1603 }, 1000);
1604 } else {
1605 Vigilante_Admin.showNotice('error', response.data || vigilanteAdmin.strings.importFailed);
1606 }
1607 },
1608 error: function() {
1609 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.importFailed);
1610 }
1611 });
1612 };
1613 reader.readAsText(file);
1614 },
1615
1616 /**
1617 * Handle reset settings
1618 */
1619 handleResetSettings: function(e) {
1620 e.preventDefault();
1621
1622 if (!confirm(vigilanteAdmin.strings.confirm + ' ' + (vigilanteAdmin.strings.confirmResetAll || 'This will reset ALL settings to defaults.'))) {
1623 return;
1624 }
1625
1626 $.ajax({
1627 url: vigilanteAdmin.ajaxUrl,
1628 type: 'POST',
1629 data: {
1630 action: 'vigilante_apply_preset',
1631 nonce: vigilanteAdmin.nonce,
1632 preset: 'reset'
1633 },
1634 success: function(response) {
1635 if (response.success) {
1636 Vigilante_Admin.showNotice('success', vigilanteAdmin.strings.settingsResetDefaults || 'Settings reset to defaults.');
1637 setTimeout(function() {
1638 location.reload();
1639 }, 1000);
1640 } else {
1641 Vigilante_Admin.showNotice('error', response.data);
1642 }
1643 }
1644 });
1645 },
1646
1647 /**
1648 * Handle test headers
1649 */
1650 handleTestHeaders: function(e) {
1651 e.preventDefault();
1652
1653 var $btn = $(e.currentTarget);
1654 var $results = $('#vigilante-headers-result');
1655
1656 $btn.prop('disabled', true).html(vigilanteAdmin.strings.testing + ' <span class="vigilante-spinner"></span>');
1657
1658 $.ajax({
1659 url: vigilanteAdmin.ajaxUrl,
1660 type: 'POST',
1661 data: {
1662 action: 'vigilante_test_headers',
1663 nonce: vigilanteAdmin.nonce
1664 },
1665 success: function(response) {
1666 if (response.success) {
1667 Vigilante_Admin.displayHeaderResults(response.data, $results);
1668 } else {
1669 Vigilante_Admin.showNotice('error', response.data);
1670 }
1671 },
1672 complete: function() {
1673 $btn.prop('disabled', false).text(vigilanteAdmin.strings.testHeaders);
1674 }
1675 });
1676 },
1677
1678 /**
1679 * Handle login URL notification button
1680 */
1681 handleNotifyLoginUrl: function(e) {
1682 e.preventDefault();
1683
1684 var $btn = $(e.currentTarget);
1685 var $status = $btn.siblings('.vigilante-login-url-notify-status');
1686
1687 $btn.prop('disabled', true).text(vigilanteAdmin.strings.sending || 'Sending...');
1688 $status.removeClass('success error').text('');
1689
1690 $.ajax({
1691 url: vigilanteAdmin.ajaxUrl,
1692 type: 'POST',
1693 data: {
1694 action: 'vigilante_notify_login_url',
1695 nonce: vigilanteAdmin.nonce
1696 },
1697 success: function(response) {
1698 if (response.success) {
1699 var msg = response.data.sent + ' ' + (vigilanteAdmin.strings.notificationsSent || 'notifications sent');
1700 if (response.data.failed > 0) {
1701 msg += ', ' + response.data.failed + ' ' + (vigilanteAdmin.strings.failed || 'failed');
1702 }
1703 $status.addClass('success').text(msg);
1704 } else {
1705 $status.addClass('error').text(response.data || 'Error');
1706 }
1707 },
1708 error: function() {
1709 $status.addClass('error').text('Error');
1710 },
1711 complete: function() {
1712 $btn.prop('disabled', false).text(vigilanteAdmin.strings.sendNotification || 'Send notification now');
1713 }
1714 });
1715 },
1716
1717 /**
1718 * Toggle login URL notification section based on URL input
1719 */
1720 handleLoginUrlToggle: function() {
1721 var hasUrl = $('#vigilante_custom_login_url').val().trim().length > 0;
1722 $('.vigilante-login-url-notify-wrapper').toggleClass('vigilante-login-url-notify-disabled', !hasUrl);
1723 $('.vigilante-login-url-preview').toggle(hasUrl);
1724 },
1725
1726 /**
1727 * Display header test results
1728 */
1729 displayHeaderResults: function(results, $container) {
1730 // Every text is escaped: the lists carry the value of headers as
1731 // the site answered them (Server, the CSP, X-Frame-Options), and
1732 // that is whatever the server, a CDN or another plugin sends.
1733 var esc = Vigilante_Admin.escapeHtml;
1734 var strings = vigilanteAdmin.strings;
1735 var grade = String(results.grade || '');
1736 var gradeClass = 'vigilante-grade-' + esc(grade.toLowerCase());
1737 var html = '<div class="vigilante-security-score" style="text-align: center; padding: 20px;">';
1738 html += '<div class="vigilante-score-circle ' + gradeClass + '">';
1739 html += '<span class="vigilante-grade">' + esc(grade) + '</span>';
1740 html += '</div>';
1741 html += '<p style="text-align: center;"><strong>' + esc(strings.score) + ':</strong> ' + Math.round(results.score) + '%</p>';
1742 html += '<p class="description" style="text-align: center;">' + esc(strings.headersMeasured) + '</p>';
1743 html += '</div>';
1744
1745 // The lists are passed, failing and notes. The server also sends
1746 // headers, missing and warnings, empty: those are the names this
1747 // function read until 3.0.4, when it printed them as they came.
1748 if (results.passed && results.passed.length > 0) {
1749 html += '<p><strong>' + esc(strings.enabledHeaders) + ':</strong></p><ul>';
1750 results.passed.forEach(function(header) {
1751 html += '<li class="enabled">&#10003; ' + esc(header) + '</li>';
1752 });
1753 html += '</ul>';
1754 }
1755
1756 if (results.failing && results.failing.length > 0) {
1757 html += '<p><strong>' + esc(strings.missingHeaders) + ':</strong></p><ul>';
1758 results.failing.forEach(function(header) {
1759 html += '<li class="missing">&#10007; ' + esc(header) + '</li>';
1760 });
1761 html += '</ul>';
1762 }
1763
1764 if (results.notes && results.notes.length > 0) {
1765 html += '<p><strong>' + esc(strings.warnings) + ':</strong></p><ul>';
1766 results.notes.forEach(function(warning) {
1767 html += '<li class="warning">&#9888; ' + esc(warning) + '</li>';
1768 });
1769 html += '</ul>';
1770 }
1771
1772 $container.html(html).addClass('visible');
1773 },
1774
1775 /**
1776 * Handle activity log filter
1777 */
1778 handleLogFilter: function() {
1779 this.logPage = 1;
1780 this.loadActivityLogs();
1781 },
1782
1783 /**
1784 * Handle activity log search with debounce (400ms, min 3 chars)
1785 */
1786 handleLogSearch: function() {
1787 var self = this;
1788 clearTimeout(this.logSearchTimer);
1789 this.logSearchTimer = setTimeout(function() {
1790 var term = $('#vigilante-log-search').val().trim();
1791 // Only search with 3+ chars, or clear search when empty
1792 if (term.length >= 3 || term.length === 0) {
1793 self.logPage = 1;
1794 self.loadActivityLogs();
1795 }
1796 }, 400);
1797 },
1798
1799 /**
1800 * Load activity logs via AJAX (with filters and pagination)
1801 */
1802 loadActivityLogs: function() {
1803 var type = $('#vigilante-log-type-filter').val();
1804 var severity = $('#vigilante-log-severity-filter').val();
1805 var method = $('#vigilante-log-method-filter').val();
1806 var search = $('#vigilante-log-search').val();
1807 var $refreshBtn = $('#vigilante-log-refresh');
1808
1809 // Visual feedback
1810 $refreshBtn.prop('disabled', true).addClass('updating-message');
1811
1812 var data = {
1813 action: 'vigilante_get_logs',
1814 nonce: vigilanteAdmin.nonce,
1815 per_page: this.logPerPage,
1816 page: this.logPage,
1817 type: type,
1818 severity: severity
1819 };
1820
1821 if (method) {
1822 data.request_method = method;
1823 }
1824
1825 if (search && search.trim().length >= 3) {
1826 data.search = search.trim();
1827 }
1828
1829 $.ajax({
1830 url: vigilanteAdmin.ajaxUrl,
1831 type: 'POST',
1832 data: data,
1833 success: function(response) {
1834 if (response.success) {
1835 Vigilante_Admin.renderLogTable(response.data.logs);
1836 Vigilante_Admin.updateLogPagination(response.data.total);
1837 } else {
1838 Vigilante_Admin.showNotice('error', response.data || vigilanteAdmin.strings.error);
1839 }
1840 },
1841 error: function() {
1842 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.error || 'Request failed');
1843 },
1844 complete: function() {
1845 $refreshBtn.prop('disabled', false).removeClass('updating-message');
1846 }
1847 });
1848 },
1849
1850 /**
1851 * Navigate activity log pages
1852 */
1853 goToLogPage: function(direction) {
1854 var $pagination = $('#vigilante-log-pagination');
1855 var total = parseInt($pagination.attr('data-total'), 10) || 0;
1856 var totalPages = Math.ceil(total / this.logPerPage) || 1;
1857
1858 switch (direction) {
1859 case 'first': this.logPage = 1; break;
1860 case 'prev': this.logPage = Math.max(1, this.logPage - 1); break;
1861 case 'next': this.logPage = Math.min(totalPages, this.logPage + 1); break;
1862 case 'last': this.logPage = totalPages; break;
1863 }
1864
1865 this.loadActivityLogs();
1866 },
1867
1868 /**
1869 * Update activity log pagination controls
1870 */
1871 updateLogPagination: function(total) {
1872 var $pagination = $('#vigilante-log-pagination');
1873 total = parseInt(total, 10) || 0;
1874 var totalPages = Math.ceil(total / this.logPerPage) || 1;
1875 var needsNav = total > this.logPerPage;
1876
1877 $pagination.attr('data-total', total);
1878 $pagination.attr('data-page', this.logPage);
1879
1880 var from = total > 0 ? ((this.logPage - 1) * this.logPerPage) + 1 : 0;
1881 var to = Math.min(this.logPage * this.logPerPage, total);
1882
1883 var text = total > 0
1884 ? (vigilanteAdmin.strings.paginationOf || '%1$d–%2$d of %3$d').replace('%1$d', from).replace('%2$d', to).replace('%3$d', total)
1885 : (vigilanteAdmin.strings.paginationEmpty || '0 items');
1886
1887 var prevDisabled = this.logPage <= 1 ? ' disabled' : '';
1888 var nextDisabled = this.logPage >= totalPages ? ' disabled' : '';
1889
1890 var html = '';
1891 if (needsNav) {
1892 html += '<button type="button" class="vigilante-page-first"' + prevDisabled + '>&laquo;</button>';
1893 html += '<button type="button" class="vigilante-page-prev"' + prevDisabled + '>&lsaquo;</button>';
1894 }
1895 html += '<span class="vigilante-page-info">' + text + '</span>';
1896 if (needsNav) {
1897 html += '<button type="button" class="vigilante-page-next"' + nextDisabled + '>&rsaquo;</button>';
1898 html += '<button type="button" class="vigilante-page-last"' + nextDisabled + '>&raquo;</button>';
1899 }
1900
1901 $pagination.html(html);
1902 },
1903
1904 /**
1905 * Render log table
1906 */
1907 renderLogTable: function(logs) {
1908 var $tbody = $('#vigilante-activity-log-table tbody');
1909 $tbody.empty();
1910
1911 if (logs.length === 0) {
1912 $tbody.append('<tr><td colspan="8">' + (vigilanteAdmin.strings.noLogEntries || 'No log entries found.') + '</td></tr>');
1913 return;
1914 }
1915
1916 var self = this;
1917 var typeLabels = (vigilanteAdmin.strings && vigilanteAdmin.strings.eventTypeLabels) || {};
1918 var severityLabels = (vigilanteAdmin.strings && vigilanteAdmin.strings.severityLabels) || {};
1919
1920 logs.forEach(function(log) {
1921 var details = {
1922 id: log.id,
1923 type: log.event_type || '',
1924 action: log.event_action || '',
1925 message: log.event_message || '',
1926 user: log.user_login || '',
1927 ip: log.ip_address || '',
1928 user_agent: log.user_agent || '',
1929 request_method: log.request_method || '',
1930 request_uri: log.request_uri || '',
1931 date: log.created_at || '',
1932 severity: log.severity || 'info',
1933 is_ip_whitelisted: !!log.is_ip_whitelisted,
1934 is_ip_blacklisted: !!log.is_ip_blacklisted,
1935 is_ua_whitelisted: !!log.is_ua_whitelisted,
1936 is_ua_blacklisted: !!log.is_ua_blacklisted,
1937 self: log.self_guidance || null
1938 };
1939
1940 var displayType = typeLabels[log.event_type] || log.event_type;
1941 var displaySeverity = severityLabels[log.severity] || log.severity;
1942
1943 var methodHtml = '-';
1944 if (log.request_method) {
1945 methodHtml = '<span class="vigilante-method-label vigilante-method-' + self.escapeHtml(log.request_method.toLowerCase()) + '">' + self.escapeHtml(log.request_method) + '</span>';
1946 }
1947
1948 var row = '<tr class="vigilante-severity-' + self.escapeHtml(log.severity) + '">';
1949 row += '<td>' + self.escapeHtml(log.created_at) + '</td>';
1950 row += '<td>' + self.escapeHtml(displayType) + '</td>';
1951 row += '<td>' + methodHtml + '</td>';
1952 row += '<td><span class="vigilante-badge vigilante-badge-' + self.escapeHtml(log.severity) + '">' + self.escapeHtml(displaySeverity) + '</span></td>';
1953 row += '<td>' + self.escapeHtml(log.event_message) + '</td>';
1954 row += '<td>' + self.escapeHtml(log.user_login || '-') + '</td>';
1955 row += '<td><code>' + self.escapeHtml(log.ip_address) + '</code></td>';
1956 row += '<td><button type="button" class="button button-small vigilante-view-log-details" data-details=\'' + self.escapeHtml(JSON.stringify(details)) + '\'>' + (vigilanteAdmin.strings.view || 'View') + '</button></td>';
1957 row += '</tr>';
1958 $tbody.append(row);
1959 });
1960 },
1961
1962 /**
1963 * Handle export logs
1964 */
1965 handleExportLogs: function(e) {
1966 e.preventDefault();
1967
1968 var $btn = $(e.currentTarget);
1969 var type = $('#vigilante-log-type-filter').val();
1970 var severity = $('#vigilante-log-severity-filter').val();
1971 var method = $('#vigilante-log-method-filter').val();
1972 var search = $('#vigilante-log-search').val();
1973
1974 var data = {
1975 action: 'vigilante_get_logs',
1976 nonce: vigilanteAdmin.nonce,
1977 per_page: 9999
1978 };
1979
1980 // Only add filters if they have values
1981 if (type && type !== '') {
1982 data.type = type;
1983 }
1984 if (severity && severity !== '') {
1985 data.severity = severity;
1986 }
1987 if (method && method !== '') {
1988 data.request_method = method;
1989 }
1990 if (search && search.trim().length >= 3) {
1991 data.search = search.trim();
1992 }
1993
1994 $btn.prop('disabled', true).text(vigilanteAdmin.strings.exporting || 'Exporting...');
1995
1996 $.ajax({
1997 url: vigilanteAdmin.ajaxUrl,
1998 type: 'POST',
1999 data: data,
2000 success: function(response) {
2001 if (response.success && response.data.logs && response.data.logs.length > 0) {
2002 Vigilante_Admin.downloadCSV(response.data.logs);
2003 Vigilante_Admin.showNotice('success', (vigilanteAdmin.strings.logsExported || 'Logs exported (%d entries)').replace('%d', response.data.logs.length));
2004 } else {
2005 Vigilante_Admin.showNotice('warning', vigilanteAdmin.strings.noLogsToExport || 'No logs to export');
2006 }
2007 },
2008 error: function() {
2009 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.exportFailed || 'Export failed');
2010 },
2011 complete: function() {
2012 $btn.prop('disabled', false).text(vigilanteAdmin.strings.exportLogs || 'Export Logs');
2013 }
2014 });
2015 },
2016
2017 /**
2018 * Download logs as CSV
2019 *
2020 * Every cell goes through csvCell(): quotes are doubled in all columns,
2021 * not only the message, and a value starting with = + - @ or a control
2022 * character gets an apostrophe in front so a spreadsheet shows it
2023 * instead of running it as a formula (S12). Action, address and
2024 * User-Agent were added for B38: the columns that help diagnose an
2025 * entry were exactly the ones the export left out.
2026 */
2027 downloadCSV: function(logs) {
2028 var csvCell = function(value) {
2029 var s = (value === null || value === undefined) ? '' : String(value);
2030 if (/^[=+\-@\t\r]/.test(s)) {
2031 s = "'" + s;
2032 }
2033 return '"' + s.replace(/"/g, '""') + '"';
2034 };
2035 var csv = 'Date,Type,Action,Method,Severity,Message,User,IP,Address,User Agent\n';
2036
2037 logs.forEach(function(log) {
2038 csv += [
2039 csvCell(log.created_at),
2040 csvCell(log.event_type),
2041 csvCell(log.event_action),
2042 csvCell(log.request_method),
2043 csvCell(log.severity),
2044 csvCell(log.event_message),
2045 csvCell(log.user_login),
2046 csvCell(log.ip_address),
2047 csvCell(log.request_uri),
2048 csvCell(log.user_agent)
2049 ].join(',') + '\n';
2050 });
2051
2052 var blob = new Blob([csv], { type: 'text/csv' });
2053 var url = URL.createObjectURL(blob);
2054 var a = document.createElement('a');
2055 a.href = url;
2056 a.download = 'activity-log-' + new Date().toISOString().slice(0, 10) + '.csv';
2057 document.body.appendChild(a);
2058 a.click();
2059 document.body.removeChild(a);
2060 URL.revokeObjectURL(url);
2061 },
2062
2063 /**
2064 * Handle create backup
2065 */
2066 handleCreateBackup: function(e) {
2067 e.preventDefault();
2068
2069 var $btn = $(e.currentTarget);
2070 $btn.prop('disabled', true).html(vigilanteAdmin.strings.loading + ' <span class="vigilante-spinner"></span>');
2071
2072 // Direct file download via a hidden form (same pattern as the
2073 // database backup): the server streams a ZIP with wp-config.php and
2074 // .htaccess as an attachment.
2075 var $form = $('<form>', { method: 'POST', action: vigilanteAdmin.ajaxUrl });
2076 $form.append($('<input>', { type: 'hidden', name: 'action', value: 'vigilante_download_files_backup' }));
2077 $form.append($('<input>', { type: 'hidden', name: 'nonce', value: vigilanteAdmin.nonce }));
2078 $('body').append($form);
2079 $form.submit();
2080 $form.remove();
2081
2082 // Re-enable the button after a short delay (download runs in the background).
2083 setTimeout(function() {
2084 $btn.prop('disabled', false).text(vigilanteAdmin.strings.createBackupNow || 'Create Backup Now');
2085 }, 2000);
2086 },
2087
2088 // =================================================================
2089 // DATABASE BACKUP HANDLERS
2090 // =================================================================
2091
2092 /**
2093 * Toggle database backup panel and load tables
2094 */
2095 handleDbBackupToggle: function(e) {
2096 e.preventDefault();
2097
2098 var $panel = $('.vigilante-db-backup-panel');
2099 var $btn = $(e.currentTarget);
2100
2101 if ($panel.is(':visible')) {
2102 $panel.slideUp(200);
2103 return;
2104 }
2105
2106 $panel.slideDown(200);
2107
2108 // Load tables if not already loaded
2109 if ($panel.find('.vigilante-db-tables-content').is(':hidden')) {
2110 this.loadDbTables();
2111 }
2112 },
2113
2114 /**
2115 * Handle settings search input
2116 */
2117 searchActiveIndex: -1,
2118
2119 handleSettingsSearch: function(e) {
2120 var query = ($('#vigilante-settings-search').val() || '').trim().toLowerCase();
2121 var $results = $('#vigilante-settings-search-results');
2122 var index = (vigilanteAdmin.searchIndex || []);
2123
2124 if (query.length < 2) {
2125 $results.attr('hidden', true).empty();
2126 this.searchActiveIndex = -1;
2127 return;
2128 }
2129
2130 // Score every entry and sort before cutting. It used to stop at the
2131 // first 30 matches in index order, so a broad query ("password"
2132 // already returns 19) could hide the very row the user was after
2133 // behind whatever happened to be declared first.
2134 var scored = [];
2135 for (var i = 0; i < index.length; i++) {
2136 var entry = index[i];
2137 var label = (entry.label || '').toLowerCase();
2138 var labelEn = (entry.label_en || '').toLowerCase();
2139 var section = (entry.section || '').toLowerCase();
2140 var tabLabel = (entry.tab_label || '').toLowerCase();
2141 var keywords = (entry.keywords || '').toLowerCase();
2142 var score = 0;
2143
2144 if (label === query || labelEn === query) {
2145 score = 100;
2146 } else if (label.indexOf(query) === 0 || labelEn.indexOf(query) === 0) {
2147 score = 80;
2148 } else if (label.indexOf(query) !== -1 || labelEn.indexOf(query) !== -1) {
2149 score = 60;
2150 } else if (section.indexOf(query) !== -1) {
2151 score = 40;
2152 } else if (tabLabel.indexOf(query) !== -1) {
2153 score = 35;
2154 } else if (keywords.indexOf(query) !== -1) {
2155 score = 30;
2156 }
2157
2158 if (score > 0) {
2159 scored.push({ entry: entry, score: score, order: i });
2160 }
2161 }
2162
2163 scored.sort(function(a, b) {
2164 return (b.score - a.score) || (a.order - b.order);
2165 });
2166
2167 var matches = [];
2168 for (var t = 0; t < scored.length && matches.length < 30; t++) {
2169 matches.push(scored[t].entry);
2170 }
2171 var hiddenCount = scored.length - matches.length;
2172
2173 if (!matches.length) {
2174 $results.html('<div class="vigilante-search-empty">' + (vigilanteAdmin.strings.searchNoResults || 'No results') + '</div>').attr('hidden', false);
2175 this.searchActiveIndex = -1;
2176 return;
2177 }
2178
2179 // Group by tab
2180 var grouped = {};
2181 var tabOrder = [];
2182 for (var j = 0; j < matches.length; j++) {
2183 var m = matches[j];
2184 if (!grouped[m.tab]) {
2185 grouped[m.tab] = { label: m.tab_label, items: [] };
2186 tabOrder.push(m.tab);
2187 }
2188 grouped[m.tab].items.push(m);
2189 }
2190
2191 var html = '';
2192 var flatIdx = 0;
2193 for (var k = 0; k < tabOrder.length; k++) {
2194 var tab = tabOrder[k];
2195 var group = grouped[tab];
2196 html += '<div class="vigilante-search-group">';
2197 html += '<div class="vigilante-search-group-title">' + this.escapeHtml(group.label) + '</div>';
2198 for (var n = 0; n < group.items.length; n++) {
2199 var it = group.items[n];
2200 var base = (vigilanteAdmin.adminUrl || 'admin.php?page=vigilante');
2201 var href = base + '&tab=' + encodeURIComponent(it.tab) + '#' + encodeURIComponent(it.anchor);
2202 var activeClass = flatIdx === 0 ? ' vigilante-search-item-active' : '';
2203 html += '<a class="vigilante-search-item' + activeClass + '" data-idx="' + flatIdx + '" role="option" href="' + href + '">';
2204 html += '<span class="vigilante-search-item-label">' + this.escapeHtml(it.label) + '</span>';
2205 html += '<span class="vigilante-search-item-section">' + this.escapeHtml(it.section) + '</span>';
2206 html += '</a>';
2207 flatIdx++;
2208 }
2209 html += '</div>';
2210 }
2211
2212 if (hiddenCount > 0) {
2213 var moreTpl = vigilanteAdmin.strings.searchMoreResults || '%d more results, refine the search';
2214 html += '<div class="vigilante-search-more">' + this.escapeHtml(moreTpl.replace('%d', hiddenCount)) + '</div>';
2215 }
2216
2217 $results.html(html).attr('hidden', false);
2218 this.searchActiveIndex = 0;
2219 },
2220
2221 /**
2222 * Handle keyboard navigation in search: arrows move highlight, Enter navigates, Escape closes
2223 */
2224 handleSettingsSearchKey: function(e) {
2225 var $items = $('#vigilante-settings-search-results .vigilante-search-item');
2226
2227 if (e.key === 'Escape') {
2228 $('#vigilante-settings-search-results').attr('hidden', true);
2229 $('#vigilante-settings-search').blur();
2230 return;
2231 }
2232
2233 if (!$items.length) return;
2234
2235 if (e.key === 'ArrowDown') {
2236 e.preventDefault();
2237 this.searchActiveIndex = Math.min(this.searchActiveIndex + 1, $items.length - 1);
2238 this.updateSearchActive();
2239 } else if (e.key === 'ArrowUp') {
2240 e.preventDefault();
2241 this.searchActiveIndex = Math.max(this.searchActiveIndex - 1, 0);
2242 this.updateSearchActive();
2243 } else if (e.key === 'Home') {
2244 e.preventDefault();
2245 this.searchActiveIndex = 0;
2246 this.updateSearchActive();
2247 } else if (e.key === 'End') {
2248 e.preventDefault();
2249 this.searchActiveIndex = $items.length - 1;
2250 this.updateSearchActive();
2251 } else if (e.key === 'Enter') {
2252 e.preventDefault();
2253 var idx = this.searchActiveIndex >= 0 ? this.searchActiveIndex : 0;
2254 var $target = $items.eq(idx);
2255 if ($target.length) {
2256 window.location.href = $target.attr('href');
2257 }
2258 }
2259 },
2260
2261 /**
2262 * Sync active highlight when user hovers a search result
2263 */
2264 handleSettingsSearchHover: function(e) {
2265 var idx = parseInt($(e.currentTarget).attr('data-idx'), 10);
2266 if (isNaN(idx)) return;
2267 this.searchActiveIndex = idx;
2268 this.updateSearchActive(false);
2269 },
2270
2271 /**
2272 * Apply the active class to the current index and scroll it into view
2273 */
2274 updateSearchActive: function(scroll) {
2275 var $items = $('#vigilante-settings-search-results .vigilante-search-item');
2276 $items.removeClass('vigilante-search-item-active');
2277 var $active = $items.eq(this.searchActiveIndex);
2278 if (!$active.length) return;
2279 $active.addClass('vigilante-search-item-active');
2280 if (scroll !== false && $active[0] && typeof $active[0].scrollIntoView === 'function') {
2281 $active[0].scrollIntoView({ block: 'nearest' });
2282 }
2283 },
2284
2285 /**
2286 * Format bytes as a human-readable size
2287 */
2288 formatBytes: function(bytes) {
2289 if (!bytes || bytes < 0) return '0 B';
2290 var units = ['B', 'KB', 'MB', 'GB', 'TB'];
2291 var i = 0;
2292 var n = bytes;
2293 while (n >= 1024 && i < units.length - 1) {
2294 n = n / 1024;
2295 i++;
2296 }
2297 return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + units[i];
2298 },
2299
2300 /**
2301 * Update DB tables info: count + total size
2302 */
2303 updateDbTablesInfo: function() {
2304 var $boxes = $('.vigilante-db-table-check');
2305 var total = $boxes.length;
2306 if (!total) {
2307 $('.vigilante-db-tables-info').text('');
2308 return;
2309 }
2310 var $checked = $boxes.filter(':checked');
2311 var checkedCount = $checked.length;
2312 var bytes = 0;
2313 $checked.each(function() {
2314 bytes += parseInt($(this).attr('data-bytes'), 10) || 0;
2315 });
2316 var sizeStr = Vigilante_Admin.formatBytes(bytes);
2317 var tpl;
2318 if (checkedCount === total) {
2319 tpl = vigilanteAdmin.strings.dbTablesTotal || '%1$d tables total (%2$s)';
2320 $('.vigilante-db-tables-info').text(tpl.replace('%1$d', total).replace('%2$s', sizeStr));
2321 } else {
2322 tpl = vigilanteAdmin.strings.dbTablesSelected || '%1$d tables selected (%2$s)';
2323 $('.vigilante-db-tables-info').text(tpl.replace('%1$d', checkedCount).replace('%2$s', sizeStr));
2324 }
2325 },
2326
2327 /**
2328 * Load database tables via AJAX
2329 */
2330 loadDbTables: function() {
2331 $.ajax({
2332 url: vigilanteAdmin.ajaxUrl,
2333 type: 'POST',
2334 data: {
2335 action: 'vigilante_get_db_tables',
2336 nonce: vigilanteAdmin.nonce
2337 },
2338 success: function(response) {
2339 if (!response.success) {
2340 Vigilante_Admin.showNotice('error', response.data);
2341 return;
2342 }
2343
2344 var data = response.data;
2345 var coreHtml = '';
2346 var otherHtml = '';
2347
2348 // Build core tables checkboxes
2349 if (data.core && data.core.length) {
2350 $.each(data.core, function(i, table) {
2351 coreHtml += '<label class="vigilante-db-table-label">';
2352 coreHtml += '<input type="checkbox" class="vigilante-db-table-check" value="' + table.name + '" data-bytes="' + (table.bytes || 0) + '" checked>';
2353 coreHtml += '<span class="vigilante-db-table-name" title="' + table.name + '">' + table.short + '</span>';
2354 coreHtml += '<span class="vigilante-db-table-meta">' + table.rows + ' rows &middot; ' + table.size + '</span>';
2355 coreHtml += '</label>';
2356 });
2357 }
2358
2359 // Build other tables checkboxes
2360 if (data.other && data.other.length) {
2361 $.each(data.other, function(i, table) {
2362 otherHtml += '<label class="vigilante-db-table-label">';
2363 otherHtml += '<input type="checkbox" class="vigilante-db-table-check" value="' + table.name + '" data-bytes="' + (table.bytes || 0) + '" checked>';
2364 otherHtml += '<span class="vigilante-db-table-name" title="' + table.name + '">' + table.short + '</span>';
2365 otherHtml += '<span class="vigilante-db-table-meta">' + table.rows + ' rows &middot; ' + table.size + '</span>';
2366 otherHtml += '</label>';
2367 });
2368 $('#vigilante-db-other-group').show();
2369 }
2370
2371 $('#vigilante-db-core-tables').html(coreHtml);
2372 $('#vigilante-db-other-tables').html(otherHtml);
2373
2374 Vigilante_Admin.updateDbTablesInfo();
2375
2376 $('.vigilante-db-tables-loading').hide();
2377 $('.vigilante-db-tables-content').show();
2378 }
2379 });
2380 },
2381
2382 /**
2383 * Select/deselect all tables
2384 */
2385 handleDbSelectAll: function(e) {
2386 var checked = $(e.currentTarget).prop('checked');
2387 $('.vigilante-db-table-check').prop('checked', checked);
2388 this.updateDbTablesInfo();
2389 },
2390
2391 /**
2392 * Update select-all state when individual checkbox changes
2393 */
2394 handleDbTableCheck: function() {
2395 var total = $('.vigilante-db-table-check').length;
2396 var checked = $('.vigilante-db-table-check:checked').length;
2397 $('#vigilante-db-select-all').prop('checked', total === checked);
2398 this.updateDbTablesInfo();
2399 },
2400
2401 /**
2402 * Download database backup
2403 */
2404 handleDbBackupDownload: function(e) {
2405 e.preventDefault();
2406
2407 var tables = [];
2408 $('.vigilante-db-table-check:checked').each(function() {
2409 tables.push($(this).val());
2410 });
2411
2412 if (tables.length === 0) {
2413 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.dbBackupNoTables);
2414 return;
2415 }
2416
2417 var $btn = $(e.currentTarget);
2418 $btn.prop('disabled', true).html(vigilanteAdmin.strings.dbBackupDownloading + ' <span class="vigilante-spinner"></span>');
2419
2420 // Submit via hidden form for direct file download
2421 var $form = $('<form>', {
2422 method: 'POST',
2423 action: vigilanteAdmin.ajaxUrl
2424 });
2425
2426 $form.append($('<input>', { type: 'hidden', name: 'action', value: 'vigilante_download_db_backup' }));
2427 $form.append($('<input>', { type: 'hidden', name: 'nonce', value: vigilanteAdmin.nonce }));
2428 $form.append($('<input>', { type: 'hidden', name: 'tables', value: tables.join(',') }));
2429
2430 $('body').append($form);
2431 $form.submit();
2432 $form.remove();
2433
2434 // Re-enable button after a delay (download starts in background)
2435 setTimeout(function() {
2436 $btn.prop('disabled', false).text($btn.data('original-text') || vigilanteAdmin.strings.downloadBackup || 'Download Backup (.zip)');
2437 Vigilante_Admin.showNotice('success', vigilanteAdmin.strings.dbBackupSuccess);
2438 }, 3000);
2439 },
2440
2441 // =================================================================
2442 // DATABASE PREFIX HANDLERS
2443 // =================================================================
2444
2445 /**
2446 * Regenerate random prefix
2447 */
2448 handleDbRegeneratePrefix: function(e) {
2449 e.preventDefault();
2450
2451 var $btn = $(e.currentTarget);
2452 $btn.prop('disabled', true);
2453
2454 $.ajax({
2455 url: vigilanteAdmin.ajaxUrl,
2456 type: 'POST',
2457 data: {
2458 action: 'vigilante_generate_prefix',
2459 nonce: vigilanteAdmin.nonce
2460 },
2461 success: function(response) {
2462 if (response.success) {
2463 $('#vigilante-new-prefix').text(response.data.prefix);
2464 } else {
2465 Vigilante_Admin.showNotice('error', response.data);
2466 }
2467 },
2468 complete: function() {
2469 $btn.prop('disabled', false);
2470 }
2471 });
2472 },
2473
2474 /**
2475 * Toggle the change prefix button based on checkbox
2476 */
2477 handleDbPrefixConfirmToggle: function(e) {
2478 var checked = $(e.currentTarget).prop('checked');
2479 $('.vigilante-db-change-prefix').prop('disabled', !checked);
2480 },
2481
2482 /**
2483 * Change database prefix
2484 */
2485 handleDbChangePrefix: function(e) {
2486 e.preventDefault();
2487
2488 // Verify checkbox is checked
2489 if (!$('#vigilante-prefix-backup-confirm').prop('checked')) {
2490 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.dbPrefixCheckbox);
2491 return;
2492 }
2493
2494 var newPrefix = $('#vigilante-new-prefix').text().trim();
2495
2496 if (!confirm(vigilanteAdmin.strings.dbPrefixConfirm)) {
2497 return;
2498 }
2499
2500 var $btn = $(e.currentTarget);
2501 $btn.prop('disabled', true).html(vigilanteAdmin.strings.dbPrefixChanging + ' <span class="vigilante-spinner"></span>');
2502
2503 $.ajax({
2504 url: vigilanteAdmin.ajaxUrl,
2505 type: 'POST',
2506 data: {
2507 action: 'vigilante_change_prefix',
2508 nonce: vigilanteAdmin.nonce,
2509 prefix: newPrefix
2510 },
2511 success: function(response) {
2512 if (response.success) {
2513 Vigilante_Admin.showNotice('success', vigilanteAdmin.strings.dbPrefixSuccess);
2514 // Reload page after prefix change (session will still work)
2515 setTimeout(function() {
2516 location.reload();
2517 }, 2000);
2518 } else {
2519 Vigilante_Admin.showNotice('error', response.data);
2520 $btn.prop('disabled', false).text($btn.data('original-text'));
2521 }
2522 },
2523 error: function() {
2524 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.error);
2525 $btn.prop('disabled', false).text($btn.data('original-text'));
2526 }
2527 });
2528 },
2529
2530 /**
2531 * Show notice
2532 */
2533 showNotice: function(type, message) {
2534 // Remove any existing notices first.
2535 $('.vigilante-notice').remove();
2536
2537 var $notice = $('<div class="vigilante-notice vigilante-notice-' + type + '">' + message + '</div>');
2538 $('.vigilante-main').prepend($notice);
2539
2540 // Scroll to top of the settings area so user sees the notice.
2541 $('html, body').animate({
2542 scrollTop: $('.vigilante-admin-wrap').offset().top - 50
2543 }, 300);
2544
2545 setTimeout(function() {
2546 $notice.fadeOut(function() {
2547 $(this).remove();
2548 });
2549 }, 5000);
2550 },
2551
2552 /**
2553 * Handle view log details
2554 */
2555 handleViewLogDetails: function(e) {
2556 e.preventDefault();
2557 e.stopPropagation();
2558
2559 var $btn = $(e.currentTarget);
2560 var detailsStr = $btn.attr('data-details');
2561
2562 if (!detailsStr) {
2563 return;
2564 }
2565
2566 var details;
2567 try {
2568 details = JSON.parse(detailsStr);
2569 } catch (err) {
2570 return;
2571 }
2572
2573 var html = '';
2574
2575 var s = vigilanteAdmin.strings;
2576 var esc = Vigilante_Admin.escapeHtml;
2577
2578 // -- Section: what happened (self-protection entries) --
2579 // Text only: every value below is printed as text, never as an
2580 // attribute, and the link is a fixed URL built with esc_url().
2581 if (details.self && details.self.title) {
2582 html += '<div class="vigilante-popup-section">';
2583 html += '<h4 class="vigilante-popup-section-title">' + esc(s.logWhatHappened || 'What happened') + '</h4>';
2584 html += '<p><strong>' + esc(details.self.title) + '</strong></p>';
2585 if (details.self.files && details.self.files.length) {
2586 html += '<ul class="vigilante-popup-files">';
2587 details.self.files.slice(0, 20).forEach(function(file) {
2588 html += '<li><code>' + esc(file) + '</code></li>';
2589 });
2590 if (details.self.files.length > 20) {
2591 html += '<li>' + esc('+' + (details.self.files.length - 20)) + '</li>';
2592 }
2593 html += '</ul>';
2594 }
2595 if (details.self.meaning) {
2596 html += '<p><strong>' + esc(s.logWhatItMeans || 'What it means') + ':</strong> ' + esc(details.self.meaning) + '</p>';
2597 }
2598 if (details.self.steps && details.self.steps.length) {
2599 html += '<p><strong>' + esc(s.logWhatToDo || 'What to do') + ':</strong></p><ol>';
2600 details.self.steps.forEach(function(step) {
2601 html += '<li>' + esc(step) + '</li>';
2602 });
2603 html += '</ol>';
2604 }
2605 if (vigilanteAdmin.selfBoxUrl) {
2606 html += '<p><a href="' + esc(vigilanteAdmin.selfBoxUrl) + '">' + esc(s.logSelfSeeDetails || 'Open File Integrity for the full detail') + '</a></p>';
2607 }
2608 html += '</div>';
2609 }
2610
2611 // -- Section: Request --
2612 html += '<div class="vigilante-popup-section">';
2613 html += '<h4 class="vigilante-popup-section-title">' + this.escapeHtml(s.logRequest || 'Request') + '</h4>';
2614 html += '<table class="vigilante-details-table">';
2615 html += '<tr><th>' + this.escapeHtml(s.logDate || 'Date') + '</th><td>' + this.escapeHtml(details.date) + '</td></tr>';
2616 if (details.request_method) {
2617 html += '<tr><th>' + this.escapeHtml(s.logMethod || 'Method') + '</th><td><span class="vigilante-method-label vigilante-method-' + this.escapeHtml(details.request_method.toLowerCase()) + '">' + this.escapeHtml(details.request_method) + '</span></td></tr>';
2618 }
2619 html += '<tr><th>' + this.escapeHtml(s.logType || 'Type') + '</th><td>' + this.escapeHtml(details.type) + '</td></tr>';
2620 html += '<tr><th>' + this.escapeHtml(s.logAction || 'Action') + '</th><td>' + this.escapeHtml(details.action || '-') + '</td></tr>';
2621 html += '<tr><th>' + this.escapeHtml(s.logSeverity || 'Severity') + '</th><td><span class="vigilante-badge vigilante-badge-' + this.escapeHtml(details.severity || 'info') + '">' + this.escapeHtml(details.severity || 'info') + '</span></td></tr>';
2622 html += '<tr><th>' + this.escapeHtml(s.logMessage || 'Message') + '</th><td>' + this.escapeHtml(details.message) + '</td></tr>';
2623 if (details.request_uri) {
2624 html += '<tr><th>' + this.escapeHtml(s.logRequestUri || 'Address') + '</th><td class="vigilante-ua-cell"><code>' + this.escapeHtml(details.request_uri) + '</code></td></tr>';
2625 }
2626 html += '</table>';
2627 html += '</div>';
2628
2629 // -- Section: Client --
2630 html += '<div class="vigilante-popup-section">';
2631 html += '<h4 class="vigilante-popup-section-title">' + this.escapeHtml(s.logClient || 'Client') + '</h4>';
2632 html += '<table class="vigilante-details-table">';
2633 html += '<tr><th>' + this.escapeHtml(s.logUser || 'User') + '</th><td>' + this.escapeHtml(details.user || '-') + '</td></tr>';
2634
2635 // IP with lookup link
2636 if (details.ip && details.ip !== '0.0.0.0') {
2637 var ipEscaped = this.escapeHtml(details.ip);
2638 html += '<tr><th>' + this.escapeHtml(s.logIpAddress || 'IP Address') + '</th><td>';
2639 html += '<code>' + ipEscaped + '</code> ';
2640 html += '<a href="https://abuseipdb.com/check/' + encodeURIComponent(details.ip) + '" target="_blank" rel="noopener noreferrer" class="vigilante-ip-lookup" title="Lookup IP">';
2641 html += '<span class="dashicons dashicons-search"></span></a>';
2642 html += '</td></tr>';
2643 } else {
2644 html += '<tr><th>' + this.escapeHtml(s.logIpAddress || 'IP Address') + '</th><td><code>' + this.escapeHtml(details.ip) + '</code></td></tr>';
2645 }
2646
2647 if (details.user_agent) {
2648 html += '<tr><th>' + this.escapeHtml(s.logUserAgent || 'User Agent') + '</th><td class="vigilante-ua-cell"><code>' + this.escapeHtml(details.user_agent) + '</code></td></tr>';
2649 }
2650 html += '</table>';
2651
2652 // Action buttons for IP and UA
2653 html += '<div class="vigilante-popup-actions">';
2654 if (details.ip && details.ip !== '0.0.0.0') {
2655 html += '<span class="vigilante-popup-actions-label">' + this.escapeHtml(s.logIpLabel || 'IP:') + '</span>';
2656 if (details.is_ip_whitelisted) {
2657 html += '<button type="button" class="button button-small vigilante-btn-added" disabled><span class="dashicons dashicons-yes-alt"></span> ' + this.escapeHtml(s.logInWhitelist || 'In whitelist') + '</button> ';
2658 } else {
2659 html += '<button type="button" class="button button-small vigilante-add-to-list" data-item-type="ip" data-list-type="whitelist" data-value="' + this.escapeHtml(details.ip) + '"><span class="dashicons dashicons-yes-alt"></span> ' + this.escapeHtml(s.logWhitelist || 'Whitelist') + '</button> ';
2660 }
2661 if (details.is_ip_blacklisted) {
2662 html += '<button type="button" class="button button-small vigilante-btn-added" disabled><span class="dashicons dashicons-dismiss"></span> ' + this.escapeHtml(s.logInBlacklist || 'In blacklist') + '</button> ';
2663 } else {
2664 html += '<button type="button" class="button button-small vigilante-add-to-list vigilante-btn-danger" data-item-type="ip" data-list-type="blacklist" data-value="' + this.escapeHtml(details.ip) + '"><span class="dashicons dashicons-dismiss"></span> ' + this.escapeHtml(s.logBlacklist || 'Blacklist') + '</button> ';
2665 }
2666 }
2667 if (details.user_agent) {
2668 html += '<span class="vigilante-popup-actions-label">' + this.escapeHtml(s.logUaLabel || 'UA:') + '</span>';
2669 if (details.is_ua_whitelisted) {
2670 html += '<button type="button" class="button button-small vigilante-btn-added" disabled><span class="dashicons dashicons-yes-alt"></span> ' + this.escapeHtml(s.logInWhitelist || 'In whitelist') + '</button> ';
2671 } else {
2672 html += '<button type="button" class="button button-small vigilante-add-to-list" data-item-type="ua" data-list-type="whitelist" data-value="' + this.escapeHtml(details.user_agent) + '"><span class="dashicons dashicons-yes-alt"></span> ' + this.escapeHtml(s.logWhitelist || 'Whitelist') + '</button> ';
2673 }
2674 if (details.is_ua_blacklisted) {
2675 html += '<button type="button" class="button button-small vigilante-btn-added" disabled><span class="dashicons dashicons-dismiss"></span> ' + this.escapeHtml(s.logInBlacklist || 'In blacklist') + '</button>';
2676 } else {
2677 html += '<button type="button" class="button button-small vigilante-add-to-list vigilante-btn-danger" data-item-type="ua" data-list-type="blacklist" data-value="' + this.escapeHtml(details.user_agent) + '"><span class="dashicons dashicons-dismiss"></span> ' + this.escapeHtml(s.logBlacklist || 'Blacklist') + '</button>';
2678 }
2679 }
2680 html += '</div>';
2681 html += '<div class="vigilante-popup-feedback" style="display:none;"></div>';
2682 html += '</div>';
2683
2684 // Event ID for reference
2685 html += '<div class="vigilante-popup-footer">';
2686 html += '<span class="vigilante-popup-id">ID: ' + this.escapeHtml(details.id) + '</span>';
2687 html += '</div>';
2688
2689 $('#vigilante-log-details-content').html(html);
2690 $('#vigilante-log-details-modal').fadeIn(200);
2691 },
2692
2693 /**
2694 * Handle adding IP or UA to firewall whitelist/blacklist
2695 */
2696 handleAddToFirewallList: function(e) {
2697 e.preventDefault();
2698 var $btn = $(e.currentTarget);
2699 var itemType = $btn.data('item-type');
2700 var listType = $btn.data('list-type');
2701 // attr(), not data(): jQuery runs JSON.parse on values that look like
2702 // JSON, so a User-Agent such as {"a":1} would arrive as an object.
2703 var value = $btn.attr('data-value');
2704
2705 if (!itemType || !listType || !value) {
2706 return;
2707 }
2708
2709 $btn.prop('disabled', true);
2710
2711 var self = this;
2712 $.ajax({
2713 url: vigilanteAdmin.ajaxUrl,
2714 type: 'POST',
2715 data: {
2716 action: 'vigilante_add_to_firewall_list',
2717 nonce: vigilanteAdmin.nonce,
2718 item_type: itemType,
2719 list_type: listType,
2720 value: value
2721 },
2722 success: function(response) {
2723 var $feedback = $('.vigilante-popup-feedback');
2724 if (response.success) {
2725 $btn.text(vigilanteAdmin.strings.logAdded || 'Added!').addClass('vigilante-btn-added');
2726 $feedback.html('<span class="vigilante-popup-msg vigilante-popup-msg-success">' + response.data + '</span>').slideDown(150);
2727 } else {
2728 $btn.prop('disabled', false);
2729 $feedback.html('<span class="vigilante-popup-msg vigilante-popup-msg-warning">' + (response.data || vigilanteAdmin.strings.logErrorAddingToList || 'Error adding to list') + '</span>').slideDown(150);
2730 }
2731 },
2732 error: function() {
2733 $btn.prop('disabled', false);
2734 $('.vigilante-popup-feedback').html('<span class="vigilante-popup-msg vigilante-popup-msg-error">' + (vigilanteAdmin.strings.logRequestFailed || 'Request failed') + '</span>').slideDown(150);
2735 }
2736 });
2737 },
2738
2739 /**
2740 * Handle unblock firewall IP
2741 */
2742 handleUnblockFirewallIp: function(e) {
2743 var $btn = $(e.currentTarget);
2744 var ip = $btn.data('ip');
2745
2746 if (!confirm(vigilanteAdmin.strings.confirmUnblockIp || 'Unblock this IP?')) {
2747 return;
2748 }
2749
2750 $btn.prop('disabled', true).text(vigilanteAdmin.strings.loading || 'Loading...');
2751
2752 $.ajax({
2753 url: vigilanteAdmin.ajaxUrl,
2754 type: 'POST',
2755 data: {
2756 action: 'vigilante_unblock_firewall_ip',
2757 nonce: vigilanteAdmin.nonce,
2758 ip: ip
2759 },
2760 success: function(response) {
2761 if (response.success) {
2762 $btn.closest('tr').fadeOut(300, function() {
2763 var $tbody = $(this).closest('tbody');
2764 $(this).remove();
2765 // Hide the whole section if no more blocked IPs
2766 if ($tbody.children('tr').length === 0) {
2767 $tbody.closest('.vigilante-lockout-section').fadeOut(300);
2768 }
2769 });
2770 } else {
2771 alert(response.data || 'Error');
2772 $btn.prop('disabled', false).text(vigilanteAdmin.strings.unblock || 'Unblock');
2773 }
2774 },
2775 error: function() {
2776 $btn.prop('disabled', false).text(vigilanteAdmin.strings.unblock || 'Unblock');
2777 }
2778 });
2779 },
2780
2781 /**
2782 * Handle close modal
2783 */
2784 handleCloseModal: function(e) {
2785 if ($(e.target).hasClass('vigilante-modal') || $(e.target).hasClass('vigilante-modal-close')) {
2786 $('.vigilante-modal').hide();
2787 }
2788 },
2789
2790 /**
2791 * Escape HTML
2792 */
2793 escapeHtml: function(text) {
2794 if (text === null || text === undefined) return '';
2795 if (typeof text !== 'string') {
2796 text = String(text);
2797 }
2798 // Quotes must be encoded too: this helper is also used in attribute
2799 // position, where an unescaped quote closes the attribute early.
2800 return text
2801 .replace(/&/g, '&amp;')
2802 .replace(/</g, '&lt;')
2803 .replace(/>/g, '&gt;')
2804 .replace(/"/g, '&quot;')
2805 .replace(/'/g, '&#039;');
2806 },
2807
2808 /**
2809 * Debounce function
2810 */
2811 debounce: function(func, wait) {
2812 var timeout;
2813 return function() {
2814 var context = this, args = arguments;
2815 clearTimeout(timeout);
2816 timeout = setTimeout(function() {
2817 func.apply(context, args);
2818 }, wait);
2819 };
2820 },
2821
2822 // =================================================================
2823 // Force Password Reset functionality
2824 // =================================================================
2825
2826 /**
2827 * Selected users for password reset
2828 */
2829 selectedPasswordResetUsers: [],
2830
2831 /**
2832 * Initialize password reset handlers
2833 */
2834 initPasswordReset: function() {
2835 var self = this;
2836
2837 // User search for password reset
2838 var searchTimeout;
2839 $(document).on('input', '#vigilante-password-reset-search', function() {
2840 var query = $(this).val();
2841 clearTimeout(searchTimeout);
2842
2843 if (query.length < 2) {
2844 $('#vigilante-password-reset-results').hide().empty();
2845 return;
2846 }
2847
2848 searchTimeout = setTimeout(function() {
2849 self.searchUsersForPasswordReset(query);
2850 }, 300);
2851 });
2852
2853 // Close search results when clicking outside
2854 $(document).on('click', function(e) {
2855 if (!$(e.target).closest('.vigilante-user-search-wrapper').length) {
2856 $('#vigilante-password-reset-results').hide();
2857 }
2858 });
2859
2860 // Reset selected users button
2861 $(document).on('click', '#vigilante-reset-selected-users', function(e) {
2862 e.preventDefault();
2863 self.forceResetSelectedUsers();
2864 });
2865
2866 // Reset all users button
2867 $(document).on('click', '#vigilante-reset-all-users', function(e) {
2868 e.preventDefault();
2869 self.forceResetAllUsers();
2870 });
2871
2872 // Reset by role - checkbox change
2873 $(document).on('change', '.vigilante-reset-role-checkbox', function() {
2874 self.updateRoleResetSummary();
2875 });
2876
2877 // Reset by role button
2878 $(document).on('click', '#vigilante-reset-by-role', function(e) {
2879 e.preventDefault();
2880 self.forceResetByRoles();
2881 });
2882 },
2883
2884 /**
2885 * Search users for password reset
2886 */
2887 searchUsersForPasswordReset: function(query) {
2888 var self = this;
2889 var $results = $('#vigilante-password-reset-results');
2890
2891 $.ajax({
2892 url: vigilanteAdmin.ajaxUrl,
2893 type: 'POST',
2894 data: {
2895 action: 'vigilante_search_users_password_reset',
2896 nonce: vigilanteAdmin.nonce,
2897 query: query
2898 },
2899 success: function(response) {
2900 if (response.success && response.data.length > 0) {
2901 var html = '<ul class="vigilante-user-search-list">';
2902 response.data.forEach(function(user) {
2903 var isSelected = self.selectedPasswordResetUsers.indexOf(user.ID) !== -1;
2904 html += '<li data-user-id="' + user.ID + '" class="' + (isSelected ? 'selected' : '') + '">';
2905 html += '<img src="' + self.escapeHtml(user.avatar) + '" alt="" class="vigilante-user-avatar">';
2906 html += '<div class="vigilante-user-info">';
2907 html += '<strong>' + self.escapeHtml(user.display_name) + '</strong>';
2908 html += '<br><small>' + self.escapeHtml(user.user_login) + ' - ' + self.escapeHtml(user.user_email) + '</small>';
2909 html += '<br><small class="vigilante-user-roles">' + self.escapeHtml(user.roles) + '</small>';
2910 html += '</div>';
2911 html += '</li>';
2912 });
2913 html += '</ul>';
2914 $results.html(html).show();
2915
2916 // Handle user selection
2917 $results.find('li').on('click', function() {
2918 var userId = parseInt($(this).data('user-id'));
2919 var userName = $(this).find('strong').text();
2920 var userEmail = $(this).find('small').first().text();
2921 self.togglePasswordResetUser(userId, userName, userEmail);
2922 $(this).toggleClass('selected');
2923 });
2924 } else {
2925 $results.html('<p class="vigilante-no-results">' + (vigilanteAdmin.strings.noUsersFoundSearch || 'No users found') + '</p>').show();
2926 }
2927 }
2928 });
2929 },
2930
2931 /**
2932 * Toggle user selection for password reset
2933 */
2934 togglePasswordResetUser: function(userId, userName, userEmail) {
2935 var index = this.selectedPasswordResetUsers.indexOf(userId);
2936 var $selectedContainer = $('#vigilante-password-reset-selected');
2937 var $list = $selectedContainer.find('.vigilante-selected-users-list');
2938 var $btn = $('#vigilante-reset-selected-users');
2939
2940 if (index === -1) {
2941 // Add user
2942 this.selectedPasswordResetUsers.push(userId);
2943 $list.append(
2944 '<li data-user-id="' + userId + '">' +
2945 '<span>' + this.escapeHtml(userName) + '</span>' +
2946 '<button type="button" class="vigilante-remove-user" title="Remove">&times;</button>' +
2947 '</li>'
2948 );
2949 } else {
2950 // Remove user
2951 this.selectedPasswordResetUsers.splice(index, 1);
2952 $list.find('li[data-user-id="' + userId + '"]').remove();
2953 }
2954
2955 // Update UI
2956 if (this.selectedPasswordResetUsers.length > 0) {
2957 $selectedContainer.show();
2958 $btn.prop('disabled', false);
2959 } else {
2960 $selectedContainer.hide();
2961 $btn.prop('disabled', true);
2962 }
2963
2964 // Handle remove button click
2965 $list.find('.vigilante-remove-user').off('click').on('click', function() {
2966 var uid = $(this).closest('li').data('user-id');
2967 var idx = Vigilante_Admin.selectedPasswordResetUsers.indexOf(uid);
2968 if (idx !== -1) {
2969 Vigilante_Admin.selectedPasswordResetUsers.splice(idx, 1);
2970 }
2971 $(this).closest('li').remove();
2972 $('#vigilante-password-reset-results').find('li[data-user-id="' + uid + '"]').removeClass('selected');
2973
2974 if (Vigilante_Admin.selectedPasswordResetUsers.length === 0) {
2975 $selectedContainer.hide();
2976 $btn.prop('disabled', true);
2977 }
2978 });
2979 },
2980
2981 /**
2982 * Force reset for selected users
2983 */
2984 forceResetSelectedUsers: function() {
2985 var self = this;
2986
2987 if (this.selectedPasswordResetUsers.length === 0) {
2988 return;
2989 }
2990
2991 var currentUserId = parseInt(vigilanteAdmin.currentUserId || 0);
2992 var resettingSelf = this.selectedPasswordResetUsers.indexOf(currentUserId) !== -1;
2993
2994 var confirmMsg = (vigilanteAdmin.strings.confirmForceReset || 'Force password reset for %d user(s)? A password reset email will be sent to each user.').replace('%d', this.selectedPasswordResetUsers.length);
2995 if (resettingSelf) {
2996 confirmMsg += '\n\n' + (vigilanteAdmin.strings.warningResettingSelf || 'WARNING: You are including yourself. Your session will end and you will need to set a new password.');
2997 }
2998
2999 if (!confirm(confirmMsg)) {
3000 return;
3001 }
3002
3003 var $btn = $('#vigilante-reset-selected-users');
3004 $btn.prop('disabled', true).text(vigilanteAdmin.strings.processing || 'Processing...');
3005
3006 $.ajax({
3007 url: vigilanteAdmin.ajaxUrl,
3008 type: 'POST',
3009 data: {
3010 action: 'vigilante_force_password_reset',
3011 nonce: vigilanteAdmin.nonce,
3012 user_ids: this.selectedPasswordResetUsers
3013 },
3014 success: function(response) {
3015 if (response.success) {
3016 self.showNotice('success', response.data.message);
3017
3018 // Clear selection
3019 self.selectedPasswordResetUsers = [];
3020 $('#vigilante-password-reset-selected').hide().find('.vigilante-selected-users-list').empty();
3021 $('#vigilante-password-reset-search').val('');
3022 $('#vigilante-password-reset-results').hide().empty();
3023
3024 // Reload if resetting self
3025 if (response.data.resetting_self) {
3026 setTimeout(function() {
3027 window.location.href = vigilanteAdmin.logoutUrl || '/wp-login.php';
3028 }, 2000);
3029 } else {
3030 setTimeout(function() {
3031 location.reload();
3032 }, 1500);
3033 }
3034 } else {
3035 self.showNotice('error', response.data);
3036 }
3037 },
3038 error: function() {
3039 self.showNotice('error', vigilanteAdmin.strings.anErrorOccurred || 'An error occurred');
3040 },
3041 complete: function() {
3042 $btn.prop('disabled', false).text(vigilanteAdmin.strings.forceResetSelected || 'Force Reset for Selected Users');
3043 }
3044 });
3045 },
3046
3047 /**
3048 * Force reset for all users
3049 */
3050 forceResetAllUsers: function() {
3051 var self = this;
3052 var includeSelf = $('#vigilante-reset-all-include-self').is(':checked');
3053
3054 var confirmMsg = vigilanteAdmin.strings.confirmForceResetAll || 'This will force ALL users to reset their password. All users will receive a password reset email. Are you sure you want to continue?';
3055 if (includeSelf) {
3056 confirmMsg += '\n\n' + (vigilanteAdmin.strings.warningResettingSelfAll || 'WARNING: You are including yourself. Your session will end immediately.');
3057 }
3058
3059 if (!confirm(confirmMsg)) {
3060 return;
3061 }
3062
3063 var $btn = $('#vigilante-reset-all-users');
3064 $btn.prop('disabled', true).text(vigilanteAdmin.strings.processing || 'Processing...');
3065
3066 $.ajax({
3067 url: vigilanteAdmin.ajaxUrl,
3068 type: 'POST',
3069 data: {
3070 action: 'vigilante_force_password_reset_all',
3071 nonce: vigilanteAdmin.nonce,
3072 include_self: includeSelf ? 1 : 0
3073 },
3074 success: function(response) {
3075 if (response.success) {
3076 self.showNotice('success', response.data.message);
3077
3078 // Reload if resetting self
3079 if (response.data.resetting_self) {
3080 setTimeout(function() {
3081 window.location.href = vigilanteAdmin.logoutUrl || '/wp-login.php';
3082 }, 2000);
3083 } else {
3084 setTimeout(function() {
3085 location.reload();
3086 }, 1500);
3087 }
3088 } else {
3089 self.showNotice('error', response.data);
3090 }
3091 },
3092 error: function() {
3093 self.showNotice('error', vigilanteAdmin.strings.anErrorOccurred || 'An error occurred');
3094 },
3095 complete: function() {
3096 $btn.prop('disabled', false).text(vigilanteAdmin.strings.forceResetAll || 'Force Reset for ALL Users');
3097 }
3098 });
3099 },
3100
3101 /**
3102 * Update role reset summary when checkboxes change
3103 */
3104 updateRoleResetSummary: function() {
3105 var totalCount = 0;
3106 var includesSelf = false;
3107
3108 $('.vigilante-reset-role-checkbox:checked').each(function() {
3109 totalCount += parseInt($(this).data('count') || 0);
3110 if ($(this).closest('label').find('em').length > 0) {
3111 includesSelf = true;
3112 }
3113 });
3114
3115 var $summary = $('#vigilante-reset-role-summary');
3116 var $selfOption = $('#vigilante-reset-role-self-option');
3117 var $btn = $('#vigilante-reset-by-role');
3118
3119 if (totalCount > 0) {
3120 $('#vigilante-reset-role-count').text(totalCount);
3121 $summary.show();
3122 $btn.prop('disabled', false);
3123 } else {
3124 $summary.hide();
3125 $btn.prop('disabled', true);
3126 }
3127
3128 if (includesSelf) {
3129 $selfOption.show();
3130 } else {
3131 $selfOption.hide();
3132 $('#vigilante-reset-role-include-self').prop('checked', false);
3133 }
3134 },
3135
3136 /**
3137 * Force reset for users with selected roles
3138 */
3139 forceResetByRoles: function() {
3140 var self = this;
3141 var roles = [];
3142
3143 $('.vigilante-reset-role-checkbox:checked').each(function() {
3144 roles.push($(this).val());
3145 });
3146
3147 if (roles.length === 0) {
3148 self.showNotice('error', vigilanteAdmin.strings.noRolesSelected || 'Please select at least one role.');
3149 return;
3150 }
3151
3152 var totalCount = 0;
3153 $('.vigilante-reset-role-checkbox:checked').each(function() {
3154 totalCount += parseInt($(this).data('count') || 0);
3155 });
3156
3157 var includeSelf = $('#vigilante-reset-role-include-self').is(':checked');
3158
3159 var confirmMsg = (vigilanteAdmin.strings.confirmForceResetByRole || 'Force password reset for %d user(s) with the selected roles? A password reset email will be sent to each user.').replace('%d', totalCount);
3160 if (includeSelf) {
3161 confirmMsg += '\n\n' + (vigilanteAdmin.strings.warningResettingSelfAll || 'WARNING: You are including yourself. Your session will end immediately.');
3162 }
3163
3164 if (!confirm(confirmMsg)) {
3165 return;
3166 }
3167
3168 var $btn = $('#vigilante-reset-by-role');
3169 $btn.prop('disabled', true).text(vigilanteAdmin.strings.processing || 'Processing...');
3170
3171 $.ajax({
3172 url: vigilanteAdmin.ajaxUrl,
3173 type: 'POST',
3174 data: {
3175 action: 'vigilante_force_password_reset_by_role',
3176 nonce: vigilanteAdmin.nonce,
3177 roles: roles,
3178 include_self: includeSelf ? 1 : 0
3179 },
3180 success: function(response) {
3181 if (response.success) {
3182 self.showNotice('success', response.data.message);
3183
3184 // Clear role checkboxes
3185 $('.vigilante-reset-role-checkbox').prop('checked', false);
3186 self.updateRoleResetSummary();
3187
3188 if (response.data.resetting_self) {
3189 setTimeout(function() {
3190 window.location.href = vigilanteAdmin.logoutUrl || '/wp-login.php';
3191 }, 2000);
3192 } else {
3193 setTimeout(function() {
3194 location.reload();
3195 }, 1500);
3196 }
3197 } else {
3198 self.showNotice('error', response.data);
3199 }
3200 },
3201 error: function() {
3202 self.showNotice('error', vigilanteAdmin.strings.anErrorOccurred || 'An error occurred');
3203 },
3204 complete: function() {
3205 $btn.prop('disabled', false).text(vigilanteAdmin.strings.forceResetByRole || 'Force Reset for Selected Roles');
3206 }
3207 });
3208 },
3209
3210 /**
3211 * Initialize user approval handlers
3212 */
3213 initUserApproval: function() {
3214 var self = this;
3215
3216 // Approve user
3217 $(document).on('click', '.vigilante-approve-user', function() {
3218 var $btn = $(this);
3219 var userId = $btn.data('user-id');
3220 var $row = $btn.closest('tr');
3221
3222 if (!confirm(vigilanteAdmin.strings.confirmApprove || 'Approve this user?')) {
3223 return;
3224 }
3225
3226 $btn.prop('disabled', true).text(vigilanteAdmin.strings.processing || 'Processing...');
3227
3228 $.ajax({
3229 url: vigilanteAdmin.ajaxUrl,
3230 type: 'POST',
3231 data: {
3232 action: 'vigilante_approve_user',
3233 nonce: vigilanteAdmin.nonce,
3234 user_id: userId
3235 },
3236 success: function(response) {
3237 if (response.success) {
3238 self.showNotice('success', response.data.message);
3239 $row.fadeOut(400, function() {
3240 $(this).remove();
3241 self.updatePendingCount();
3242 });
3243 } else {
3244 self.showNotice('error', response.data);
3245 $btn.prop('disabled', false).text(vigilanteAdmin.strings.approve || 'Approve');
3246 }
3247 },
3248 error: function() {
3249 self.showNotice('error', vigilanteAdmin.strings.error);
3250 $btn.prop('disabled', false).text(vigilanteAdmin.strings.approve || 'Approve');
3251 }
3252 });
3253 });
3254
3255 // Reject user
3256 $(document).on('click', '.vigilante-reject-user', function() {
3257 var $btn = $(this);
3258 var userId = $btn.data('user-id');
3259 var $row = $btn.closest('tr');
3260
3261 var reason = prompt(vigilanteAdmin.strings.rejectReason || 'Enter rejection reason (optional):');
3262 if (reason === null) {
3263 return; // Cancelled
3264 }
3265
3266 $btn.prop('disabled', true).text(vigilanteAdmin.strings.processing || 'Processing...');
3267
3268 $.ajax({
3269 url: vigilanteAdmin.ajaxUrl,
3270 type: 'POST',
3271 data: {
3272 action: 'vigilante_reject_user',
3273 nonce: vigilanteAdmin.nonce,
3274 user_id: userId,
3275 reason: reason
3276 },
3277 success: function(response) {
3278 if (response.success) {
3279 self.showNotice('success', response.data.message);
3280 $row.fadeOut(400, function() {
3281 $(this).remove();
3282 self.updatePendingCount();
3283 });
3284 } else {
3285 self.showNotice('error', response.data);
3286 $btn.prop('disabled', false).text(vigilanteAdmin.strings.reject || 'Reject');
3287 }
3288 },
3289 error: function() {
3290 self.showNotice('error', vigilanteAdmin.strings.error);
3291 $btn.prop('disabled', false).text(vigilanteAdmin.strings.reject || 'Reject');
3292 }
3293 });
3294 });
3295 },
3296
3297 /**
3298 * Update pending users count badge
3299 */
3300 updatePendingCount: function() {
3301 var $badge = $('.vigilante-pending-users-section .vigilante-badge-warning');
3302 var $table = $('.vigilante-pending-users-table tbody tr');
3303 var count = $table.length;
3304
3305 if (count === 0) {
3306 $badge.remove();
3307 $('.vigilante-pending-users-table').replaceWith(
3308 '<div class="vigilante-no-lockouts">' +
3309 '<span class="dashicons dashicons-yes-alt"></span>' +
3310 '<p>' + (vigilanteAdmin.strings.noPending || 'No pending registrations.') + '</p>' +
3311 '</div>'
3312 );
3313 } else {
3314 $badge.text(count);
3315 }
3316 },
3317
3318 /**
3319 * Initialize session management handlers
3320 */
3321 initSessionManagement: function() {
3322 var self = this;
3323 var searchTimer = null;
3324 this.selectedSessionUserId = null;
3325
3326 // Search users for session management
3327 $(document).on('input', '#vigilante-session-user-search', function() {
3328 var query = $(this).val();
3329 var $results = $('#vigilante-session-search-results');
3330
3331 clearTimeout(searchTimer);
3332
3333 if (query.length < 2) {
3334 $results.hide().empty();
3335 return;
3336 }
3337
3338 searchTimer = setTimeout(function() {
3339 $.ajax({
3340 url: vigilanteAdmin.ajaxUrl,
3341 type: 'POST',
3342 data: {
3343 action: 'vigilante_search_users_password_reset',
3344 nonce: vigilanteAdmin.nonce,
3345 query: query
3346 },
3347 success: function(response) {
3348 if (response.success && response.data.length > 0) {
3349 var html = '<ul class="vigilante-user-search-list">';
3350 $.each(response.data, function(i, user) {
3351 html += '<li data-user-id="' + user.ID + '">' +
3352 '<img src="' + self.escapeHtml(user.avatar) + '" class="vigilante-user-avatar" alt="">' +
3353 '<div class="vigilante-user-info">' +
3354 '<strong>' + self.escapeHtml(user.display_name) + '</strong><br>' +
3355 '<small>' + self.escapeHtml(user.user_email) + '</small>' +
3356 '</div>' +
3357 '</li>';
3358 });
3359 html += '</ul>';
3360 $results.html(html).show();
3361 } else {
3362 $results.html('<p class="vigilante-no-results">' + (vigilanteAdmin.strings.noUsers || 'No users found') + '</p>').show();
3363 }
3364 }
3365 });
3366 }, 300);
3367 });
3368
3369 // Click handler for user selection in session search
3370 $(document).on('click', '#vigilante-session-search-results li', function() {
3371 var userId = $(this).data('user-id');
3372 var userName = $(this).find('strong').text();
3373 self.loadUserSessions(userId, userName);
3374 $('#vigilante-session-search-results').hide();
3375 $('#vigilante-session-user-search').val(userName);
3376 });
3377
3378 // Revoke single session
3379 $(document).on('click', '.vigilante-revoke-session', function() {
3380 var $btn = $(this);
3381 var userId = $btn.attr('data-user-id');
3382 var token = $btn.attr('data-token');
3383 var $row = $btn.closest('tr');
3384 var $paginatedContainer = $btn.closest('.vigilante-paginated-section');
3385
3386 // Fallback to row token if button token is invalid
3387 if (!token || token.length < 32) {
3388 token = $row.attr('data-token');
3389 }
3390
3391 if (!token || token.length < 32) {
3392 self.showNotice('error', vigilanteAdmin.strings.error || 'Invalid session token');
3393 return;
3394 }
3395
3396 if (!confirm(vigilanteAdmin.strings.confirmRevoke || 'Revoke this session?')) {
3397 return;
3398 }
3399
3400 $btn.prop('disabled', true);
3401
3402 $.ajax({
3403 url: vigilanteAdmin.ajaxUrl,
3404 type: 'POST',
3405 data: {
3406 action: 'vigilante_revoke_session',
3407 nonce: vigilanteAdmin.nonce,
3408 user_id: userId,
3409 token: token
3410 },
3411 success: function(response) {
3412 if (response.success) {
3413 self.showNotice('success', response.data.message);
3414 $row.fadeOut(400, function() {
3415 $(this).remove();
3416 if ($paginatedContainer.length) {
3417 Vigilante_Admin.refreshFilePagination($paginatedContainer);
3418 }
3419 });
3420 } else {
3421 self.showNotice('error', response.data);
3422 $btn.prop('disabled', false);
3423 }
3424 },
3425 error: function() {
3426 self.showNotice('error', vigilanteAdmin.strings.error);
3427 $btn.prop('disabled', false);
3428 }
3429 });
3430 });
3431
3432 // Revoke all other sessions (current user)
3433 $(document).on('click', '.vigilante-revoke-other-sessions', function() {
3434 var $btn = $(this);
3435 var userId = $btn.data('user-id');
3436
3437 if (!confirm(vigilanteAdmin.strings.confirmRevokeAll || 'Revoke all other sessions?')) {
3438 return;
3439 }
3440
3441 $btn.prop('disabled', true).text(vigilanteAdmin.strings.processing || 'Processing...');
3442
3443 $.ajax({
3444 url: vigilanteAdmin.ajaxUrl,
3445 type: 'POST',
3446 data: {
3447 action: 'vigilante_revoke_all_sessions',
3448 nonce: vigilanteAdmin.nonce,
3449 user_id: userId,
3450 include_current: 0
3451 },
3452 success: function(response) {
3453 if (response.success) {
3454 self.showNotice('success', response.data.message);
3455 setTimeout(function() {
3456 location.reload();
3457 }, 1000);
3458 } else {
3459 self.showNotice('error', response.data);
3460 }
3461 },
3462 error: function() {
3463 self.showNotice('error', vigilanteAdmin.strings.error);
3464 },
3465 complete: function() {
3466 $btn.prop('disabled', false).text(vigilanteAdmin.strings.revokeOthers || 'Revoke All Other Sessions');
3467 }
3468 });
3469 });
3470
3471 // Revoke all sessions for a user (admin managing other users)
3472 $(document).on('click', '.vigilante-revoke-all-user-sessions', function() {
3473 var $btn = $(this);
3474
3475 if (!self.selectedSessionUserId) {
3476 return;
3477 }
3478
3479 if (!confirm(vigilanteAdmin.strings.confirmRevokeAllUser || 'Revoke ALL sessions for this user? They will be logged out everywhere.')) {
3480 return;
3481 }
3482
3483 $btn.prop('disabled', true);
3484
3485 $.ajax({
3486 url: vigilanteAdmin.ajaxUrl,
3487 type: 'POST',
3488 data: {
3489 action: 'vigilante_revoke_all_sessions',
3490 nonce: vigilanteAdmin.nonce,
3491 user_id: self.selectedSessionUserId,
3492 include_current: 1
3493 },
3494 success: function(response) {
3495 if (response.success) {
3496 self.showNotice('success', response.data.message);
3497 $('#vigilante-user-sessions-container').hide();
3498 $('#vigilante-session-user-search').val('');
3499 } else {
3500 self.showNotice('error', response.data);
3501 }
3502 },
3503 error: function() {
3504 self.showNotice('error', vigilanteAdmin.strings.error);
3505 },
3506 complete: function() {
3507 $btn.prop('disabled', false);
3508 }
3509 });
3510 });
3511 },
3512
3513 /**
3514 * Load sessions for a specific user
3515 */
3516 loadUserSessions: function(userId, userName) {
3517 var self = this;
3518 this.selectedSessionUserId = userId;
3519 var $container = $('#vigilante-user-sessions-container');
3520 var $list = $('#vigilante-user-sessions-list');
3521
3522 $list.html('<tr><td colspan="4">' + (vigilanteAdmin.strings.loading || 'Loading...') + '</td></tr>');
3523 $container.show();
3524 $('#vigilante-sessions-user-name').text((vigilanteAdmin.strings.sessionsFor || 'Sessions for: ') + userName);
3525
3526 $.ajax({
3527 url: vigilanteAdmin.ajaxUrl,
3528 type: 'POST',
3529 data: {
3530 action: 'vigilante_get_user_sessions',
3531 nonce: vigilanteAdmin.nonce,
3532 user_id: userId
3533 },
3534 success: function(response) {
3535 if (response.success) {
3536 var sessions = response.data.sessions;
3537 if (sessions.length === 0) {
3538 $list.html('<tr><td colspan="4">' + (vigilanteAdmin.strings.noSessions || 'No active sessions') + '</td></tr>');
3539 return;
3540 }
3541
3542 var html = '';
3543 $.each(sessions, function(i, session) {
3544 var loginTime = session.login ? self.timeAgo(session.login) : (vigilanteAdmin.strings.unknown || 'Unknown');
3545 html += '<tr data-token="' + session.token_hash + '">' +
3546 '<td>' + self.escapeHtml(session.browser) + '</td>' +
3547 '<td><code>' + self.escapeHtml(session.ip) + '</code></td>' +
3548 '<td>' + loginTime + '</td>' +
3549 '<td>' +
3550 '<button type="button" class="button button-small vigilante-revoke-session" ' +
3551 'data-user-id="' + userId + '" data-token="' + session.token_hash + '">' +
3552 (vigilanteAdmin.strings.revoke || 'Revoke') +
3553 '</button>' +
3554 '</td>' +
3555 '</tr>';
3556 });
3557 $list.html(html);
3558 } else {
3559 $list.html('<tr><td colspan="4">' + response.data + '</td></tr>');
3560 }
3561 },
3562 error: function() {
3563 $list.html('<tr><td colspan="4">' + vigilanteAdmin.strings.error + '</td></tr>');
3564 }
3565 });
3566 },
3567
3568 /**
3569 * Format timestamp as time ago
3570 */
3571 timeAgo: function(timestamp) {
3572 var seconds = Math.floor((Date.now() / 1000) - timestamp);
3573 var s = vigilanteAdmin.strings;
3574 var intervals = [
3575 { label: s.timeYear || 'year', labelPlural: s.timeYears || 'years', seconds: 31536000 },
3576 { label: s.timeMonth || 'month', labelPlural: s.timeMonths || 'months', seconds: 2592000 },
3577 { label: s.timeDay || 'day', labelPlural: s.timeDays || 'days', seconds: 86400 },
3578 { label: s.timeHour || 'hour', labelPlural: s.timeHours || 'hours', seconds: 3600 },
3579 { label: s.timeMinute || 'minute', labelPlural: s.timeMinutes || 'minutes', seconds: 60 }
3580 ];
3581
3582 for (var i = 0; i < intervals.length; i++) {
3583 var count = Math.floor(seconds / intervals[i].seconds);
3584 if (count >= 1) {
3585 var unit = count > 1 ? intervals[i].labelPlural : intervals[i].label;
3586 return (s.timeAgo || '%1$d %2$s ago').replace('%1$d', count).replace('%2$s', unit);
3587 }
3588 }
3589 return s.justNow || 'Just now';
3590 }
3591 };
3592
3593 /* ---------------------------------------------------------------------
3594 * Security Analyzer (Dashboard widget) — v2.1.0
3595 * ------------------------------------------------------------------ */
3596
3597 var VigilanteAnalyzer = {
3598
3599 $root: null,
3600
3601 init: function() {
3602 this.$root = $('#vigilante-analyzer');
3603 if (!this.$root.length) {
3604 // Widget not present on this page — still run hash focus in case a fix link was followed.
3605 this.hashFocusFlash();
3606 return;
3607 }
3608
3609 // Scan button — 2-phase AJAX.
3610 $(document).on('click', '#vigilante-analyzer-scan', this.handleScan.bind(this));
3611
3612 // Expand/collapse detailed breakdown.
3613 $(document).on('click', '.vigilante-analyzer-toggle', this.handleToggleDetails.bind(this));
3614
3615 // Save weekly cron + email toggle on change.
3616 $(document).on('change',
3617 '.vigilante-analyzer-weekly input[type="checkbox"]',
3618 this.handleSettingsChange.bind(this));
3619
3620 // Pulse the row we landed on if we came from a fix link.
3621 this.hashFocusFlash();
3622
3623 // Upgrade the server-rendered SVG (no-op if already correct).
3624 this.renderSparklineFromData();
3625 },
3626
3627 /**
3628 * Kick off a scan. Phase 'fast' first for instant feedback, then 'slow' for remote checks.
3629 */
3630 handleScan: function(e) {
3631 e.preventDefault();
3632 if (this.isScanning) {
3633 return;
3634 }
3635 this.isScanning = true;
3636
3637 var $btn = $('#vigilante-analyzer-scan');
3638 var originalHtml = $btn.html();
3639 $btn.data('original-html', originalHtml);
3640 $btn.prop('disabled', true)
3641 .html('<span class="dashicons dashicons-update vigilante-analyzer-spin"></span> '
3642 + (vigilanteAdmin.strings.analyzerFastPhase || 'Running fast checks…'));
3643
3644 var self = this;
3645
3646 // Expand the details section so the user sees results pour in.
3647 this.expandDetails(true);
3648
3649 this.runScanPhase('fast')
3650 .done(function(fastReport) {
3651 self.applyReport(fastReport);
3652 $btn.html('<span class="dashicons dashicons-update vigilante-analyzer-spin"></span> '
3653 + (vigilanteAdmin.strings.analyzerSlowPhase || 'Running remote checks…'));
3654
3655 self.runScanPhase('slow')
3656 .done(function(slowReport) {
3657 self.applyReport(slowReport);
3658 if (typeof Vigilante_Admin !== 'undefined' && Vigilante_Admin.showNotice) {
3659 Vigilante_Admin.showNotice('success',
3660 vigilanteAdmin.strings.analyzerScanComplete || 'Security scan complete.');
3661 }
3662 })
3663 .fail(function(msg) {
3664 if (typeof Vigilante_Admin !== 'undefined' && Vigilante_Admin.showNotice) {
3665 Vigilante_Admin.showNotice('error',
3666 msg || vigilanteAdmin.strings.analyzerScanFailed || 'Security scan failed.');
3667 }
3668 })
3669 .always(function() {
3670 $btn.prop('disabled', false).html($btn.data('original-html') || originalHtml);
3671 self.isScanning = false;
3672 });
3673 })
3674 .fail(function(msg) {
3675 $btn.prop('disabled', false).html($btn.data('original-html') || originalHtml);
3676 self.isScanning = false;
3677 if (typeof Vigilante_Admin !== 'undefined' && Vigilante_Admin.showNotice) {
3678 Vigilante_Admin.showNotice('error',
3679 msg || vigilanteAdmin.strings.analyzerScanFailed || 'Security scan failed.');
3680 }
3681 });
3682 },
3683
3684 runScanPhase: function(phase) {
3685 var deferred = $.Deferred();
3686 $.ajax({
3687 url: vigilanteAdmin.ajaxUrl,
3688 type: 'POST',
3689 timeout: 60000,
3690 data: {
3691 action: 'vigilante_analyzer_run',
3692 nonce: vigilanteAdmin.nonce,
3693 phase: phase
3694 }
3695 }).done(function(response) {
3696 if (response && response.success) {
3697 deferred.resolve(response.data);
3698 } else {
3699 deferred.reject(response && response.data ? response.data : null);
3700 }
3701 }).fail(function(xhr, status, err) {
3702 deferred.reject(err || status);
3703 });
3704 return deferred.promise();
3705 },
3706
3707 /**
3708 * Map a 0-100 score percentage to a quality tag {label, slug}, mirroring
3709 * Vigilante_SA_Helpers::grade_for_score() in PHP: A only at 100, B from
3710 * 85, C from 70, D from 50, E below. Change one, change the other.
3711 */
3712 qualityTag: function(pct) {
3713 pct = Math.max(0, Math.min(100, parseInt(pct, 10) || 0));
3714 var s = vigilanteAdmin.strings || {};
3715 if (pct === 100) { return { label: s.analyzerQualityExcellent || 'Excellent', slug: 'a' }; }
3716 if (pct >= 85) { return { label: s.analyzerQualityGood || 'Good', slug: 'b' }; }
3717 if (pct >= 70) { return { label: s.analyzerQualityFair || 'Fair', slug: 'c' }; }
3718 if (pct >= 50) { return { label: s.analyzerQualityPoor || 'Poor', slug: 'd' }; }
3719 return { label: s.analyzerQualityCritical || 'Critical', slug: 'e' };
3720 },
3721
3722 /**
3723 * Swap a {a,b,c,d,e} slug modifier class on an element.
3724 */
3725 setQualityClass: function($el, baseClass, slug) {
3726 $el.removeClass(function(i, cls) {
3727 return (cls.match(new RegExp('(^|\\s)' + baseClass + '-(?:[abcde]|info)(?=\\s|$)', 'g')) || []).join(' ');
3728 });
3729 $el.addClass(baseClass + '-' + slug);
3730 },
3731
3732 /**
3733 * Merge a phase report into the widget DOM.
3734 */
3735 applyReport: function(report) {
3736 if (!report || typeof report !== 'object') {
3737 return;
3738 }
3739 var $root = this.$root;
3740 var self = this;
3741
3742 // Overall pass/warn/fail counts (summary).
3743 if (report.counts && typeof report.counts === 'object') {
3744 $root.find('[data-role="pass"]').text(report.counts.pass || 0);
3745 $root.find('[data-role="warn"]').text(report.counts.warn || 0);
3746 $root.find('[data-role="fail"]').text(report.counts.fail || 0);
3747
3748 // Left out of the score, so it is said: shown only when there is any.
3749 var skipped = parseInt(report.counts.skip, 10) || 0;
3750 $root.find('[data-role="skip"]').text(skipped);
3751 $root.find('[data-role="skip-wrap"]').prop('hidden', skipped < 1);
3752 }
3753
3754 // The note that says the score comes from an earlier scan, capped
3755 // by what self-protection found then, goes once a new scan is not
3756 // capped: it asks for a new scan, and it stayed after running it.
3757 if (report.categories && report.capped_by !== 'self_integrity') {
3758 $root.find('.vigilante-analyzer-capped[data-why="stored"]').remove();
3759 }
3760
3761 // Main score circle + quality tag.
3762 if (report.grade) {
3763 var $card = $root.find('.vigilante-analyzer-score-card');
3764 var $circle = $card.find('.vigilante-score-circle');
3765 var gradeLc = String(report.grade).toLowerCase();
3766 var scoreN = parseInt(report.score, 10) || 0;
3767
3768 if (!$circle.length || $circle.hasClass('vigilante-grade-empty')) {
3769 $circle.remove();
3770 $card.prepend(
3771 '<div class="vigilante-score-circle vigilante-grade-'
3772 + this.escAttr(gradeLc)
3773 + '">'
3774 + '<span class="vigilante-grade">' + this.esc(report.grade) + '</span>'
3775 + '<span class="vigilante-score-text">' + this.esc(scoreN) + '%</span>'
3776 + '</div>'
3777 );
3778 } else {
3779 $circle.removeClass(function(i, cls) {
3780 return (cls.match(/(^|\s)vigilante-grade-\S+/g) || []).join(' ');
3781 }).addClass('vigilante-grade-' + gradeLc);
3782 $circle.find('.vigilante-grade').text(report.grade);
3783 $circle.find('.vigilante-score-text').text(scoreN + '%');
3784 }
3785
3786 // Quality tag in the score card.
3787 var quality = this.qualityTag(scoreN);
3788 var $tag = $root.find('[data-role="quality-tag"]');
3789 if ($tag.length) {
3790 this.setQualityClass($tag, 'vigilante-analyzer-quality', quality.slug);
3791 $tag.text(quality.label);
3792 }
3793 }
3794
3795 // "Last scan" label in the header.
3796 if (report.ran_at) {
3797 var $ranAt = $root.find('[data-role="ran-at"]');
3798 var justNow = vigilanteAdmin.strings.analyzerLastScanJustNow
3799 || 'Last scan just now';
3800 $ranAt.html(
3801 '<span class="dashicons dashicons-clock" aria-hidden="true"></span>'
3802 + this.esc(justNow)
3803 );
3804 }
3805
3806 // Per-category rows: quality pill, state counts, score, bar, and check rows.
3807 if (report.categories && typeof report.categories === 'object') {
3808 $.each(report.categories, function(slug, cat) {
3809 var $cat = $root.find('.vigilante-analyzer-category[data-category="' + slug + '"]');
3810 if (!$cat.length) {
3811 return;
3812 }
3813
3814 var infoOnly = $cat.attr('data-info-only') === '1';
3815 var counts = (cat.counts && typeof cat.counts === 'object') ? cat.counts : {};
3816
3817 if (!infoOnly) {
3818 var earned = parseInt(cat.earned, 10) || 0;
3819 var max = parseInt(cat.max, 10) || 0;
3820 var pct = max > 0 ? Math.round((earned / max) * 100) : 0;
3821 var cq = self.qualityTag(pct);
3822 var ran = Array.isArray(cat.checks) && cat.checks.length > 0;
3823
3824 // Checks ran and none of them could be scored: not a zero.
3825 if (ran && max === 0) {
3826 cq = {
3827 label: (vigilanteAdmin.strings && vigilanteAdmin.strings.analyzerNotMeasured) || 'Not measured',
3828 slug: 'info'
3829 };
3830 }
3831
3832 $cat.find('[data-role="earned"]').text(earned);
3833 // The server adds the maximum up from the checks that
3834 // scored, so it changes from one scan to the next.
3835 if (ran) {
3836 $cat.find('[data-role="max"]').text(max);
3837 }
3838
3839 var $catQuality = $cat.find('[data-role="category-quality"]');
3840 if ($catQuality.length) {
3841 self.setQualityClass($catQuality, 'vigilante-analyzer-quality', cq.slug);
3842 // Update only the label span; the pill also contains the
3843 // "X/Y tests" counter so a blanket .text() would wipe it.
3844 var $label = $catQuality.find('[data-role="category-quality-label"]');
3845 if ($label.length) {
3846 $label.text(cq.label);
3847 } else {
3848 $catQuality.text(cq.label);
3849 }
3850 }
3851
3852 // "X/Y tests" inside the pill. Total is everything that
3853 // scores (pass + warn + fail), info/skip are excluded
3854 // because they don't move the category score.
3855 var $tests = $cat.find('[data-role="category-tests"]');
3856 if ($tests.length) {
3857 var passed = parseInt(counts.pass, 10) || 0;
3858 var total = passed
3859 + (parseInt(counts.warn, 10) || 0)
3860 + (parseInt(counts.fail, 10) || 0);
3861 var tpl = (vigilanteAdmin.strings && vigilanteAdmin.strings.testsCounter)
3862 || '%1$d/%2$d tests';
3863 $tests.text(tpl.replace('%1$d', passed).replace('%2$d', total));
3864 $tests.attr('data-passed', passed);
3865 $tests.attr('data-total', total);
3866 }
3867
3868 // Progress bar (width + quality color).
3869 var $bar = $cat.find('[data-role="category-bar"]');
3870 if ($bar.length) {
3871 $bar.css('width', pct + '%');
3872 // Two hyphens before the slug, as the stylesheet and the server write it.
3873 self.setQualityClass($bar, 'vigilante-analyzer-category-bar-fill-', cq.slug);
3874 }
3875 }
3876
3877 // State counts — show each chip only when count > 0.
3878 // Info-only categories (like reputation) also show an info count.
3879 self.updateCategoryState($cat, 'pass', parseInt(counts.pass, 10) || 0);
3880 self.updateCategoryState($cat, 'warn', parseInt(counts.warn, 10) || 0);
3881 self.updateCategoryState($cat, 'fail', parseInt(counts.fail, 10) || 0);
3882 if (infoOnly) {
3883 self.updateCategoryState($cat, 'info', parseInt(counts.info, 10) || 0);
3884 // Info-only categories replace the progress bar with a status pill
3885 // ("All clear" when nothing actionable, "N findings" otherwise).
3886 self.updateInfoStatus($cat, counts);
3887 }
3888
3889 // Replace the check rows only for checks this phase actually returned.
3890 if (cat.checks && cat.checks.length) {
3891 var $list = $cat.find('[data-role="check-list"]');
3892 $list.find('.vigilante-analyzer-check-empty').remove();
3893
3894 $.each(cat.checks, function(i, check) {
3895 var $existing = $list.find('[data-check-id="' + check.id + '"]');
3896 var html = self.renderCheckRow(check);
3897 if ($existing.length) {
3898 $existing.replaceWith(html);
3899 } else {
3900 $list.append(html);
3901 }
3902 });
3903 }
3904 });
3905 }
3906 },
3907
3908 /**
3909 * Update a per-category state chip (pass/warn/fail). Adds the chip if it doesn't
3910 * exist yet, updates its number, or removes it when the count is zero.
3911 */
3912 updateCategoryState: function($cat, state, count) {
3913 var $wrap = $cat.find('[data-role="category-states"]');
3914 if (!$wrap.length) {
3915 return;
3916 }
3917 var selector = '.vigilante-analyzer-category-state--' + state;
3918 var $chip = $wrap.find(selector);
3919
3920 if (count > 0) {
3921 if (!$chip.length) {
3922 var iconMap = { pass: 'yes', warn: 'warning', fail: 'no', info: 'info-outline' };
3923 var titleMap = {
3924 pass: 'Passing',
3925 warn: 'Warning',
3926 fail: 'Failing',
3927 info: 'Informational'
3928 };
3929 var title = titleMap[state] || '';
3930 $chip = $(
3931 '<span class="vigilante-analyzer-category-state vigilante-analyzer-category-state--'
3932 + state + '" title="' + this.escAttr(title) + '">'
3933 + '<span data-role="state-' + state + '">0</span>'
3934 + '<span class="dashicons dashicons-' + iconMap[state] + '" aria-hidden="true"></span>'
3935 + '</span>'
3936 );
3937 // Keep order: pass → warn → fail → info.
3938 if (state === 'pass') {
3939 $wrap.prepend($chip);
3940 } else if (state === 'warn') {
3941 var $passChip = $wrap.find('.vigilante-analyzer-category-state--pass');
3942 if ($passChip.length) { $chip.insertAfter($passChip); } else { $wrap.prepend($chip); }
3943 } else if (state === 'fail') {
3944 var $infoChip = $wrap.find('.vigilante-analyzer-category-state--info');
3945 if ($infoChip.length) { $chip.insertBefore($infoChip); } else { $wrap.append($chip); }
3946 } else {
3947 $wrap.append($chip);
3948 }
3949 }
3950 $chip.find('[data-role="state-' + state + '"]').text(count);
3951 } else if ($chip.length) {
3952 $chip.remove();
3953 }
3954 },
3955
3956 /**
3957 * Refresh the "All clear" / "N findings" pill for an info-only category.
3958 * Runs after a scan replaces the counts on the server side.
3959 */
3960 updateInfoStatus: function($cat, counts) {
3961 var $status = $cat.find('[data-role="info-status"]');
3962 if (!$status.length) {
3963 return;
3964 }
3965 var warnN = parseInt(counts.warn, 10) || 0;
3966 var failN = parseInt(counts.fail, 10) || 0;
3967 var issues = warnN + failN;
3968 var strings = (vigilanteAdmin && vigilanteAdmin.strings) || {};
3969
3970 if (issues > 0) {
3971 var template = strings.analyzerInfoFindings || '%d findings';
3972 var label = template.replace('%d', issues);
3973 $status
3974 .removeClass('vigilante-analyzer-category-status--clear')
3975 .addClass('vigilante-analyzer-category-status--attention')
3976 .html(
3977 '<span class="dashicons dashicons-warning" aria-hidden="true"></span>'
3978 + this.esc(label)
3979 );
3980 } else {
3981 var clear = strings.analyzerInfoAllClear || 'All clear';
3982 $status
3983 .removeClass('vigilante-analyzer-category-status--attention')
3984 .addClass('vigilante-analyzer-category-status--clear')
3985 .html(
3986 '<span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>'
3987 + this.esc(clear)
3988 );
3989 }
3990 },
3991
3992 renderCheckRow: function(check) {
3993 var state = check.state || 'skip';
3994 var icons = {
3995 pass: 'yes-alt',
3996 warn: 'warning',
3997 fail: 'dismiss',
3998 info: 'info',
3999 skip: 'minus'
4000 };
4001 var icon = icons[state] || 'minus';
4002
4003 var scoreHtml = '';
4004 var max = parseInt(check.max, 10) || 0;
4005 if (max > 0 && state !== 'info' && state !== 'skip') {
4006 scoreHtml = '<span class="vigilante-analyzer-check-score">'
4007 + this.esc((parseInt(check.score, 10) || 0) + '/' + max)
4008 + '</span>';
4009 }
4010
4011 var fixHtml = '';
4012 if (check.fix_link && (state === 'fail' || state === 'warn')) {
4013 fixHtml = '<a href="' + this.escAttr(check.fix_link) + '" class="vigilante-analyzer-fix-link">'
4014 + this.esc(vigilanteAdmin.strings.analyzerGoToSetting || 'Go to setting')
4015 + '<span class="vigilante-analyzer-fix-arrow" aria-hidden="true">&rarr;</span></a>';
4016 } else if (check.fix_link && state === 'info') {
4017 var isExt = /^https?:\/\//i.test(check.fix_link);
4018 fixHtml = '<a href="' + this.escAttr(check.fix_link) + '" class="vigilante-analyzer-fix-link"'
4019 + (isExt ? ' target="_blank" rel="noopener noreferrer"' : '') + '>'
4020 + this.esc(vigilanteAdmin.strings.analyzerLearnMore || 'Learn more')
4021 + '<span class="vigilante-analyzer-fix-arrow" aria-hidden="true">&rarr;</span></a>';
4022 }
4023
4024 var detailHtml = '';
4025 if (check.detail) {
4026 detailHtml = '<p class="vigilante-analyzer-check-detail">' + this.esc(check.detail) + '</p>';
4027 }
4028
4029 return '<li class="vigilante-analyzer-check vigilante-analyzer-check--' + this.escAttr(state) + '"'
4030 + ' data-check-id="' + this.escAttr(check.id || '') + '">'
4031 + '<span class="vigilante-analyzer-check-icon dashicons dashicons-' + this.escAttr(icon) + '" aria-hidden="true"></span>'
4032 + '<div class="vigilante-analyzer-check-body">'
4033 + '<div class="vigilante-analyzer-check-label">'
4034 + '<span>' + this.esc(check.label || '') + '</span>'
4035 + scoreHtml
4036 + '</div>'
4037 + detailHtml
4038 + fixHtml
4039 + '</div>'
4040 + '</li>';
4041 },
4042
4043 /**
4044 * Expand or collapse the detailed breakdown section.
4045 */
4046 handleToggleDetails: function(e) {
4047 e.preventDefault();
4048 var $btn = $(e.currentTarget);
4049 var expanded = $btn.attr('aria-expanded') === 'true';
4050 this.expandDetails(!expanded);
4051 },
4052
4053 expandDetails: function(expand) {
4054 var $btn = this.$root.find('.vigilante-analyzer-toggle');
4055 var $details = this.$root.find('.vigilante-analyzer-details');
4056 var show = !!expand;
4057
4058 $btn.attr('aria-expanded', show ? 'true' : 'false');
4059
4060 var label = show
4061 ? (vigilanteAdmin.strings.analyzerHideDetails || 'Hide detailed breakdown')
4062 : (vigilanteAdmin.strings.analyzerShowDetails || 'Show detailed breakdown');
4063
4064 // Preserve the chevron element; only swap the label text.
4065 var $chevron = $btn.find('.vigilante-analyzer-toggle-chevron').detach();
4066 if (!$chevron.length) {
4067 $chevron = $('<span class="vigilante-analyzer-toggle-chevron" aria-hidden="true"></span>');
4068 }
4069 $btn.text(label).append(' ').append($chevron);
4070
4071 if (show) {
4072 $details.removeAttr('hidden');
4073 } else {
4074 $details.attr('hidden', 'hidden');
4075 }
4076 },
4077
4078 /**
4079 * Auto-save weekly scan + email toggles.
4080 */
4081 handleSettingsChange: function(e) {
4082 var $input = $(e.currentTarget);
4083 var $weekly = this.$root.find('input[name="security_analyzer[weekly_scan_enabled]"]');
4084 var $email = this.$root.find('input[name="security_analyzer[email_on_regression]"]');
4085
4086 $.ajax({
4087 url: vigilanteAdmin.ajaxUrl,
4088 type: 'POST',
4089 data: {
4090 action: 'vigilante_analyzer_save_settings',
4091 nonce: vigilanteAdmin.nonce,
4092 weekly_scan_enabled: $weekly.is(':checked') ? 1 : 0,
4093 email_on_regression: $email.is(':checked') ? 1 : 0
4094 },
4095 success: function(response) {
4096 if (response && response.success) {
4097 if (typeof Vigilante_Admin !== 'undefined' && Vigilante_Admin.showNotice) {
4098 Vigilante_Admin.showNotice('success',
4099 vigilanteAdmin.strings.analyzerSettingsSaved || 'Analyzer settings saved.');
4100 }
4101 } else {
4102 if (typeof Vigilante_Admin !== 'undefined' && Vigilante_Admin.showNotice) {
4103 Vigilante_Admin.showNotice('error',
4104 (response && response.data) || vigilanteAdmin.strings.error);
4105 }
4106 $input.prop('checked', !$input.is(':checked'));
4107 }
4108 },
4109 error: function() {
4110 if (typeof Vigilante_Admin !== 'undefined' && Vigilante_Admin.showNotice) {
4111 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.error);
4112 }
4113 $input.prop('checked', !$input.is(':checked'));
4114 }
4115 });
4116 },
4117
4118 /**
4119 * Re-render the sparkline from data-points (no-op if no points).
4120 */
4121 renderSparklineFromData: function() {
4122 var $wrap = this.$root.find('[data-role="sparkline"]');
4123 if (!$wrap.length) {
4124 return;
4125 }
4126 var raw = $wrap.attr('data-points');
4127 if (!raw) {
4128 return;
4129 }
4130 try {
4131 var points = JSON.parse(raw);
4132 if (!points || points.length < 2) {
4133 return;
4134 }
4135 // Only regenerate if the SVG is missing (e.g. server gave empty markup).
4136 if (!$wrap.find('svg').length) {
4137 $wrap.html(this.buildSparklineSvg(points));
4138 }
4139 this.attachSparklineHotspots($wrap, points);
4140 } catch (err) { /* ignore parse errors */ }
4141 },
4142
4143 /**
4144 * Inject invisible hot-spots over each sparkline point and wire up a tooltip
4145 * that shows the score on hover/focus.
4146 */
4147 attachSparklineHotspots: function($wrap, points) {
4148 if (!points || points.length < 2) {
4149 return;
4150 }
4151 // Idempotent: clear any previous hot-spots/tooltip first.
4152 $wrap.find('.vigilante-analyzer-sparkline-dot, .vigilante-analyzer-sparkline-tooltip').remove();
4153
4154 var SVG_W = 280, SVG_H = 60, PAD = 4;
4155 var usableW = SVG_W - PAD * 2;
4156 var usableH = SVG_H - PAD * 2;
4157 var step = usableW / Math.max(1, points.length - 1);
4158 var max = 100;
4159
4160 var $tooltip = $('<div class="vigilante-analyzer-sparkline-tooltip" aria-hidden="true"></div>');
4161 $wrap.append($tooltip);
4162
4163 for (var i = 0; i < points.length; i++) {
4164 var x = PAD + (i * step);
4165 var y = PAD + (usableH - ((points[i] / max) * usableH));
4166 var xPct = (x / SVG_W) * 100;
4167 var yPct = (y / SVG_H) * 100;
4168
4169 var $dot = $('<button type="button" class="vigilante-analyzer-sparkline-dot"></button>')
4170 .css({ left: xPct + '%', top: yPct + '%' })
4171 .attr('data-score', points[i])
4172 .attr('data-index', i)
4173 .attr('aria-label', String(points[i]));
4174 $wrap.append($dot);
4175 }
4176
4177 $wrap.off('.vigSpark');
4178 $wrap.on('mouseenter.vigSpark focus.vigSpark', '.vigilante-analyzer-sparkline-dot', function() {
4179 var $dot = $(this);
4180 $tooltip
4181 .text($dot.attr('data-score'))
4182 .css({ left: $dot.css('left'), top: $dot.css('top') })
4183 .addClass('is-visible');
4184 });
4185 $wrap.on('mouseleave.vigSpark blur.vigSpark', '.vigilante-analyzer-sparkline-dot', function() {
4186 $tooltip.removeClass('is-visible');
4187 });
4188 },
4189
4190 buildSparklineSvg: function(points) {
4191 var width = 280;
4192 var height = 60;
4193 var padding = 4;
4194 var usableW = width - (padding * 2);
4195 var usableH = height - (padding * 2);
4196 var count = points.length;
4197 var step = usableW / Math.max(1, count - 1);
4198 var max = 100;
4199
4200 var coords = [];
4201 for (var i = 0; i < count; i++) {
4202 var x = padding + (i * step);
4203 var y = padding + (usableH - ((points[i] / max) * usableH));
4204 coords.push(x.toFixed(2) + ',' + y.toFixed(2));
4205 }
4206 var path = 'M ' + coords.join(' L ');
4207 var lastX = padding + ((count - 1) * step);
4208 var lastY = padding + (usableH - ((points[count - 1] / max) * usableH));
4209
4210 return '<svg viewBox="0 0 ' + width + ' ' + height + '" preserveAspectRatio="none" '
4211 + 'role="img" focusable="false">'
4212 + '<path d="' + path + '" fill="none" stroke="currentColor" '
4213 + 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'
4214 + '<circle cx="' + lastX.toFixed(2) + '" cy="' + lastY.toFixed(2) + '" r="3" fill="currentColor"/>'
4215 + '</svg>';
4216 },
4217
4218 /**
4219 * When landing on a fix link (#field-*, #vigilante-section-* or any
4220 * other #vigilante-* target), scroll smoothly and flash-highlight it.
4221 *
4222 * The pattern used to name the two families literally, which left the
4223 * one anchor that follows neither out in the cold: the weekly report
4224 * email links to #vigilante-analyzer, so that link landed on the
4225 * dashboard with no scroll and no flash. Widened in 2.11.1 to the
4226 * whole vigilante- prefix, which is still narrow enough that a hash
4227 * written by anything else on the page cannot reach the selector.
4228 */
4229 hashFocusFlash: function() {
4230 var hash = window.location.hash;
4231 if (!hash || hash.length < 2) {
4232 return;
4233 }
4234 // Accept field anchors and every anchor the plugin owns.
4235 if (!/^#(?:field-|vigilante-)/.test(hash)) {
4236 return;
4237 }
4238
4239 setTimeout(function() {
4240 var $target;
4241 try {
4242 $target = $(hash);
4243 } catch (err) { return; }
4244 if (!$target || !$target.length) {
4245 return;
4246 }
4247
4248 // Scroll to it smoothly.
4249 if ('scrollIntoView' in $target[0]) {
4250 $target[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
4251 }
4252
4253 // Flash — remove any previous flash first.
4254 $('.vigilante-focus-flash').removeClass('vigilante-focus-flash');
4255 $target.addClass('vigilante-focus-flash');
4256 setTimeout(function() {
4257 $target.removeClass('vigilante-focus-flash');
4258 }, 1800);
4259
4260 // And once more when everything has loaded: anything that
4261 // changes height above the target (images, notices, the admin
4262 // bar on a narrow screen) moves it after the first scroll, and
4263 // then the link looks like it only opened the tab.
4264 $(window).one('load', function() {
4265 if ($target[0] && 'scrollIntoView' in $target[0]) {
4266 $target[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
4267 }
4268 });
4269 }, 150);
4270 },
4271
4272 /** Escape text for HTML. */
4273 esc: function(str) {
4274 return String(str == null ? '' : str)
4275 .replace(/&/g, '&amp;')
4276 .replace(/</g, '&lt;')
4277 .replace(/>/g, '&gt;')
4278 .replace(/"/g, '&quot;')
4279 .replace(/'/g, '&#039;');
4280 },
4281
4282 /** Escape text for HTML attribute. */
4283 escAttr: function(str) {
4284 return this.esc(str);
4285 }
4286 };
4287
4288 // Initialize on document ready
4289 $(document).ready(function() {
4290 Vigilante_Admin.init();
4291 Vigilante_Admin.initPasswordReset();
4292 Vigilante_Admin.initUserApproval();
4293 Vigilante_Admin.initSessionManagement();
4294 VigilanteAnalyzer.init();
4295
4296 // Shared "Send test email" button (Notification settings, File Integrity, Audit Alerts).
4297 $(document).on('click', '.vigilante-test-email-btn', function(e) {
4298 e.preventDefault();
4299 var $btn = $(this);
4300 var $result = $btn.siblings('.vigilante-test-email-result');
4301 var original = $btn.data('original-text') || $btn.text();
4302 $btn.prop('disabled', true).text(vigilanteAdmin.strings.sendingTest || 'Sending...');
4303 $result.text('').css('color', '');
4304 $.ajax({
4305 url: vigilanteAdmin.ajaxUrl,
4306 type: 'POST',
4307 data: {
4308 action: 'vigilante_send_test_email',
4309 nonce: vigilanteAdmin.nonce
4310 },
4311 success: function(response) {
4312 if (response && response.success) {
4313 $result.css('color', '#00a32a').text((response.data && response.data.message) || '');
4314 } else {
4315 $result.css('color', '#d63638').text((response && response.data && response.data.message) || vigilanteAdmin.strings.error);
4316 }
4317 },
4318 error: function() {
4319 $result.css('color', '#d63638').text(vigilanteAdmin.strings.error);
4320 },
4321 complete: function() {
4322 $btn.prop('disabled', false).text(original);
4323 }
4324 });
4325 });
4326 });
4327
4328 /**
4329 * Security Headers settings recovery (2.10.0).
4330 *
4331 * Both buttons are disabled together while the request is in flight, so a
4332 * second click cannot fire a second restore. The page reloads afterwards
4333 * because the whole tab is rendered from the settings that just changed.
4334 */
4335 $(function() {
4336 var SELECTOR = '#vigilante-recovery-restore, #vigilante-recovery-dismiss';
4337
4338 function recoveryRun(action) {
4339 $(SELECTOR).prop('disabled', true);
4340
4341 $.ajax({
4342 url: vigilanteAdmin.ajaxUrl,
4343 type: 'POST',
4344 data: {
4345 action: action,
4346 nonce: vigilanteAdmin.nonce
4347 },
4348 success: function(response) {
4349 if (response.success) {
4350 Vigilante_Admin.showNotice('success', response.data);
4351 setTimeout(function() {
4352 location.reload();
4353 }, 1200);
4354 return;
4355 }
4356
4357 Vigilante_Admin.showNotice('error', response.data);
4358 $(SELECTOR).prop('disabled', false);
4359 },
4360 error: function() {
4361 Vigilante_Admin.showNotice('error', vigilanteAdmin.strings.error);
4362 $(SELECTOR).prop('disabled', false);
4363 }
4364 });
4365 }
4366
4367 $(document).on('click', '#vigilante-recovery-restore', function(e) {
4368 e.preventDefault();
4369 recoveryRun('vigilante_headers_recovery_restore');
4370 });
4371
4372 $(document).on('click', '#vigilante-recovery-dismiss', function(e) {
4373 e.preventDefault();
4374 recoveryRun('vigilante_headers_recovery_dismiss');
4375 });
4376
4377 $(document).on('click', '#vigilante-recovery-undo', function(e) {
4378 e.preventDefault();
4379 $(this).prop('disabled', true);
4380 recoveryRun('vigilante_headers_recovery_undo');
4381 });
4382 });
4383
4384 })(jQuery);