PluginProbe ʕ •ᴥ•ʔ
WP 2FA – Two-factor authentication for WordPress / 4.0.0
WP 2FA – Two-factor authentication for WordPress v4.0.0
4.0.0 1.7.1 2.0.0 2.0.1 2.1.0 2.2.0 2.2.1 2.3.0 2.4.0 2.4.1 2.4.2 2.5.0 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.7.0 2.8.0 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0 3.0.1 3.1.0 3.1.1 3.1.1.2 trunk 1.2.0 1.3.0 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.2 1.6.0 1.6.1 1.6.2 1.7.0
wp-2fa / includes / classes / Admin / AdminSettings / class-settings-builder.php
wp-2fa / includes / classes / Admin / AdminSettings Last commit date
class-settings-builder.php 1 day ago index.php 1 day ago
class-settings-builder.php
2266 lines
1 <?php
2 /**
3 * Class: Determine the context in which the plugin is executed.
4 *
5 * Helper class to determine the proper status of the request.
6 *
7 * @package wp-2fa
8 *
9 * @since 4.0.0
10 */
11
12 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
13
14 declare(strict_types=1);
15
16 namespace WP2FA\Admin;
17
18 use WP2FA\Utils\Settings_Utils;
19
20 // Exit if accessed directly.
21 if ( ! defined( 'ABSPATH' ) ) {
22 exit;
23 }
24
25 /**
26 * Upgrade notice class
27 */
28 if ( ! class_exists( '\WP2FA\Admin\Settings_Builder' ) ) {
29 /**
30 * Utility class for showing the upgrade notice in the plugins page.
31 *
32 * @package wp-2fa
33 *
34 * @since 4.0.0
35 */
36 class Settings_Builder {
37 /**
38 * Whether the multi-select-ajax CSS/JS assets have been output.
39 *
40 * @var bool
41 *
42 * @since 4.0.0
43 */
44 private static $multi_select_ajax_rendered = false;
45
46 /**
47 * The inner item id of the setting
48 *
49 * @var string
50 *
51 * @since 4.0.0
52 */
53 public static $item_id;
54
55 /**
56 * Additional attributes of the item.
57 *
58 * @var string
59 *
60 * @since 4.0.0
61 */
62 public static $item_id_attr;
63
64 /**
65 * Item wrapper - the id of the setting + '_item' suffix
66 *
67 * @var string
68 *
69 * @since 4.0.0
70 */
71 public static $item_id_wrap;
72
73 /**
74 * The name attribute of the element
75 *
76 * @var string
77 *
78 * @since 4.0.0
79 */
80 public static $name_attr;
81
82 /**
83 * Placeholder for the setting
84 *
85 * @var string
86 *
87 * @since 4.0.0
88 */
89 public static $placeholder_attr;
90
91 /**
92 * Custom class for the setting
93 *
94 * @var string
95 *
96 * @since 4.0.0
97 */
98 public static $custom_class;
99
100 /**
101 * The setting value
102 *
103 * @var mixed
104 *
105 * @since 4.0.0
106 */
107 public static $current_value;
108
109 /**
110 * The type of the setting
111 *
112 * @var string
113 *
114 * @since 4.0.0
115 */
116 public static $option_type;
117
118 /**
119 * The name of the setting
120 *
121 * @var string
122 *
123 * @since 4.0.0
124 */
125 public static $option_name;
126
127 /**
128 * Array with the setting settings
129 *
130 * @var array
131 *
132 * @since 4.0.0
133 */
134 public static $settings;
135
136 /**
137 * Array with the data attributes settings
138 *
139 * @var array
140 *
141 * @since 4.0.0
142 */
143 public static $data_attrs;
144
145 /**
146 * Integer - minimum value
147 *
148 * @var int
149 *
150 * @since 4.0.0
151 */
152 public static $min;
153
154 /**
155 * Integer - maximum value
156 *
157 * @var int
158 *
159 * @since 4.0.0
160 */
161 public static $max;
162
163 /**
164 * Integer - step value
165 *
166 * @var int
167 *
168 * @since 4.0.0
169 */
170 public static $step;
171
172 /**
173 * Holds the type of the edit HTML field
174 *
175 * @var string
176 *
177 * @since 4.0.0
178 */
179 public static $edit_type;
180
181 /**
182 * Holds the validation pattern for the text field
183 *
184 * @var string
185 *
186 * @since 4.0.0
187 */
188 public static $validate_pattern;
189
190 /**
191 * Holds the maximum characters for the text field
192 *
193 * @var string
194 *
195 * @since 4.0.0
196 */
197 public static $max_chars;
198
199 /**
200 * Holds the title attribute for the text field
201 *
202 * @var string
203 *
204 * @since 4.0.0
205 */
206 public static $title_attr;
207
208 /**
209 * The given field is required
210 *
211 * @var string
212 *
213 * @since 4.0.0
214 */
215 public static $required;
216
217 /**
218 * If the value is not a boolean, for checkboxes.
219 *
220 * @var string
221 *
222 * @since 4.0.0
223 */
224 public static $not_bool;
225
226 /**
227 * Builds the element
228 *
229 * @param array $settings - Array with settings.
230 * @param mixed $data - The data to show.
231 *
232 * @return void
233 *
234 * @since 4.0.0
235 */
236 public static function create( $settings, $data ) {
237
238 self::$item_id = null;
239 self::$item_id_attr = null;
240 self::$item_id_wrap = null;
241 self::$name_attr = null;
242 self::$placeholder_attr = null;
243 self::$custom_class = null;
244 self::$current_value = null;
245 self::$option_type = null;
246 self::$option_name = null;
247 self::$settings = null;
248 self::$edit_type = null;
249 self::$validate_pattern = null;
250 self::$max_chars = null;
251 self::$min = null;
252 self::$max = null;
253 self::$step = null;
254 self::$data_attrs = null;
255 self::$not_bool = null;
256
257 self::prepare_data( $settings, $data );
258
259 if ( empty( self::$option_type ) ) {
260 return;
261 }
262
263 // Options Without Labels.
264 $with_label = false;
265
266 switch ( self::$option_type ) {
267 case 'simple-text':
268 self::simple_text();
269 break;
270
271 case 'number':
272 self::number();
273 break;
274
275 case 'text':
276 self::text();
277 break;
278
279 case 'secret':
280 self::secret();
281 break;
282
283 case 'button':
284 self::button();
285 break;
286
287 case 'tab-title':
288 self::tab_title();
289 break;
290
291 case 'section-title':
292 self::section_title();
293 break;
294
295 case 'section-sub-title':
296 self::section_sub_title();
297 break;
298
299 case 'list':
300 self::list();
301 break;
302
303 case 'radio':
304 self::radio();
305 break;
306
307 case 'group-title':
308 self::group_title();
309 break;
310
311 case 'description':
312 self::description();
313 break;
314
315 case 'checkbox':
316 self::checkbox();
317 break;
318
319 case 'settings-label':
320 self::settings_label();
321 break;
322
323 case 'tip':
324 self::tip();
325 break;
326
327 case 'editor':
328 self::editor();
329 break;
330
331 case 'textarea':
332 self::textarea();
333 break;
334
335 case 'color':
336 self::color();
337 break;
338
339 case 'image-select':
340 self::image_select();
341 break;
342
343 case 'font-select':
344 self::font_select();
345 break;
346
347 case 'tabbed-navigation':
348 self::tabbed_navigation();
349 break;
350
351 case 'toggle-checkbox':
352 self::toggle_checkbox();
353 break;
354
355 case 'user-roles':
356 self::user_roles();
357 break;
358
359 case 'stat-cards':
360 self::stat_cards();
361 break;
362
363 case 'data-table':
364 self::data_table();
365 break;
366
367 case 'multi-select-ajax':
368 self::multi_select_ajax();
369 break;
370
371 case 'sortable-list':
372 self::sortable_list();
373 break;
374
375 case 'select':
376 self::select();
377 break;
378
379 case 'url-prefixed-text':
380 self::url_prefixed_text();
381 break;
382
383 case 'breadcrumb':
384 self::breadcrumb();
385 break;
386 }
387
388 self::hint();
389 }
390
391 /**
392 * Textarea
393 *
394 * @since 4.0.0
395 */
396 private static function textarea() {
397 ?>
398 <div class="body-editor-inner">
399 <div class="editor-area">
400 <?php
401 $textarea_value = self::$current_value;
402 if ( \is_array( $textarea_value ) ) {
403 $textarea_value = \implode( "\n", $textarea_value );
404 }
405 ?>
406 <textarea style="width: 100%;" <?php echo self::$item_id_attr; ?> <?php echo self::$name_attr; ?>
407 rows="5"><?php echo \esc_textarea( (string) $textarea_value ); ?></textarea>
408 </div><!-- /.editor-area -->
409 <?php
410 if ( ! empty( self::$settings['legend'] ) ) {
411 ?>
412 <div class="token-list" aria-label="Available tokens">
413 <div class="token-row">
414 <?php
415 foreach ( self::$settings['legend'] as $item ) {
416 ?>
417 <span class="token-badge"><?php echo esc_html( $item ); ?></span>
418 <?php
419 }
420 ?>
421 </div>
422 </div>
423 <?php
424 }
425 ?>
426 </div>
427 <?php
428 }
429
430 /**
431 * Tabbed Navigation
432 *
433 * @since 4.0.0
434 */
435 private static function tabbed_navigation() {
436 $styles = '';
437 $labels = '';
438 $radios = '';
439
440 $lock_icon = '<svg class="wp2fa-tab-lock-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">'
441 . '<path d="M4.66699 6.41667V4.08333C4.66699 3.46449 4.91282 2.871 5.35041 2.43342C5.78799 1.99583 6.38149 1.75 7.00033 1.75C7.61916 1.75 8.21266 1.99583 8.65024 2.43342C9.08783 2.871 9.33366 3.46449 9.33366 4.08333V6.41667M2.91699 7.58333C2.91699 7.27391 3.03991 6.97717 3.2587 6.75838C3.47749 6.53958 3.77424 6.41667 4.08366 6.41667H9.91699C10.2264 6.41667 10.5232 6.53958 10.742 6.75838C10.9607 6.97717 11.0837 7.27391 11.0837 7.58333V11.0833C11.0837 11.3928 10.9607 11.6895 10.742 11.9083C10.5232 12.1271 10.2264 12.25 9.91699 12.25H4.08366C3.77424 12.25 3.47749 12.1271 3.2587 11.9083C3.03991 11.6895 2.91699 11.3928 2.91699 11.0833V7.58333ZM6.41699 9.33333C6.41699 9.48804 6.47845 9.63642 6.58785 9.74581C6.69724 9.85521 6.84562 9.91667 7.00033 9.91667C7.15504 9.91667 7.30341 9.85521 7.4128 9.74581C7.5222 9.63642 7.58366 9.48804 7.58366 9.33333C7.58366 9.17862 7.5222 9.03025 7.4128 8.92085C7.30341 8.81146 7.15504 8.75 7.00033 8.75C6.84562 8.75 6.69724 8.81146 6.58785 8.92085C6.47845 9.03025 6.41699 9.17862 6.41699 9.33333Z" stroke="#646970" stroke-width="1.3125" stroke-linecap="round" stroke-linejoin="round"/>'
442 . '</svg>';
443
444 $i = 0;
445 foreach ( self::$current_value as $value ) {
446 $is_locked = ! empty( $value['locked'] );
447
448 if ( $is_locked ) {
449 $context_attr = '';
450 if ( ! empty( $value['premium_context'] ) ) {
451 $context_attr = ' data-wp2fa-premium-context="' . \esc_attr( $value['premium_context'] ) . '"';
452 }
453 $labels .= '<span class="tab-label tab-label--locked wp2fa-premium-badge" role="button" tabindex="0"' . $context_attr . '>' . \esc_html( $value['title'] ) . ' ' . $lock_icon . '</span>';
454 } else {
455 ++$i;
456 $checked = ( 1 === $i ) ? ' checked' : '';
457 $styles .= '#tab-' . $value['id'] . ':checked ~ .tab-panel-' . $value['id'] . ' { display: block; } ';
458 $styles .= '#tab-' . $value['id'] . ':checked ~ .tab-nav .tab-label[for="tab-' . $value['id'] . '"] { color: var(--blue-color); border-bottom-color: var(--blue-color); } ';
459 $labels .= '<label class="tab-label" for="tab-' . $value['id'] . '">' . \esc_html( $value['title'] ) . '</label>';
460 $radios .= '<input class="tab-radio" type="radio" id="tab-' . $value['id'] . '" name="' . self::$option_name . '"' . $checked . '>';
461 }
462 }
463 ?>
464 <style>
465 <?php echo $styles; ?>
466 .tab-label--locked {
467 cursor: pointer;
468 opacity: 0.7;
469 display: inline-flex;
470 align-items: center;
471 gap: 4px;
472 }
473 .wp2fa-tab-lock-icon {
474 flex-shrink: 0;
475 }
476 </style>
477 <?php echo $radios; ?>
478 <nav class="tab-nav" aria-label="Template sections">
479 <?php echo $labels; ?>
480 </nav>
481
482 <?php
483 }
484
485 /**
486 * Editor
487 *
488 * @since 4.0.0
489 */
490 private static function editor() {
491 ?>
492 <div class="body-editor-inner">
493 <?php
494
495 // Settings.
496 $settings = ! empty( self::$settings['editor'] ) ? self::$settings['editor'] : array(
497 'editor_height' => '320',
498 'media_buttons' => false,
499 );
500 $settings['textarea_name'] = self::$option_name;
501
502 self::$current_value = ! empty( self::$settings['kses'] ) ? wp_kses_stripslashes( stripslashes( self::$current_value ) ) : self::$current_value;
503
504 if ( null === self::$current_value ) {
505 self::$current_value = '';
506 }
507
508 ?>
509 <!-- Editor (Visual / Code) -->
510 <div class="editor-area">
511 <?php
512
513 \wp_editor(
514 self::$current_value,
515 self::$item_id,
516 $settings
517 );
518 ?>
519 </div><!-- /.editor-area -->
520 <?php
521 if ( ! empty( self::$settings['legend'] ) ) {
522 ?>
523 <div class="token-list" aria-label="Available tokens">
524 <div class="token-row">
525 <?php
526 foreach ( self::$settings['legend'] as $item ) {
527 ?>
528 <span class="token-badge"><?php echo esc_html( $item ); ?></span>
529 <?php
530 }
531 ?>
532 </div>
533 </div>
534 <?php
535 }
536 ?>
537 </div>
538 <?php
539 }
540
541 /**
542 * Tip Message
543 *
544 * @since 4.0.0
545 */
546 private static function tip() {
547 if ( ! empty( self::$settings['text'] ) ) {
548 ?>
549 <div class="notice-tip">
550 <div class="notice-tip-body">
551 <strong><?php echo \esc_html__( 'Tip', 'wp-2fa' ); ?></strong>
552 <?php echo self::$settings['text']; ?>
553 </div>
554 </div>
555 <?php
556 }
557 }
558
559 /**
560 * Setting Description
561 *
562 * @since 4.0.0
563 */
564 private static function hint() {
565
566 if ( ! empty( self::$settings['hint'] ) ) {
567 ?>
568 <span class="extra-text">
569 <?php echo self::$settings['hint']; ?>
570 </span>
571 <?php
572 }
573 }
574
575 /**
576 * Button
577 *
578 * @since 4.0.0
579 */
580 private static function button() {
581 $type_attr = 'type="button"';
582 $pattern = '';
583 $step = '';
584 $max_chars = '';
585 if ( ! empty( self::$edit_type ) ) {
586 $type_attr = ' type="' . self::$edit_type . '"';
587 }
588 if ( ! empty( self::$validate_pattern ) ) {
589 $pattern = ' pattern="' . self::$validate_pattern . '"';
590 }
591 if ( ! empty( self::$step ) ) {
592 $step = ' step="' . self::$step . '"';
593 }
594 if ( ! empty( self::$max_chars ) ) {
595 $max_chars = ' maxlength="' . self::$max_chars . '"';
596 }
597
598 if ( self::$data_attrs && is_array( self::$data_attrs ) && ! empty( self::$data_attrs ) ) {
599 foreach ( self::$data_attrs as $data_attr_key => $data_attr_value ) {
600 $type_attr .= ' data-' . $data_attr_key . '="' . \esc_attr( $data_attr_value ) . '"';
601 }
602 }
603 ?>
604 <input <?php echo self::$item_id_attr; ?> class="<?php echo self::$custom_class; ?>" <?php echo self::$name_attr; ?> <?php echo $type_attr; ?> value="<?php echo \esc_attr( self::$current_value ); ?>" <?php echo self::$placeholder_attr; ?><?php echo $pattern; ?><?php echo $max_chars; ?><?php echo ( ( self::$required ) ? ' required' : '' ); ?><?php echo $step; ?>>
605 <?php
606 }
607
608 /**
609 * Radio
610 *
611 * @since 4.0.0
612 */
613 private static function radio() {
614
615 ?>
616 <div class="settings-control <?php echo self::$custom_class; ?>" <?php echo self::$item_id_wrap; ?>>
617 <?php
618 $i = 0;
619 foreach ( self::$settings['options'] as $option_key => $option ) {
620 ++$i;
621
622 $checked = '';
623 if ( ( ! empty( self::$current_value ) && self::$current_value === $option_key ) || ( empty( self::$current_value ) && 1 === $i ) ) {
624 $checked = 'checked="checked"';
625 }
626
627 ?>
628 <label class="radio-option">
629 <input <?php echo self::$name_attr; ?> <?php echo $checked; ?> type="radio" value="<?php echo $option_key; ?>">
630 <?php echo $option; ?>
631 </label>
632 <?php
633 }
634
635 ?>
636 </div>
637 <div class="clear"></div>
638
639 <?php
640 if ( empty( self::$settings['toggle'] ) ) {
641 return;
642 }
643 ?>
644
645 <script>
646 document.addEventListener('DOMContentLoaded', function() {
647 // Hide all option elements
648 if ( document.querySelectorAll('.<?php echo esc_js( self::$item_id ); ?>-options') ) {
649 document.querySelectorAll('.<?php echo esc_js( self::$item_id ); ?>-options').forEach(function(el) {
650 el.style.display = 'none';
651 });
652 }
653
654 <?php
655 if ( isset( self::$settings['toggle'][ self::$current_value ] ) ) {
656 if ( ! empty( self::$settings['toggle'][ self::$current_value ] ) ) {
657 ?>
658 document.querySelector('<?php echo esc_js( self::$settings['toggle'][ self::$current_value ] ); ?>').style.display = 'block';
659 <?php
660 }
661 } elseif ( is_array( self::$settings['toggle'] ) ) {
662 $first_elem = reset( self::$settings['toggle'] );
663 ?>
664 if ( '<?php echo esc_js( $first_elem ); ?>'.trim() && document.querySelector('<?php echo esc_js( $first_elem ); ?>') ) {
665 document.querySelector('<?php echo esc_js( $first_elem ); ?>').style.display = 'block';
666 }
667 <?php
668 }
669 ?>
670
671 document.querySelectorAll("input[name='<?php echo esc_js( self::$option_name ); ?>']").forEach(function(radio) {
672 radio.addEventListener('change', function() {
673 var selected_val = this.value;
674 if ( document.querySelectorAll('.<?php echo esc_js( self::$item_id ); ?>-options') ) {
675 document.querySelectorAll('.<?php echo esc_js( self::$item_id ); ?>-options').forEach(function(el) {
676 el.style.display = 'none';
677 });
678 }
679
680 <?php
681 foreach ( self::$settings['toggle'] as $tg_item_name => $tg_item_id ) {
682 if ( ! empty( $tg_item_id ) ) {
683 ?>
684 if (selected_val === '<?php echo esc_js( $tg_item_name ); ?>') {
685 document.querySelector('<?php echo esc_js( $tg_item_id ); ?>').style.display = 'block';
686 } else {
687 document.querySelector('<?php echo esc_js( $tg_item_id ); ?>').style.display = 'none';
688 }
689 <?php
690 }
691 }
692 ?>
693 });
694 });
695 });
696 </script>
697 <?php
698 }
699
700 /**
701 * Section Title
702 *
703 * @since 4.0.0
704 */
705 private static function section_sub_title() {
706 ?>
707 <h4 class="section-sub-title <?php echo self::$custom_class; ?>" <?php echo self::$item_id_attr; ?>><?php echo \esc_html( self::$settings['title'] ); ?></h4>
708 <?php
709 }
710
711 /**
712 * Section Title
713 *
714 * @since 4.0.0
715 */
716 private static function section_title() {
717 ?>
718 <h3 class="section-title <?php echo self::$custom_class; ?>" <?php echo self::$item_id_attr; ?>><?php echo \esc_html( self::$settings['title'] ); ?></h3>
719 <?php
720 }
721
722 /**
723 * Settings Label
724 *
725 * @since 4.0.0
726 */
727 private static function settings_label() {
728 ?>
729 <div class="settings-label"><?php echo self::$settings['text']; ?></div>
730 <?php
731 }
732
733 /**
734 * Checkbox
735 *
736 * @since 4.0.0
737 */
738 private static function checkbox() {
739
740 /**
741 * Old logic is messy
742 * The 'not_bool' setting is used when the value of the checkbox is not a boolean, but a specific value (e.g. 'yes' or 'enabled'), and the checkbox should be checked if the current value matches that specific value. In this case, the 'value' setting specifies the value that should be submitted when the checkbox is checked.
743 */
744 if ( self::$not_bool ) {
745 /**
746 * For what is worse - some of them have values == element ids, but someones do not.
747 * This solves the case - once not_bool is set, if the 'not_id' is present, it decides the state comparing it to the value, if not - to the element ID.
748 */
749 if ( isset( self::$settings['not_id'] ) && self::$settings['not_id'] ) {
750 $checked = \checked( self::$current_value, ( self::$settings['value'] ) ? self::$settings['value'] : '', false );
751 } else {
752 $checked = \checked( self::$current_value, self::$item_id, false );
753 }
754
755 $value = self::$settings['value'] ?? '';
756
757 if ( $value ) {
758 self::$name_attr .= ' value="' . $value . '"';
759 }
760 } else {
761
762 $checked = \checked( Settings_Utils::string_to_bool( self::$current_value ), true, false );
763 }
764 ?>
765
766 <?php $disabled_attr = ! empty( self::$settings['disabled'] ) ? ' disabled' : ''; ?>
767 <label class="checkbox-option <?php echo self::$custom_class; ?><?php echo $disabled_attr; ?>"><input type="checkbox" <?php echo self::$item_id_attr; ?> <?php echo self::$name_attr; ?> <?php echo $checked; ?>><?php echo self::$settings['text']; ?></label>
768
769 <?php
770 }
771
772 /**
773 * Simple Text
774 *
775 * @since 4.0.0
776 */
777 private static function simple_text() {
778 ?>
779 <span <?php echo self::$item_id_wrap; ?> class="<?php echo self::$custom_class; ?>">
780 <?php echo self::$settings['text']; ?>
781 </span>
782 <?php
783 }
784
785 /**
786 * Text
787 *
788 * @since 4.0.0
789 */
790 private static function text() {
791 $type_attr = 'type="text"';
792 $pattern = '';
793 $step = '';
794 $max_chars = '';
795 $title_attr = '';
796
797 if ( ! empty( self::$edit_type ) ) {
798 $type_attr = ' type="' . self::$edit_type . '"';
799 }
800 if ( ! empty( self::$title_attr ) ) {
801 $title_attr = ' title="' . self::$title_attr . '"';
802 }
803 if ( ! empty( self::$validate_pattern ) ) {
804 $pattern = ' pattern="' . self::$validate_pattern . '"';
805 }
806 if ( ! empty( self::$step ) ) {
807 $step = ' step="' . self::$step . '"';
808 }
809 if ( ! empty( self::$max_chars ) ) {
810 $max_chars = ' maxlength="' . self::$max_chars . '"';
811 }
812
813 if ( 'password' === self::$edit_type ) {
814 $type_attr .= ' autocomplete="new-password"';
815 }
816
817 if ( self::$data_attrs && is_array( self::$data_attrs ) && ! empty( self::$data_attrs ) ) {
818 foreach ( self::$data_attrs as $data_attr_key => $data_attr_value ) {
819 $type_attr .= ' data-' . $data_attr_key . '="' . \esc_attr( $data_attr_value ) . '"';
820 }
821 }
822 ?>
823 <input <?php echo self::$item_id_attr; ?> class="<?php echo self::$custom_class; ?>" <?php echo $title_attr; ?> <?php echo self::$name_attr; ?> <?php echo $type_attr; ?> value="<?php echo \esc_attr( self::$current_value ); ?>" <?php echo self::$placeholder_attr; ?><?php echo $pattern; ?><?php echo $max_chars; ?><?php echo ( ( self::$required ) ? ' required' : '' ); ?><?php echo $step; ?>>
824 <?php
825 }
826
827 /**
828 * Secret - masked text input with eye toggle
829 *
830 * @since 3.2.1
831 */
832 private static function secret() {
833 $pattern = '';
834 $step = '';
835 $max_chars = '';
836
837 if ( ! empty( self::$validate_pattern ) ) {
838 $pattern = ' pattern="' . self::$validate_pattern . '"';
839 }
840 if ( ! empty( self::$step ) ) {
841 $step = ' step="' . self::$step . '"';
842 }
843 if ( ! empty( self::$max_chars ) ) {
844 $max_chars = ' maxlength="' . self::$max_chars . '"';
845 }
846
847 $data_attr_string = '';
848 if ( self::$data_attrs && is_array( self::$data_attrs ) && ! empty( self::$data_attrs ) ) {
849 foreach ( self::$data_attrs as $data_attr_key => $data_attr_value ) {
850 $data_attr_string .= ' data-' . $data_attr_key . '="' . \esc_attr( $data_attr_value ) . '"';
851 }
852 }
853 ?>
854 <span class="wp2fa-secret-field-wrap">
855 <input <?php echo self::$item_id_attr; ?> class="<?php echo self::$custom_class; ?> wp2fa-secret-field" <?php echo self::$name_attr; ?> type="text" autocomplete="off" value="<?php echo \esc_attr( self::$current_value ); ?>" <?php echo self::$placeholder_attr; ?><?php echo $pattern; ?><?php echo $max_chars; ?><?php echo ( ( self::$required ) ? ' required' : '' ); ?><?php echo $step; ?><?php echo $data_attr_string; ?>>
856 <button type="button" class="wp2fa-secret-toggle" aria-label="<?php \esc_attr_e( 'Show', 'wp-2fa' ); ?>">
857 <svg viewBox="0 0 24 24"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zm0 12.5c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
858 </button>
859 </span>
860 <?php
861 }
862
863 /**
864 * Description Message
865 *
866 * @since 4.0.0
867 */
868 private static function description() {
869 ?>
870 <p <?php echo self::$item_id_wrap; ?> class="description <?php echo self::$custom_class; ?>">
871 <?php echo self::$settings['text']; ?>
872 </p>
873 <?php
874 }
875
876 /**
877 * Color Picker - uses WP core wp-color-picker
878 *
879 * @since 4.0.0
880 */
881 private static function color() {
882 ?>
883 <div class="wp2fa-color-picker-wrap">
884 <input <?php echo self::$item_id_attr; ?> <?php echo self::$name_attr; ?>
885 type="text"
886 class="wp2fa-color-picker<?php echo self::$custom_class; ?>"
887 value="<?php echo \esc_attr( self::$current_value ); ?>">
888 </div>
889 <?php
890 }
891
892 /**
893 * Image / Logo selector - uses WP core media library
894 *
895 * Expected settings keys:
896 * 'button_text' (string) - Label on the select button.
897 * 'description' (string) - Helper text shown next to the button.
898 *
899 * @since 4.0.0
900 */
901 private static function image_select() {
902 $button_text = ! empty( self::$settings['button_text'] ) ? self::$settings['button_text'] : \esc_html__( 'Select image', 'wp-2fa' );
903 $description = ! empty( self::$settings['description'] ) ? self::$settings['description'] : '';
904 $has_image = ! empty( self::$current_value );
905 ?>
906 <div class="wp2fa-image-select-wrap">
907 <div class="wp2fa-image-select-controls">
908 <button type="button"
909 class="button wp2fa-media-upload<?php echo self::$custom_class; ?>"
910 data-target="<?php echo \esc_attr( self::$item_id ); ?>">
911 <?php echo \esc_html( $button_text ); ?>
912 </button>
913 <?php if ( $description ) : ?>
914 <span class="wp2fa-image-desc"><?php echo \esc_html( $description ); ?></span>
915 <?php endif; ?>
916 </div>
917 <input <?php echo self::$item_id_attr; ?> <?php echo self::$name_attr; ?>
918 type="hidden"
919 value="<?php echo \esc_attr( self::$current_value ); ?>">
920 <div class="wp2fa-image-preview<?php echo $has_image ? '' : ' hidden'; ?>">
921 <img src="<?php echo $has_image ? \esc_url( self::$current_value ) : ''; ?>" alt="">
922 <button type="button" class="wp2fa-media-remove"
923 title="<?php \esc_attr_e( 'Remove', 'wp-2fa' ); ?>">&#x2715;</button>
924 </div>
925 </div>
926 <?php
927 }
928
929 /**
930 * Font selector - a styled <select> with common web-safe font stacks.
931 * The stored value is the full CSS font-family string (e.g. "Arial, sans-serif").
932 *
933 * @since 4.0.0
934 */
935 private static function font_select() {
936 $font_families = array(
937 'Arial, Helvetica, sans-serif' => 'Arial',
938 "'Arial Black', Gadget, sans-serif" => 'Arial Black',
939 "'Bookman Old Style', serif" => 'Bookman Old Style',
940 "'Comic Sans MS', cursive" => 'Comic Sans MS',
941 'Courier, monospace' => 'Courier',
942 'Garamond, serif' => 'Garamond',
943 'Georgia, serif' => 'Georgia',
944 'Impact, Charcoal, sans-serif' => 'Impact',
945 "'Lucida Console', Monaco, monospace" => 'Lucida Console',
946 "'Lucida Sans Unicode', 'Lucida Grande', sans-serif" => 'Lucida Sans',
947 "'MS Sans Serif', Geneva, sans-serif" => 'MS Sans Serif',
948 "'MS Serif', 'New York', sans-serif" => 'MS Serif',
949 "'Palatino Linotype', 'Book Antiqua', Palatino, serif" => 'Palatino Linotype',
950 'Tahoma, Geneva, sans-serif' => 'Tahoma',
951 "'Times New Roman', Times, serif" => 'Times New Roman',
952 "'Trebuchet MS', Helvetica, sans-serif" => 'Trebuchet MS',
953 'Verdana, Geneva, sans-serif' => 'Verdana',
954 );
955 ?>
956 <div class="wp2fa-font-select-wrap">
957 <select <?php echo self::$item_id_attr; ?> <?php echo self::$name_attr; ?>
958 class="wp2fa-font-select<?php echo self::$custom_class; ?>">
959 <?php
960 foreach ( $font_families as $value => $label ) {
961 $selected = \selected( self::$current_value, $value, false );
962 ?>
963 <option value="<?php echo \esc_attr( $value ); ?>" <?php echo $selected; ?>
964 style="font-family: <?php echo \esc_attr( $value ); ?>;">
965 <?php echo \esc_html( $label ); ?>
966 </option>
967 <?php
968 }
969 ?>
970 </select>
971 </div>
972 <?php
973 }
974
975 /**
976 * Toggle Checkbox - renders a toggle switch that behaves like a checkbox.
977 *
978 * @since 4.0.0
979 */
980 private static function toggle_checkbox() {
981
982 if ( self::$not_bool ) {
983 if ( isset( self::$settings['not_id'] ) && self::$settings['not_id'] ) {
984 $checked = \checked( self::$current_value, ( isset( self::$settings['value'] ) ? self::$settings['value'] : '' ), false );
985 } else {
986 $checked = \checked( self::$current_value, self::$item_id, false );
987 }
988 $value = self::$settings['value'] ?? '';
989
990 if ( $value ) {
991 self::$name_attr .= ' value="' . $value . '"';
992 }
993 } else {
994 $checked = \checked( Settings_Utils::string_to_bool( self::$current_value ), true, false );
995 }
996
997 $extra_attrs = '';
998 if ( self::$data_attrs && is_array( self::$data_attrs ) && ! empty( self::$data_attrs ) ) {
999 foreach ( self::$data_attrs as $data_attr_key => $data_attr_value ) {
1000 $extra_attrs .= ' data-' . \esc_attr( $data_attr_key ) . '="' . \esc_attr( $data_attr_value ) . '"';
1001 }
1002 }
1003 ?>
1004 <label class="toggle-switch <?php echo self::$custom_class; ?>">
1005 <input type="checkbox" <?php echo self::$item_id_attr; ?> <?php echo self::$name_attr; ?> <?php echo $checked; ?><?php echo $extra_attrs; ?>>
1006 <span class="toggle-slider"></span>
1007 <?php if ( ! empty( self::$settings['text'] ) ) { ?>
1008 <span class="toggle-label"><?php echo self::$settings['text']; ?></span>
1009 <?php } ?>
1010 </label>
1011 <?php
1012 }
1013
1014 /**
1015 * User Roles - renders a multi-column grid of checkboxes for user roles.
1016 *
1017 * Expected settings keys:
1018 * 'roles' (array) - Associative array of role_slug => display_name.
1019 * 'checked' (array) - Array of role slugs that are currently checked.
1020 * 'name_prefix' (string) - The name attribute prefix for each checkbox.
1021 *
1022 * @since 4.0.0
1023 */
1024 private static function user_roles() {
1025 $roles = ! empty( self::$settings['roles'] ) ? self::$settings['roles'] : array();
1026 $checked = ! empty( self::$settings['checked'] ) ? self::$settings['checked'] : array();
1027 $name_prefix = ! empty( self::$settings['name_prefix'] ) ? self::$settings['name_prefix'] : '';
1028 ?>
1029 <div <?php echo self::$item_id_wrap; ?> class="user-roles-grid <?php echo self::$custom_class; ?>">
1030 <?php foreach ( $roles as $role_slug => $role_name ) { ?>
1031 <label class="checkbox-option user-role-item" for="role-<?php echo \esc_attr( $role_slug ); ?>">
1032 <input type="checkbox"
1033 id="role-<?php echo \esc_attr( $role_slug ); ?>"
1034 name="<?php echo \esc_attr( $name_prefix ); ?>[<?php echo \esc_attr( $role_slug ); ?>]"
1035 value="yes"
1036 <?php \checked( in_array( $role_slug, $checked, true ) ); ?>>
1037 <?php echo \esc_html( $role_name ); ?>
1038 </label>
1039 <?php } ?>
1040 </div>
1041 <?php
1042 }
1043
1044 /**
1045 * Stat Cards - renders a row of statistic summary cards.
1046 *
1047 * Expected settings keys:
1048 * 'cards' (array) - Array of arrays with 'value' and 'label' keys.
1049 *
1050 * @since 4.0.0
1051 */
1052 private static function stat_cards() {
1053 $cards = ! empty( self::$settings['cards'] ) ? self::$settings['cards'] : array();
1054 ?>
1055 <div <?php echo self::$item_id_wrap; ?> class="stat-cards-row <?php echo self::$custom_class; ?>">
1056 <?php foreach ( $cards as $card ) { ?>
1057 <div class="stat-card">
1058 <span class="stat-value"><?php echo \esc_html( (string) $card['value'] ); ?></span>
1059 <span class="stat-label"><?php echo \esc_html( $card['label'] ); ?></span>
1060 </div>
1061 <?php } ?>
1062 </div>
1063 <?php
1064 }
1065
1066 /**
1067 * Data Table - renders a data table with header and rows.
1068 *
1069 * Expected settings keys:
1070 * 'header' (array) - Array of column header strings.
1071 * 'rows' (array) - Array of row arrays. Each row value can be a string or
1072 * an associative array with 'url' and 'label' (renders as a link).
1073 * 'table_id' (string) - Optional HTML id for the table element.
1074 *
1075 * @since 4.0.0
1076 */
1077 private static function data_table() {
1078 $header = ! empty( self::$settings['header'] ) ? self::$settings['header'] : array();
1079 $rows = ! empty( self::$settings['rows'] ) ? self::$settings['rows'] : array();
1080 $table_id = ! empty( self::$settings['table_id'] ) ? self::$settings['table_id'] : '';
1081 $id_attr = $table_id ? ' id="' . \esc_attr( $table_id ) . '"' : '';
1082 ?>
1083 <div class="report-table-wrap <?php echo self::$custom_class; ?>">
1084 <table class="report-data-table"<?php echo $id_attr; ?>>
1085 <?php if ( ! empty( $header ) ) { ?>
1086 <thead>
1087 <tr>
1088 <?php foreach ( $header as $cell ) { ?>
1089 <th><?php echo \esc_html( $cell ); ?></th>
1090 <?php } ?>
1091 </tr>
1092 </thead>
1093 <?php } ?>
1094 <tbody>
1095 <?php foreach ( $rows as $row ) { ?>
1096 <tr>
1097 <?php foreach ( $row as $cell ) { ?>
1098 <td>
1099 <?php
1100 if ( \is_array( $cell ) && isset( $cell['url'], $cell['label'] ) ) {
1101 echo '<a href="' . \esc_url( $cell['url'] ) . '">' . \esc_html( $cell['label'] ) . '</a>';
1102 } else {
1103 echo \esc_html( (string) $cell );
1104 }
1105 ?>
1106 </td>
1107 <?php } ?>
1108 </tr>
1109 <?php } ?>
1110 </tbody>
1111 </table>
1112 </div>
1113 <?php
1114 }
1115
1116 /**
1117 * Multi-select with AJAX autocomplete search (select2-like, vanilla JS).
1118 *
1119 * Expected settings keys:
1120 * 'items' (array) - Array of [ 'id' => ..., 'text' => ... ] for pre-selected items.
1121 * 'placeholder' (string) - Placeholder text for the search input.
1122 * 'data_attrs' (array) - Must contain 'search-type' ('users' or 'roles').
1123 * Optional 'min-chars' (default '2').
1124 *
1125 * The hidden input stores a comma-separated list of selected IDs for form submission.
1126 *
1127 * @since 4.0.0
1128 */
1129 private static function multi_select_ajax() {
1130 $items = ! empty( self::$settings['items'] ) ? self::$settings['items'] : array();
1131 $search_type = '';
1132 $min_chars = '2';
1133
1134 if ( ! empty( self::$data_attrs ) && is_array( self::$data_attrs ) ) {
1135 $search_type = isset( self::$data_attrs['search-type'] ) ? self::$data_attrs['search-type'] : '';
1136 $min_chars = isset( self::$data_attrs['min-chars'] ) ? self::$data_attrs['min-chars'] : '2';
1137 }
1138
1139 $placeholder = ! empty( self::$settings['placeholder'] ) ? self::$settings['placeholder'] : '';
1140
1141 if ( ! self::$multi_select_ajax_rendered ) {
1142 self::multi_select_ajax_assets();
1143 self::$multi_select_ajax_rendered = true;
1144 }
1145
1146 ?>
1147 <div class="wp2fa-msa-wrap<?php echo self::$custom_class; ?>"
1148 <?php echo self::$item_id_wrap; ?>
1149 data-search-type="<?php echo \esc_attr( $search_type ); ?>"
1150 data-min-chars="<?php echo \esc_attr( $min_chars ); ?>">
1151 <div class="wp2fa-msa-tags-input">
1152 <?php foreach ( $items as $item ) { ?>
1153 <span class="wp2fa-msa-tag" data-id="<?php echo \esc_attr( $item['id'] ); ?>">
1154 <?php echo \esc_html( $item['text'] ); ?>
1155 <button type="button" class="wp2fa-msa-remove" aria-label="<?php \esc_attr_e( 'Remove', 'wp-2fa' ); ?>">&times;</button>
1156 </span>
1157 <?php } ?>
1158 <input type="text"
1159 class="wp2fa-msa-input"
1160 placeholder="<?php echo \esc_attr( $placeholder ); ?>"
1161 autocomplete="off">
1162 </div>
1163 <div class="wp2fa-msa-dropdown"></div>
1164 <input type="hidden"
1165 class="wp2fa-msa-value"
1166 <?php echo self::$name_attr; ?>
1167 value="<?php echo \esc_attr( self::$current_value ); ?>">
1168 </div>
1169 <?php
1170 }
1171
1172 /**
1173 * Output the shared CSS and JS assets for the multi-select-ajax component.
1174 * Called once per page load via the static flag.
1175 *
1176 * @since 4.0.0
1177 */
1178 private static function multi_select_ajax_assets() {
1179 ?>
1180 <style>
1181 .wp2fa-msa-wrap{position:relative;max-width:500px}
1182 .wp2fa-msa-tags-input{display:flex;flex-wrap:wrap;align-items:center;gap:4px;padding:4px 8px;border:1px solid #8c8f94;border-radius:4px;background:#fff;cursor:text;min-height:36px}
1183 .wp2fa-msa-tags-input:focus-within{border-color:#2271b1;box-shadow:0 0 0 1px #2271b1}
1184 .wp2fa-msa-tag{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:#f0f0f1;border:1px solid #c3c4c7;border-radius:3px;font-size:13px;line-height:1.4;white-space:nowrap}
1185 .wp2fa-msa-remove{background:none;border:none;padding:0 2px;cursor:pointer;font-size:16px;line-height:1;color:#787c82}
1186 .wp2fa-msa-remove:hover{color:#d63638}
1187 .wp2fa-msa-input{border:none !important;outline:none !important;box-shadow:none !important;padding:2px 4px !important;min-width:120px;flex:1;font-size:13px;background:transparent !important}
1188 .wp2fa-msa-dropdown{display:none;position:absolute;top:100%;left:0;right:0;z-index:1000;background:#fff;border:1px solid #8c8f94;border-top:none;border-radius:0 0 4px 4px;max-height:200px;overflow-y:auto;box-shadow:0 2px 4px rgba(0,0,0,.1)}
1189 .wp2fa-msa-option{padding:8px 12px;cursor:pointer;font-size:13px}
1190 .wp2fa-msa-option:hover,.wp2fa-msa-option.wp2fa-msa-active{background:#2271b1;color:#fff}
1191 .wp2fa-msa-no-results{padding:8px 12px;color:#787c82;font-size:13px;font-style:italic}
1192 </style>
1193 <script>
1194 (function(){
1195 'use strict';
1196 if(window.wp2faMSAInit)return;
1197 window.wp2faMSAInit=true;
1198
1199 /* Mapping of opposite selector wrapper IDs. */
1200 var oppositePairs={
1201 'enforced-roles-item':'excluded-roles-item',
1202 'excluded-roles-item':'enforced-roles-item',
1203 'enforced-users-item':'excluded-users-item',
1204 'excluded-users-item':'enforced-users-item'
1205 };
1206
1207 /**
1208 * Return the IDs currently selected in the opposite selector,
1209 * or an empty array if there is no opposite.
1210 */
1211 function getOppositeIds(wrap){
1212 var wrapId=wrap.getAttribute('id')||'';
1213 var oppositeId=oppositePairs[wrapId];
1214 if(!oppositeId)return[];
1215 var opp=document.getElementById(oppositeId);
1216 if(!opp)return[];
1217 var h=opp.querySelector('.wp2fa-msa-value');
1218 if(!h||!h.value.trim())return[];
1219 return h.value.trim().split(',').map(function(s){return s.trim()}).filter(Boolean);
1220 }
1221
1222 /**
1223 * Remove a single ID from the opposite selector
1224 * (both the hidden value and the visible tag).
1225 */
1226 function removeFromOpposite(wrap,id){
1227 var wrapId=wrap.getAttribute('id')||'';
1228 var oppositeId=oppositePairs[wrapId];
1229 if(!oppositeId)return;
1230 var opp=document.getElementById(oppositeId);
1231 if(!opp)return;
1232 var h=opp.querySelector('.wp2fa-msa-value');
1233 if(!h)return;
1234 var ids=h.value.trim()?h.value.trim().split(',').map(function(s){return s.trim()}).filter(Boolean):[];
1235 if(ids.indexOf(id)===-1)return;
1236 ids=ids.filter(function(i){return i!==id});
1237 h.value=ids.join(',');
1238 var tag=opp.querySelector('.wp2fa-msa-tag[data-id="'+id+'"]');
1239 if(tag)tag.parentNode.removeChild(tag);
1240 }
1241
1242 function initMSA(wrap,cfg){
1243 var tagsInput=wrap.querySelector('.wp2fa-msa-tags-input');
1244 var input=wrap.querySelector('.wp2fa-msa-input');
1245 var dropdown=wrap.querySelector('.wp2fa-msa-dropdown');
1246 var hiddenInput=wrap.querySelector('.wp2fa-msa-value');
1247 var searchType=wrap.getAttribute('data-search-type')||'';
1248 var minChars=parseInt(wrap.getAttribute('data-min-chars')||'2',10);
1249 var debounceTimer=null;
1250 var currentXhr=null;
1251 var activeIndex=-1;
1252
1253 function getIds(){
1254 var v=hiddenInput.value.trim();
1255 if(!v)return[];
1256 return v.split(',').map(function(s){return s.trim()}).filter(Boolean);
1257 }
1258
1259 function setIds(ids){
1260 hiddenInput.value=ids.join(',');
1261 }
1262
1263 function addTag(id,text){
1264 var ids=getIds();
1265 if(ids.indexOf(id)!==-1)return;
1266
1267 /* Remove from the opposite selector first. */
1268 removeFromOpposite(wrap,id);
1269
1270 ids.push(id);
1271 setIds(ids);
1272
1273 var tag=document.createElement('span');
1274 tag.className='wp2fa-msa-tag';
1275 tag.setAttribute('data-id',id);
1276
1277 var label=document.createTextNode(text+' ');
1278 tag.appendChild(label);
1279
1280 var btn=document.createElement('button');
1281 btn.type='button';
1282 btn.className='wp2fa-msa-remove';
1283 btn.setAttribute('aria-label','Remove');
1284 btn.innerHTML='&times;';
1285 btn.addEventListener('click',function(e){
1286 e.stopPropagation();
1287 removeTag(id);
1288 });
1289 tag.appendChild(btn);
1290
1291 tagsInput.insertBefore(tag,input);
1292 }
1293
1294 function removeTag(id){
1295 var ids=getIds().filter(function(i){return i!==id});
1296 setIds(ids);
1297 var tag=wrap.querySelector('.wp2fa-msa-tag[data-id="'+id+'"]');
1298 if(tag)tag.parentNode.removeChild(tag);
1299 }
1300
1301 function hideDropdown(){
1302 dropdown.style.display='none';
1303 dropdown.innerHTML='';
1304 activeIndex=-1;
1305 }
1306
1307 function showDropdown(results){
1308 dropdown.innerHTML='';
1309 activeIndex=-1;
1310 var ids=getIds();
1311 var oppIds=getOppositeIds(wrap);
1312 var filtered=results.filter(function(r){return ids.indexOf(r.id)===-1&&oppIds.indexOf(r.id)===-1});
1313
1314 if(!filtered.length){
1315 var noRes=document.createElement('div');
1316 noRes.className='wp2fa-msa-no-results';
1317 noRes.textContent='No results found';
1318 dropdown.appendChild(noRes);
1319 dropdown.style.display='block';
1320 return;
1321 }
1322
1323 filtered.forEach(function(item){
1324 var opt=document.createElement('div');
1325 opt.className='wp2fa-msa-option';
1326 opt.textContent=item.text;
1327 opt.setAttribute('data-id',item.id);
1328 opt.addEventListener('mousedown',function(e){
1329 e.preventDefault();
1330 addTag(item.id,item.text);
1331 input.value='';
1332 hideDropdown();
1333 });
1334 dropdown.appendChild(opt);
1335 });
1336 dropdown.style.display='block';
1337 }
1338
1339 function setActive(idx){
1340 var opts=dropdown.querySelectorAll('.wp2fa-msa-option');
1341 for(var i=0;i<opts.length;i++){opts[i].classList.remove('wp2fa-msa-active')}
1342 if(idx>=0&&idx<opts.length){
1343 opts[idx].classList.add('wp2fa-msa-active');
1344 opts[idx].scrollIntoView({block:'nearest'});
1345 }
1346 activeIndex=idx;
1347 }
1348
1349 function doSearch(term){
1350 if(currentXhr){currentXhr.abort();currentXhr=null}
1351
1352 var xhr=new XMLHttpRequest();
1353 currentXhr=xhr;
1354
1355 var url=cfg.ajaxUrl
1356 +'?action='+encodeURIComponent(cfg.searchAction)
1357 +'&nonce='+encodeURIComponent(cfg.nonce)
1358 +'&type='+encodeURIComponent(searchType)
1359 +'&term='+encodeURIComponent(term);
1360
1361 xhr.open('GET',url,true);
1362 xhr.onreadystatechange=function(){
1363 if(xhr.readyState!==4)return;
1364 currentXhr=null;
1365 if(xhr.status>=200&&xhr.status<300){
1366 try{
1367 var resp=JSON.parse(xhr.responseText);
1368 if(resp.success&&Array.isArray(resp.data)){
1369 showDropdown(resp.data);
1370 }
1371 }catch(e){hideDropdown()}
1372 }
1373 };
1374 xhr.send();
1375 }
1376
1377 input.addEventListener('input',function(){
1378 var term=input.value.trim();
1379 if(debounceTimer)clearTimeout(debounceTimer);
1380 if(term.length<minChars){hideDropdown();return}
1381 debounceTimer=setTimeout(function(){doSearch(term)},250);
1382 });
1383
1384 input.addEventListener('keydown',function(e){
1385 var opts=dropdown.querySelectorAll('.wp2fa-msa-option');
1386 if(e.key==='ArrowDown'){
1387 e.preventDefault();
1388 if(activeIndex<opts.length-1)setActive(activeIndex+1);
1389 }else if(e.key==='ArrowUp'){
1390 e.preventDefault();
1391 if(activeIndex>0)setActive(activeIndex-1);
1392 }else if(e.key==='Enter'){
1393 e.preventDefault();
1394 if(activeIndex>=0&&opts[activeIndex]){
1395 var id=opts[activeIndex].getAttribute('data-id');
1396 var text=opts[activeIndex].textContent;
1397 addTag(id,text);
1398 input.value='';
1399 hideDropdown();
1400 }
1401 }else if(e.key==='Escape'){
1402 hideDropdown();
1403 }else if(e.key==='Backspace'&&!input.value){
1404 var tags=wrap.querySelectorAll('.wp2fa-msa-tag');
1405 if(tags.length){
1406 var last=tags[tags.length-1];
1407 removeTag(last.getAttribute('data-id'));
1408 }
1409 }
1410 });
1411
1412 input.addEventListener('blur',function(){
1413 setTimeout(hideDropdown,200);
1414 });
1415
1416 tagsInput.addEventListener('click',function(e){
1417 if(e.target===input)return;
1418 input.focus();
1419 });
1420
1421 /* Attach remove handlers for server-rendered tags. */
1422 var existingBtns=wrap.querySelectorAll('.wp2fa-msa-remove');
1423 for(var i=0;i<existingBtns.length;i++){
1424 (function(btn){
1425 btn.addEventListener('click',function(e){
1426 e.stopPropagation();
1427 var tag=btn.parentNode;
1428 if(tag&&tag.classList.contains('wp2fa-msa-tag')){
1429 removeTag(tag.getAttribute('data-id'));
1430 }
1431 });
1432 })(existingBtns[i]);
1433 }
1434 }
1435
1436 function boot(){
1437 var cfg=window.wp2faSavePoliciesNew;
1438 if(!cfg||!cfg.searchAction)return;
1439 var wraps=document.querySelectorAll('.wp2fa-msa-wrap');
1440 for(var i=0;i<wraps.length;i++){initMSA(wraps[i],cfg)}
1441 }
1442
1443 if(document.readyState==='loading'){
1444 document.addEventListener('DOMContentLoaded',boot);
1445 }else{
1446 boot();
1447 }
1448 })();
1449 </script>
1450 <?php
1451 }
1452
1453 /**
1454 * Tab Title
1455 *
1456 * @since 4.0.0
1457 */
1458 private static function tab_title() {
1459 ?>
1460 <h2>
1461 <?php
1462
1463 echo \esc_html( self::$settings['title'] );
1464 ?>
1465 </h2>
1466 <?php
1467 }
1468
1469 /**
1470 * Group Title Builder
1471 *
1472 * @return void
1473 *
1474 * @since 4.0.0
1475 */
1476 private static function group_title() {
1477 ?>
1478 <div <?php echo self::$item_id_attr; ?> class="group-title <?php echo self::$custom_class; ?>"><?php echo \esc_html( self::$current_value ); ?></div>
1479 <?php
1480 }
1481
1482 /**
1483 * List Builder
1484 *
1485 * @return void
1486 *
1487 * @since 4.0.0
1488 */
1489 private static function list() {
1490 echo '<ul ' . self::$item_id_wrap . ' class="wp-2fa-list-options' . self::$custom_class . '">';
1491
1492 foreach ( self::$current_value as $value ) {
1493 ?>
1494 <li tabindex="0">
1495 <span class="option-value" id="<?php echo \esc_attr( $value['id'] ); ?>"><?php echo \esc_html( $value['title'] ); ?></span>
1496 <?php if ( ! empty( self::$settings['values'][ $value['id'] ]['badge'] ) ) : ?>
1497 <span class="wp-2fa-badge"><?php echo \esc_html( self::$settings['values'][ $value['id'] ]['badge'] ); ?></span>
1498 <?php endif; ?>
1499 </li>
1500 <?php
1501 }
1502
1503 echo '</ul>';
1504 }
1505
1506 /**
1507 * Number
1508 *
1509 * @since 5.0.0
1510 */
1511 private static function number() {
1512
1513 $min = ! empty( self::$min ) ? self::$min : -1000;
1514 $max = ! empty( self::$max ) ? self::$max : 1000000;
1515
1516 ?>
1517 <div class="number-input-wrap">
1518 <input style="width:100px" min="<?php echo $min; ?>" max="<?php echo $max; ?>" <?php echo self::$item_id_attr; ?> <?php echo self::$name_attr; ?> type="number" value="<?php echo \esc_attr( self::$current_value ); ?>" <?php echo self::$placeholder_attr; ?>>
1519 <?php
1520 if ( self::$settings['label'] ?? false ) {
1521 echo '<span class="number-label">' . \esc_html( self::$settings['label'] ) . '</span>';
1522 }
1523 ?>
1524 </div>
1525 <?php
1526 }
1527
1528 /**
1529 * Select dropdown.
1530 *
1531 * Expected settings keys:
1532 * 'options' (array) - Associative array of value => label.
1533 *
1534 * @since 5.0.0
1535 */
1536 private static function select() {
1537 ?>
1538 <select <?php echo self::$item_id_attr; ?> <?php echo self::$name_attr; ?> class="<?php echo self::$custom_class; ?>">
1539 <?php
1540 if ( ! empty( self::$settings['options'] ) && \is_array( self::$settings['options'] ) ) {
1541 foreach ( self::$settings['options'] as $option_key => $option ) {
1542 $selected = \selected( self::$current_value, $option_key, false );
1543 ?>
1544 <option value="<?php echo \esc_attr( $option_key ); ?>" <?php echo $selected; ?>><?php echo \esc_html( $option ); ?></option>
1545 <?php
1546 }
1547 }
1548 ?>
1549 </select>
1550 <?php
1551 }
1552
1553 /**
1554 * Renders a breadcrumb navigation bar.
1555 *
1556 * @param string $parent_label - The escaped parent page label.
1557 * @param string $parent_back_class - The CSS class used for the back navigation.
1558 * @param string $current_label - The escaped current page label.
1559 *
1560 * @return void
1561 *
1562 * @since 4.0.0
1563 */
1564 public static function breadcrumb() {
1565 ?>
1566 <nav class="breadcrumbs">
1567 <a href="#" class="back-button back-click" aria-label="<?php echo \esc_attr__( 'Go back', 'wp-2fa' ); ?>"><svg xmlns="http://www.w3.org/2000/svg" width="7" height="12" viewBox="0 0 7 12" fill="none" aria-hidden="true"><path d="M5.9375 0.9375L0.9375 5.9375L5.9375 10.9375" stroke="white" stroke-width="1.875" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
1568 <ul>
1569 <li><a href="#" class="back-click <?php echo \esc_attr( self::$custom_class ); ?>"><?php echo \esc_html( self::$settings['parent'] ); ?></a></li>
1570 <li><?php echo \esc_html( self::$current_value ); ?></li>
1571 </ul>
1572 </nav>
1573 <?php
1574 }
1575
1576 /**
1577 * Text input with a URL prefix displayed before it.
1578 *
1579 * Expected settings keys:
1580 * 'url_prefix' (string) - The URL prefix to display (e.g. site URL).
1581 *
1582 * @since 5.0.0
1583 */
1584 private static function url_prefixed_text() {
1585 $prefix = ! empty( self::$settings['url_prefix'] ) ? self::$settings['url_prefix'] : '';
1586 ?>
1587 <div class="url-prefix-wrap">
1588 <span class="url-prefix-text"><?php echo \esc_html( $prefix ); ?></span>
1589 <input <?php echo self::$item_id_attr; ?> <?php echo self::$name_attr; ?> type="text" value="<?php echo \esc_attr( self::$current_value ); ?>" class="regular-text<?php echo self::$custom_class; ?>" <?php echo self::$placeholder_attr; ?>>
1590 </div>
1591 <?php
1592 }
1593
1594 /**
1595 * Prepare Data
1596 *
1597 * @param array $settings - Array with settings.
1598 * @param mixed $data - The data to show.
1599 *
1600 * @since 4.0.0
1601 */
1602 private static function prepare_data( $settings, $data ) {
1603
1604 // Default Settings.
1605 $settings = \wp_parse_args(
1606 $settings,
1607 array(
1608 'id' => '',
1609 'class' => '',
1610 )
1611 );
1612
1613 self::$settings = $settings;
1614
1615 extract( $settings ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract
1616
1617 self::$option_type = ! empty( $type ) ? $type : false;
1618 self::$required = ! empty( $required ) ? $required : false;
1619
1620 self::$min = ! empty( $min ) ? $min : null;
1621 self::$max = ! empty( $max ) ? $max : null;
1622
1623 self::$step = ! empty( $step ) ? $step : null;
1624
1625 self::$data_attrs = ! empty( $data_attrs ) ? $data_attrs : array();
1626
1627 if ( 'text' === self::$option_type || 'secret' === self::$option_type ) {
1628 self::$edit_type = ! empty( $validate ) ? $validate : false;
1629 self::$validate_pattern = ! empty( $pattern ) ? $pattern : false;
1630 self::$max_chars = ! empty( $max_chars ) ? $max_chars : false;
1631 self::$title_attr = ! empty( $title_attr ) ? $title_attr : false;
1632 }
1633
1634 self::$not_bool = ! empty( $not_bool ) ? true : null;
1635
1636 // ID.
1637 self::$item_id .= ! empty( $prefix ) ? $prefix . '-' : '';
1638 self::$item_id .= ! empty( $id ) ? $id : '';
1639
1640 self::$item_id = \esc_attr( self::$item_id );
1641
1642 if ( ! empty( self::$item_id ) && ' ' !== self::$item_id ) {
1643
1644 self::$item_id = ( 'arrayText' === $type ) ? self::$item_id . '-' . $key : self::$item_id;
1645
1646 self::$item_id_attr = 'id="' . self::$item_id . '"';
1647 self::$item_id_wrap = 'id="' . self::$item_id . '-item"';
1648 }
1649
1650 // Class.
1651 self::$custom_class = ! empty( $class ) ? ' ' . $class : '';
1652
1653 if ( isset( $option_name ) ) {
1654 $option_name = \esc_attr( $option_name );
1655 }
1656
1657 // Name.
1658 self::$name_attr = 'name="' . ( ( $option_name ) ?? '' ) . '"';
1659 self::$option_name = ! empty( $option_name ) ? $option_name : null;
1660
1661 // Placeholder.
1662 self::$placeholder_attr = ! empty( $placeholder ) ? 'placeholder="' . $placeholder . '"' : '';
1663
1664 // Get the option stored data.
1665 if ( ! \is_null( $data ) ) {
1666 self::$current_value = $data;
1667 } elseif ( isset( $default ) ) {
1668 self::$current_value = $default;
1669 }
1670
1671 if ( empty( self::$current_value ) && ( 'list' === self::$option_type || 'tabbed-navigation' === self::$option_type ) ) {
1672 self::$current_value = array_map(
1673 function ( $value ) {
1674 $item = array(
1675 'title' => ( $value['title'] ) ? (string) \esc_html( $value['title'] ) : 'Not Set',
1676 'id' => ( $value['id'] ) ? (string) ( $value['id'] ) : '',
1677 );
1678 if ( ! empty( $value['locked'] ) ) {
1679 $item['locked'] = true;
1680 }
1681 if ( ! empty( $value['premium_context'] ) ) {
1682 $item['premium_context'] = (string) $value['premium_context'];
1683 }
1684 return $item;
1685 },
1686 $settings['values']
1687 );
1688 }
1689 }
1690
1691 /**
1692 * Whether the sortable-list CSS/JS assets have been output.
1693 *
1694 * @var bool
1695 *
1696 * @since 3.1.2
1697 */
1698 private static $sortable_list_rendered = false;
1699
1700 /**
1701 * Sortable List – renders an ordered list of items that can be reordered via drag-and-drop.
1702 *
1703 * Expected settings keys:
1704 * 'items' (array) - Ordered array of arrays with 'id', 'title', and optionally 'enabled' (bool), 'description' (string).
1705 * 'name_prefix' (string) - The hidden input name prefix for the order, e.g. 'wp_2fa_policy[methods_order]'.
1706 * Each item will produce: <input name="{name_prefix}[]" value="{id}">.
1707 *
1708 * @since 3.1.2
1709 */
1710 private static function sortable_list() {
1711 $items = ! empty( self::$settings['items'] ) ? self::$settings['items'] : array();
1712 $name_prefix = ! empty( self::$settings['name_prefix'] ) ? self::$settings['name_prefix'] : '';
1713
1714 if ( ! self::$sortable_list_rendered ) {
1715 self::sortable_list_assets();
1716 self::$sortable_list_rendered = true;
1717 }
1718 ?>
1719 <div <?php echo self::$item_id_wrap; ?> class="wp2fa-sortable-list-wrap <?php echo self::$custom_class; ?>">
1720 <ul class="wp2fa-sortable-list" data-name-prefix="<?php echo \esc_attr( $name_prefix ); ?>">
1721 <?php
1722 foreach ( $items as $item ) {
1723 $has_extra = ! empty( $item['extra_settings'] );
1724 $has_integration = ! empty( $item['integration_settings'] );
1725 $is_configured = ! empty( $item['is_configured'] );
1726 $needs_setup = $has_integration && ! $is_configured;
1727 $li_class = 'wp2fa-sortable-item';
1728 ?>
1729 <li class="<?php echo \esc_attr( $li_class ); ?>" data-id="<?php echo \esc_attr( $item['id'] ); ?>" <?php echo $needs_setup ? 'data-needs-setup="1"' : ''; ?>>
1730 <input type="hidden" name="<?php echo \esc_attr( $name_prefix ); ?>[]" value="<?php echo \esc_attr( ( $item['order_id'] ) ? $item['order_id'] : $item['id'] ); ?>">
1731 <span class="wp2fa-sortable-handle" aria-label="<?php \esc_attr_e( 'Drag to reorder', 'wp-2fa' ); ?>">&#9776;</span>
1732 <?php if ( ! empty( $item['checkbox_name'] ) ) { ?>
1733 <label class="wp2fa-sortable-checkbox toggle-switch">
1734 <input type="checkbox"
1735 name="<?php echo \esc_attr( $item['checkbox_name'] ); ?>"
1736 value="<?php echo \esc_attr( ! empty( $item['checkbox_value'] ) ? $item['checkbox_value'] : $item['id'] ); ?>"
1737 <?php \checked( ! empty( $item['checked'] ) ); ?>
1738 <?php echo $has_extra ? 'data-has-extra="1"' : ''; ?>>
1739 <span class="toggle-slider"></span>
1740 <span class="wp2fa-sortable-title"> <?php echo \esc_html( $item['title'] ); ?></span>
1741 <?php if ( ! empty( $item['title_hint'] ) ) { ?>
1742 <span class="wp2fa-sortable-title-hint"><?php echo $item['title_hint']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped HTML with links. ?></span>
1743 <?php } ?>
1744 </label>
1745 <?php } else { ?>
1746 <span class="wp2fa-sortable-title"><?php echo \esc_html( $item['title'] ); ?></span>
1747 <?php } ?>
1748 <?php if ( ! empty( $item['setup_url'] ) ) { ?>
1749 <a href="<?php echo \esc_url( $item['setup_url'] ); ?>" class="wp2fa-sortable-setup-link" style="display:none;"><?php \esc_html_e( 'Setup method', 'wp-2fa' ); ?></a>
1750 <?php } ?>
1751 <?php if ( ! empty( $item['description'] ) ) { ?>
1752 <span class="wp2fa-sortable-desc"><?php echo $item['description']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped HTML with links. ?></span>
1753 <?php } ?>
1754 <?php if ( $has_extra ) { ?>
1755 <div class="wp2fa-method-settings-toggle-wrap" style="<?php echo empty( $item['checked'] ) ? 'display:none;' : ''; ?>">
1756 <button type="button" class="wp2fa-collapsible-toggle" aria-expanded="false" data-label-show="<?php \esc_attr_e( 'Show method settings', 'wp-2fa' ); ?>" data-label-hide="<?php \esc_attr_e( 'Hide method settings', 'wp-2fa' ); ?>">
1757 <span class="wp2fa-collapsible-chevron" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="7" viewBox="0 0 12 7" fill="none"><path d="M1 1L6 6L11 1" stroke="currentColor" stroke-width="1.875" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
1758 <span class="wp2fa-collapsible-text"><?php \esc_html_e( 'Show method settings', 'wp-2fa' ); ?></span>
1759 </button>
1760 </div>
1761 <div class="wp2fa-sortable-extra-settings wp2fa-collapsible-content" style="display:none;">
1762 <?php echo $item['extra_settings']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-built HTML from Settings_Builder. ?>
1763 </div>
1764 <?php } ?>
1765 <?php if ( $has_integration ) { ?>
1766 <?php if ( ! $is_configured ) { ?>
1767 <div class="wp2fa-integration-setup-notice" style="display:none;">
1768 <p><?php \esc_html_e( 'Complete configuration to enable this method', 'wp-2fa' ); ?></p>
1769 </div>
1770 <?php } ?>
1771 <div class="wp2fa-integration-settings-toggle-wrap" style="<?php echo ( empty( $item['checked'] ) && ! $needs_setup ) ? 'display:none;' : ''; ?>">
1772 <button type="button" class="wp2fa-collapsible-toggle" aria-expanded="false" data-label-show="<?php \esc_attr_e( 'Show integration settings', 'wp-2fa' ); ?>" data-label-hide="<?php \esc_attr_e( 'Hide integration settings', 'wp-2fa' ); ?>">
1773 <span class="wp2fa-collapsible-chevron" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="7" viewBox="0 0 12 7" fill="none"><path d="M1 1L6 6L11 1" stroke="currentColor" stroke-width="1.875" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
1774 <span class="wp2fa-collapsible-text"><?php \esc_html_e( 'Show integration settings', 'wp-2fa' ); ?></span>
1775 </button>
1776 <span class="wp2fa-integration-status-dot <?php echo $is_configured ? 'wp2fa-status-configured' : 'wp2fa-status-not-configured'; ?>" title="<?php echo $is_configured ? \esc_attr__( 'Configured', 'wp-2fa' ) : \esc_attr__( 'Not configured', 'wp-2fa' ); ?>"></span>
1777 </div>
1778 <div class="wp2fa-sortable-integration-settings wp2fa-collapsible-content" style="display:none;">
1779 <?php if ( ! $is_configured ) { ?>
1780 <div class="wp2fa-integration-info-notice">
1781 <p><?php \esc_html_e( 'Configure this method below and hit save to enable it.', 'wp-2fa' ); ?></p>
1782 </div>
1783 <?php } ?>
1784 <?php echo $item['integration_settings']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-built HTML from Settings_Builder. ?>
1785 </div>
1786 <?php } ?>
1787 </li>
1788 <?php } ?>
1789 </ul>
1790 </div>
1791 <?php
1792 }
1793
1794 /**
1795 * Output inline CSS + JS for the sortable-list component.
1796 *
1797 * Uses vanilla JavaScript – no third-party libraries.
1798 *
1799 * @since 3.1.2
1800 */
1801 private static function sortable_list_assets() {
1802 ?>
1803 <style>
1804 /* .wp2fa-sortable-list-wrap { max-width: 600px; } */
1805 .wp2fa-sortable-list { list-style: none; margin: 0; padding: 0; }
1806 .wp2fa-sortable-item {
1807 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
1808 background: #fff; border: 1px solid #e2e2e4; border-radius: 5px;
1809 padding: 12px 16px; margin-bottom: 8px; cursor: default;
1810 transition: box-shadow .15s, border-color .15s;
1811 user-select: none;
1812 }
1813 .wp2fa-sortable-item.wp2fa-sortable-dragging {
1814 opacity: .55; box-shadow: 0 4px 12px rgba(0,0,0,.15); border-color: #2271b1;
1815 }
1816 .wp2fa-sortable-item.wp2fa-sortable-over {
1817 border-top: 2px solid #2271b1; margin-top: -2px;
1818 }
1819 .wp2fa-sortable-handle {
1820 cursor: grab; font-size: 18px; color: #787c82; line-height: 1; flex-shrink: 0;
1821 }
1822 .wp2fa-sortable-handle:active { cursor: grabbing; }
1823 .wp2fa-sortable-checkbox { flex-shrink: 0; display: inline-flex; align-items: center; gap: 12px; }
1824 .wp2fa-sortable-checkbox input[type="checkbox"] { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
1825 .wp2fa-sortable-title { font-weight: 500; font-size: 14px; flex: 1; margin-left: 10px; }
1826 .wp2fa-sortable-desc { font-size: 12px; color: #646970; }
1827 .wp2fa-sortable-extra-settings {
1828 width: 100%; padding: 10px 0 4px 91px; border-top: 1px solid #f0f0f1;
1829 margin-top: 4px; display: flex; flex-direction: column; gap: 8px;
1830 }
1831 .wp2fa-sortable-extra-settings .number-input-wrap,
1832 .wp2fa-sortable-extra-settings .settings-control { margin: 0; }
1833 .wp2fa-sortable-extra-settings .number-input-wrap input[type="number"] { width: 70px; }
1834 .wp2fa-sortable-extra-settings .simple-text { font-size: 13px; margin: 0 0 4px; }
1835 .wp2fa-extra-inline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1836 .wp2fa-extra-inline .settings-control { display: flex; align-items: center; gap: 6px; }
1837 .wp2fa-extra-inline .settings-control .radio-option { margin: 0; }
1838 .wp2fa-sortable-item-disabled {
1839 background: #f9f9f9;
1840 }
1841 .wp2fa-sortable-item-disabled .wp2fa-sortable-handle,
1842 .wp2fa-sortable-item-disabled .wp2fa-sortable-checkbox,
1843 .wp2fa-sortable-item-disabled .wp2fa-sortable-title { opacity: 0.5; pointer-events: none; }
1844 .wp2fa-sortable-setup-link {
1845 display: none;
1846 flex-shrink: 0; margin-left: auto; font-size: 13px; color: #2271b1; text-decoration: none;
1847 font-weight: 500; white-space: nowrap;
1848 }
1849 .wp2fa-sortable-setup-link:hover { text-decoration: underline; color: #135e96; }
1850
1851 /* Collapsible toggle (Show/Hide method/integration settings) */
1852 .wp2fa-method-settings-toggle-wrap,
1853 .wp2fa-integration-settings-toggle-wrap {
1854 width: 100%; padding: 8px 0 0 91px; display: flex; align-items: center; gap: 8px;
1855 }
1856 .wp2fa-collapsible-toggle {
1857 background: none; border: none; cursor: pointer; padding: 0;
1858 display: inline-flex; align-items: center; gap: 6px;
1859 font-size: 12px; font-weight: 600; text-transform: uppercase;
1860 color: #2271b1; letter-spacing: 0.3px;
1861 }
1862 .wp2fa-collapsible-toggle:hover { color: #135e96; }
1863 .wp2fa-collapsible-chevron {
1864 display: inline-flex; align-items: center; transition: transform 0.2s ease;
1865 }
1866 .wp2fa-collapsible-chevron svg { width: 12px; height: 7px; }
1867 .wp2fa-collapsible-toggle[aria-expanded="true"] .wp2fa-collapsible-chevron {
1868 transform: rotate(180deg);
1869 }
1870 .wp2fa-integration-status-dot {
1871 display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
1872 }
1873 .wp2fa-status-configured { background-color: #00a32a; }
1874 .wp2fa-status-not-configured { background-color: #a7aaad; }
1875 .wp2fa-sortable-integration-settings {
1876 width: 100%; padding: 10px 0 4px 91px; border-top: 1px solid #f0f0f1;
1877 margin-top: 4px; display: flex; flex-direction: column; gap: 8px;
1878 }
1879 .wp2fa-sortable-integration-settings .number-input-wrap,
1880 .wp2fa-sortable-integration-settings .settings-control { margin: 0; }
1881 .wp2fa-integration-setup-notice {
1882 width: 100%; padding: 8px 0 0 91px;
1883 }
1884 .wp2fa-integration-setup-notice p {
1885 background: #fcf0f1; border: 1px solid #f0c6c8; border-radius: 4px;
1886 padding: 12px 16px; color: #8b1a1f; font-size: 13px; line-height: 1.5; margin: 0;
1887 }
1888 .wp2fa-integration-info-notice {
1889 background: #fef3cd; border: 1px solid #ffc107; border-radius: 4px;
1890 padding: 14px 18px; color: #856404; font-size: 13px; line-height: 1.6;
1891 }
1892 .wp2fa-integration-info-notice p { margin: 0; }
1893 .wp2fa-sortable-desc {
1894 width: 100%; padding: 0 0 0 91px; font-size: 13px; color: #646970; line-height: 1.5;
1895 }
1896 .wp2fa-sortable-desc a { color: #2271b1; text-decoration: underline; }
1897 .wp2fa-sortable-desc a:hover { color: #135e96; }
1898 </style>
1899 <script>
1900 document.addEventListener('DOMContentLoaded', function() {
1901 /* Prevent enabling unconfigured integration methods – keep unchecked and show/hide integration settings */
1902 document.querySelectorAll('.wp2fa-sortable-item[data-needs-setup="1"] input[type="checkbox"]').forEach(function(cb) {
1903 cb.addEventListener('change', function(e) {
1904 var li = this.closest('.wp2fa-sortable-item');
1905 /* Only block if still needs setup (attribute may be removed after verification) */
1906 if (this.checked && li.hasAttribute('data-needs-setup')) {
1907 /* Block the check */
1908 this.checked = false;
1909
1910 var setupNotice = li.querySelector('.wp2fa-integration-setup-notice');
1911 var integrationWrap = li.querySelector('.wp2fa-integration-settings-toggle-wrap');
1912 var integrationContent = li.querySelector('.wp2fa-sortable-integration-settings');
1913 var integrationToggle = integrationWrap ? integrationWrap.querySelector('.wp2fa-collapsible-toggle') : null;
1914
1915 /* If already expanded, collapse and return to original state */
1916 if (integrationContent && integrationContent.style.display !== 'none') {
1917 if (setupNotice) { setupNotice.style.display = 'none'; }
1918 if (integrationWrap) { integrationWrap.style.display = 'none'; }
1919 integrationContent.style.display = 'none';
1920 if (integrationToggle) {
1921 integrationToggle.setAttribute('aria-expanded', 'false');
1922 integrationToggle.querySelector('.wp2fa-collapsible-text').textContent = integrationToggle.dataset.labelShow;
1923 }
1924 } else {
1925 /* Show the setup notice */
1926 if (setupNotice) { setupNotice.style.display = ''; }
1927 /* Show the integration settings toggle and expand it */
1928 if (integrationWrap) { integrationWrap.style.display = ''; }
1929 if (integrationContent && integrationToggle) {
1930 integrationContent.style.display = '';
1931 integrationToggle.setAttribute('aria-expanded', 'true');
1932 integrationToggle.querySelector('.wp2fa-collapsible-text').textContent = integrationToggle.dataset.labelHide;
1933 }
1934 }
1935 }
1936 });
1937 });
1938
1939 /* Toggle extra/integration settings visibility on checkbox change */
1940 document.querySelectorAll('.wp2fa-sortable-item input[type="checkbox"][data-has-extra]').forEach(function(cb) {
1941 cb.addEventListener('change', function() {
1942 var li = this.closest('.wp2fa-sortable-item');
1943 var toggleWrap = li.querySelector('.wp2fa-method-settings-toggle-wrap');
1944 if (toggleWrap) { toggleWrap.style.display = this.checked ? '' : 'none'; }
1945 var integrationWrap = li.querySelector('.wp2fa-integration-settings-toggle-wrap');
1946 if (integrationWrap) { integrationWrap.style.display = this.checked ? '' : 'none'; }
1947 /* Hide the collapsible content when method is unchecked */
1948 if (!this.checked) {
1949 var setupNotice = li.querySelector('.wp2fa-integration-setup-notice');
1950 if (setupNotice) { setupNotice.style.display = 'none'; }
1951 li.querySelectorAll('.wp2fa-collapsible-content').forEach(function(content) {
1952 content.style.display = 'none';
1953 });
1954 li.querySelectorAll('.wp2fa-collapsible-toggle').forEach(function(toggle) {
1955 toggle.setAttribute('aria-expanded', 'false');
1956 toggle.querySelector('.wp2fa-collapsible-text').textContent = toggle.dataset.labelShow;
1957 });
1958 }
1959 });
1960 });
1961
1962 /* Also handle integration toggle visibility on checkbox change (methods without extra_settings) */
1963 document.querySelectorAll('.wp2fa-sortable-item:not([data-needs-setup]) input[type="checkbox"]').forEach(function(cb) {
1964 if (cb.dataset.hasExtra) return; /* Already handled above */
1965 cb.addEventListener('change', function() {
1966 var li = this.closest('.wp2fa-sortable-item');
1967 var integrationWrap = li.querySelector('.wp2fa-integration-settings-toggle-wrap');
1968 if (integrationWrap) { integrationWrap.style.display = this.checked ? '' : 'none'; }
1969 if (!this.checked) {
1970 var setupNotice = li.querySelector('.wp2fa-integration-setup-notice');
1971 if (setupNotice) { setupNotice.style.display = 'none'; }
1972 li.querySelectorAll('.wp2fa-collapsible-content').forEach(function(content) {
1973 content.style.display = 'none';
1974 });
1975 li.querySelectorAll('.wp2fa-collapsible-toggle').forEach(function(toggle) {
1976 toggle.setAttribute('aria-expanded', 'false');
1977 toggle.querySelector('.wp2fa-collapsible-text').textContent = toggle.dataset.labelShow;
1978 });
1979 }
1980 });
1981 });
1982
1983 /* Collapsible toggle click handler */
1984 document.querySelectorAll('.wp2fa-collapsible-toggle').forEach(function(btn) {
1985 btn.addEventListener('click', function(e) {
1986 e.preventDefault();
1987 var li = this.closest('.wp2fa-sortable-item');
1988 var isIntegration = this.closest('.wp2fa-integration-settings-toggle-wrap') !== null;
1989 var content = isIntegration
1990 ? li.querySelector('.wp2fa-sortable-integration-settings')
1991 : li.querySelector('.wp2fa-sortable-extra-settings');
1992 if (!content) return;
1993 var isExpanded = this.getAttribute('aria-expanded') === 'true';
1994 if (isExpanded) {
1995 content.style.display = 'none';
1996 this.setAttribute('aria-expanded', 'false');
1997 this.querySelector('.wp2fa-collapsible-text').textContent = this.dataset.labelShow;
1998 if (isIntegration) {
1999 var setupNotice = li.querySelector('.wp2fa-integration-setup-notice');
2000 if (setupNotice) { setupNotice.style.display = 'none'; }
2001 }
2002 } else {
2003 content.style.display = '';
2004 this.setAttribute('aria-expanded', 'true');
2005 this.querySelector('.wp2fa-collapsible-text').textContent = this.dataset.labelHide;
2006 if (isIntegration) {
2007 var setupNotice = li.querySelector('.wp2fa-integration-setup-notice');
2008 if (setupNotice) { setupNotice.style.display = ''; }
2009 }
2010 }
2011 });
2012 });
2013
2014 document.querySelectorAll('.wp2fa-sortable-list').forEach(function(list) {
2015 var dragItem = null;
2016
2017 list.querySelectorAll('.wp2fa-sortable-item').forEach(function(item) {
2018 var handle = item.querySelector('.wp2fa-sortable-handle');
2019 if (!handle) return;
2020
2021 handle.addEventListener('mousedown', function() {
2022 item.setAttribute('draggable', 'true');
2023 });
2024 handle.addEventListener('mouseup', function() {
2025 item.removeAttribute('draggable');
2026 });
2027
2028 item.addEventListener('dragstart', function(e) {
2029 dragItem = item;
2030 item.classList.add('wp2fa-sortable-dragging');
2031 e.dataTransfer.effectAllowed = 'move';
2032 e.dataTransfer.setData('text/plain', '');
2033 });
2034
2035 item.addEventListener('dragend', function() {
2036 item.classList.remove('wp2fa-sortable-dragging');
2037 item.removeAttribute('draggable');
2038 dragItem = null;
2039 list.querySelectorAll('.wp2fa-sortable-item').forEach(function(el) {
2040 el.classList.remove('wp2fa-sortable-over');
2041 });
2042 /* Rebuild hidden inputs order */
2043 var prefix = list.getAttribute('data-name-prefix');
2044 list.querySelectorAll('.wp2fa-sortable-item').forEach(function(li) {
2045 var inp = li.querySelector('input[type="hidden"]');
2046 if (inp) inp.setAttribute('name', prefix + '[]');
2047 });
2048 });
2049
2050 item.addEventListener('dragover', function(e) {
2051 e.preventDefault();
2052 e.dataTransfer.dropEffect = 'move';
2053 if (item !== dragItem) {
2054 item.classList.add('wp2fa-sortable-over');
2055 }
2056 });
2057
2058 item.addEventListener('dragleave', function() {
2059 item.classList.remove('wp2fa-sortable-over');
2060 });
2061
2062 item.addEventListener('drop', function(e) {
2063 e.preventDefault();
2064 item.classList.remove('wp2fa-sortable-over');
2065 if (dragItem && dragItem !== item) {
2066 var allItems = Array.from(list.children);
2067 var dragIdx = allItems.indexOf(dragItem);
2068 var dropIdx = allItems.indexOf(item);
2069 if (dragIdx < dropIdx) {
2070 list.insertBefore(dragItem, item.nextSibling);
2071 } else {
2072 list.insertBefore(dragItem, item);
2073 }
2074 }
2075 });
2076
2077 /* Touch support for mobile */
2078 var touchStartY = 0;
2079 var touchClone = null;
2080 var touchCurrentOver = null;
2081
2082 handle.addEventListener('touchstart', function(e) {
2083 dragItem = item;
2084 touchStartY = e.touches[0].clientY;
2085 item.classList.add('wp2fa-sortable-dragging');
2086 e.preventDefault();
2087 }, { passive: false });
2088
2089 handle.addEventListener('touchmove', function(e) {
2090 if (!dragItem) return;
2091 e.preventDefault();
2092 var touch = e.touches[0];
2093 var overEl = document.elementFromPoint(touch.clientX, touch.clientY);
2094 if (overEl) {
2095 var overItem = overEl.closest('.wp2fa-sortable-item');
2096 if (touchCurrentOver && touchCurrentOver !== overItem) {
2097 touchCurrentOver.classList.remove('wp2fa-sortable-over');
2098 }
2099 if (overItem && overItem !== dragItem && overItem.parentNode === list) {
2100 overItem.classList.add('wp2fa-sortable-over');
2101 touchCurrentOver = overItem;
2102 }
2103 }
2104 }, { passive: false });
2105
2106 handle.addEventListener('touchend', function(e) {
2107 if (!dragItem) return;
2108 dragItem.classList.remove('wp2fa-sortable-dragging');
2109 if (touchCurrentOver) {
2110 touchCurrentOver.classList.remove('wp2fa-sortable-over');
2111 var allItems = Array.from(list.children);
2112 var dragIdx = allItems.indexOf(dragItem);
2113 var dropIdx = allItems.indexOf(touchCurrentOver);
2114 if (dragIdx < dropIdx) {
2115 list.insertBefore(dragItem, touchCurrentOver.nextSibling);
2116 } else {
2117 list.insertBefore(dragItem, touchCurrentOver);
2118 }
2119 /* Rebuild hidden inputs */
2120 var prefix = list.getAttribute('data-name-prefix');
2121 list.querySelectorAll('.wp2fa-sortable-item').forEach(function(li) {
2122 var inp = li.querySelector('input[type="hidden"]');
2123 if (inp) inp.setAttribute('name', prefix + '[]');
2124 });
2125 }
2126 dragItem = null;
2127 touchCurrentOver = null;
2128 });
2129 });
2130 });
2131 });
2132 </script>
2133 <?php
2134 }
2135
2136 /**
2137 * Creates an option and draws it
2138 *
2139 * @param array $value - The array with option data.
2140 *
2141 * @return void
2142 *
2143 * @since 4.0.0
2144 */
2145 public static function build_option( array $value ) {
2146 $data = null;
2147
2148 if ( empty( $value['id'] ) ) {
2149 $value['id'] = ' ';
2150 }
2151
2152 self::create( $value, $data );
2153 }
2154
2155 /**
2156 * Renders the premium badge (lock icon + "PREMIUM" label).
2157 *
2158 * Use this inline next to feature labels that require a premium license.
2159 *
2160 * @return void
2161 *
2162 * @since 4.1.0
2163 */
2164 public static function premium_badge() {
2165 echo self::get_premium_badge_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
2166 }
2167
2168 /**
2169 * Returns the premium badge HTML markup (lock icon + "PREMIUM" label).
2170 *
2171 * Useful for embedding in other element attributes like checkbox text.
2172 *
2173 * @return string
2174 *
2175 * @since 4.1.0
2176 */
2177 public static function get_premium_badge_html( string $context = '' ): string {
2178 return self::get_plan_badge_html( \esc_html__( 'PREMIUM', 'wp-2fa' ), $context );
2179 }
2180
2181 /**
2182 * Renders the enterprise badge (lock icon + "ENTERPRISE" label).
2183 *
2184 * Use this inline next to feature labels that require an enterprise license.
2185 *
2186 * @return void
2187 *
2188 * @since 4.1.0
2189 */
2190 public static function enterprise_badge() {
2191 echo self::get_enterprise_badge_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
2192 }
2193
2194 /**
2195 * Returns the enterprise badge HTML markup (lock icon + "ENTERPRISE" label).
2196 *
2197 * Useful for embedding in other element attributes like checkbox text.
2198 *
2199 * @return string
2200 *
2201 * @since 4.1.0
2202 */
2203 public static function get_enterprise_badge_html( string $context = '' ): string {
2204 return self::get_plan_badge_html( \esc_html__( 'ENTERPRISE', 'wp-2fa' ), $context );
2205 }
2206
2207 /**
2208 * Returns a badge HTML markup for a license plan label.
2209 *
2210 * @param string $label Badge text label.
2211 *
2212 * @return string
2213 *
2214 * @since 4.1.0
2215 */
2216 private static function get_plan_badge_html( string $label, string $context = '' ): string {
2217 $aria_label = sprintf(
2218 /* translators: %s: plan label. */
2219 esc_html__( 'Learn more about %s features', 'wp-2fa' ),
2220 wp_strip_all_tags( $label )
2221 );
2222
2223 $context_attr = '' !== $context ? ' data-wp2fa-premium-context="' . esc_attr( $context ) . '"' : '';
2224
2225 return '<span class="wp2fa-premium-badge" role="button" tabindex="0" data-wp2fa-premium-dialog-trigger="1"' . $context_attr . ' aria-label="' . esc_attr( $aria_label ) . '">'
2226 . '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">'
2227 . '<path d="M4.66699 6.41667V4.08333C4.66699 3.46449 4.91282 2.871 5.35041 2.43342C5.78799 1.99583 6.38149 1.75 7.00033 1.75C7.61916 1.75 8.21266 1.99583 8.65024 2.43342C9.08783 2.871 9.33366 3.46449 9.33366 4.08333V6.41667M2.91699 7.58333C2.91699 7.27391 3.03991 6.97717 3.2587 6.75838C3.47749 6.53958 3.77424 6.41667 4.08366 6.41667H9.91699C10.2264 6.41667 10.5232 6.53958 10.742 6.75838C10.9607 6.97717 11.0837 7.27391 11.0837 7.58333V11.0833C11.0837 11.3928 10.9607 11.6895 10.742 11.9083C10.5232 12.1271 10.2264 12.25 9.91699 12.25H4.08366C3.77424 12.25 3.47749 12.1271 3.2587 11.9083C3.03991 11.6895 2.91699 11.3928 2.91699 11.0833V7.58333ZM6.41699 9.33333C6.41699 9.48804 6.47845 9.63642 6.58785 9.74581C6.69724 9.85521 6.84562 9.91667 7.00033 9.91667C7.15504 9.91667 7.30341 9.85521 7.4128 9.74581C7.5222 9.63642 7.58366 9.48804 7.58366 9.33333C7.58366 9.17862 7.5222 9.03025 7.4128 8.92085C7.30341 8.81146 7.15504 8.75 7.00033 8.75C6.84562 8.75 6.69724 8.81146 6.58785 8.92085C6.47845 9.03025 6.41699 9.17862 6.41699 9.33333Z" stroke="#646970" stroke-width="1.3125" stroke-linecap="round" stroke-linejoin="round"/>'
2228 . '</svg> '
2229 . esc_html( $label )
2230 . '</span>';
2231 }
2232
2233 /**
2234 * Opens a premium-gated wrapper.
2235 *
2236 * When $is_premium is false the wrapper disables all contained inputs
2237 * and applies premium styling. Call premium_gate_close() to end the block.
2238 *
2239 * @param bool $is_premium Whether the current install has a valid premium license.
2240 *
2241 * @return void
2242 *
2243 * @since 4.1.0
2244 */
2245 public static function premium_gate_open( bool $is_premium ) {
2246 $class = $is_premium ? '' : ' wp2fa-premium-gate-locked';
2247 ?>
2248 <div class="wp2fa-premium-gate<?php echo \esc_attr( $class ); ?>">
2249 <?php
2250 }
2251
2252 /**
2253 * Closes a premium-gated wrapper.
2254 *
2255 * @return void
2256 *
2257 * @since 4.1.0
2258 */
2259 public static function premium_gate_close() {
2260 ?>
2261 </div>
2262 <?php
2263 }
2264 }
2265 }
2266