PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.2
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.2
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.2, at assets/js/admin.js

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