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 / js / wp2fa-wizard.js
wp-2fa / js Last commit date
passkeys 1 day ago profile 1 day ago wp2fa-dialog.js 1 day ago wp2fa-premium-badge-dialog.js 1 day ago wp2fa-wizard-backup-codes.js 1 day ago wp2fa-wizard-email.js 1 day ago wp2fa-wizard-totp.js 1 day ago wp2fa-wizard.js 1 day ago
wp2fa-wizard.js
1367 lines
1 /**
2 * WP 2FA - Setup Wizard Core
3 *
4 * A fully extensible, wp.hooks-driven wizard for configuring 2FA methods
5 * on a per-user basis. Methods register themselves via hooks so the wizard
6 * is decoupled from any specific 2FA provider.
7 *
8 * @package wp2fa
9 * @since 4.0.0
10 *
11 * Hooks reference (all via wp.hooks):
12 *
13 * Filters:
14 * wp2fa_wizard_methods - Collect enabled methods for current user.
15 * Each method adds an object: { id, name, description, order }
16 * wp2fa_wizard_backup_methods - Collect enabled backup/secondary methods for current user.
17 * Each backup method adds: { id, name, description, order }
18 * wp2fa_wizard_step_title - Filter the title shown for a given step.
19 * wp2fa_wizard_can_proceed - Whether the "Continue" button should be enabled.
20 * wp2fa_wizard_modal_classes - Filter additional CSS classes on the modal container.
21 *
22 * Actions:
23 * wp2fa_wizard_opened - Fired when wizard modal opens.
24 * wp2fa_wizard_closed - Fired when wizard modal closes.
25 * wp2fa_wizard_method_selected - Fired when a method is selected (args: methodId).
26 * wp2fa_wizard_step_shown - Fired when a wizard step becomes visible (args: stepId).
27 * wp2fa_wizard_render_method - Render method-specific configuration UI (args: methodId, containerEl).
28 * wp2fa_wizard_render_backup_method - Render backup-method UI (args: methodId, containerEl, context).
29 * wp2fa_wizard_before_proceed - Fired before moving to next step (args: currentStepId, nextStepId).
30 * wp2fa_wizard_after_proceed - Fired after moving to next step (args: newStepId).
31 * wp2fa_wizard_before_back - Fired before going back a step.
32 * wp2fa_wizard_after_back - Fired after going back a step.
33 * wp2fa_wizard_validate_method - Fired when user submits verification for a method (args: methodId, containerEl).
34 * wp2fa_wizard_method_validated - Fired when a method has been validated successfully (args: methodId).
35 * The core listens for this and proceeds to backup methods or closes.
36 * wp2fa_wizard_init - Fired after the wizard core initializes.
37 * wp2fa_wizard_complete - Fired when the entire wizard flow is complete (no more steps).
38 */
39 ( function () {
40 'use strict';
41
42 /* -------------------------------------------------------
43 * Guard: wp.hooks must be available
44 * ------------------------------------------------------- */
45 if ( ! window.wp || ! window.wp.hooks ) {
46 // eslint-disable-next-line no-console
47 console.error( '[WP2FA Wizard] wp.hooks is required. Ensure "wp-hooks" is a script dependency.' );
48 return;
49 }
50
51 var hooks = wp.hooks;
52
53 /**
54 * Validate that a URL is same-origin before redirecting.
55 * Returns the URL if safe, or the current page URL as fallback.
56 */
57 function safeSameOriginUrl( url ) {
58 if ( ! url || '' === url.trim() ) {
59 return window.location.href;
60 }
61 try {
62 var parsed = new URL( url, window.location.origin );
63 if ( parsed.origin === window.location.origin ) {
64 return parsed.href;
65 }
66 } catch ( e ) {}
67 return window.location.href;
68 }
69
70 function isPrimaryWizardButton( el ) {
71 if ( ! el || ! el.classList ) {
72 return false;
73 }
74
75 return el.classList.contains( 'wp2fa-wizard-btn-primary' ) || el.classList.contains( 'wp-2fa-button-primary' ) || el.classList.contains( 'button-primary' );
76 }
77
78 function getPrimaryFooterButton( modal ) {
79 if ( ! modal ) {
80 return null;
81 }
82
83 return modal.querySelector( '#wp2fa-wizard-footer .wp2fa-wizard-btn-primary:not([disabled]), #wp2fa-wizard-footer .wp-2fa-button-primary:not([disabled]), #wp2fa-wizard-footer .button-primary:not([disabled])' );
84 }
85
86 /* -------------------------------------------------------
87 * Internal state
88 * ------------------------------------------------------- */
89 var state = {
90 currentStep: 'select-method', // 'select-method' | 'configure-method' | 'backup-method' | 'select-backup-method'
91 selectedMethod: null,
92 selectedBackupMethod: null,
93 methods: [],
94 backupMethods: [],
95 currentBackupIndex: 0,
96 userId: 0,
97 userRole: '',
98 nonce: '',
99 overlay: null,
100 modal: null,
101 wizardCompleted: false
102 };
103
104 /* -------------------------------------------------------
105 * DOM helpers
106 * ------------------------------------------------------- */
107 function el( tag, attrs, children ) {
108 var node = document.createElement( tag );
109 if ( attrs ) {
110 Object.keys( attrs ).forEach( function ( key ) {
111 if ( key === 'className' ) {
112 node.className = attrs[ key ];
113 } else if ( key === 'textContent' ) {
114 node.textContent = attrs[ key ];
115 } else if ( key === 'innerHTML' ) {
116 node.innerHTML = attrs[ key ];
117 } else if ( key.indexOf( 'data-' ) === 0 || key === 'id' || key === 'type' || key === 'name' || key === 'value' || key === 'for' || key === 'placeholder' || key === 'autocomplete' || key === 'pattern' || key === 'readonly' || key === 'disabled' || key === 'checked' || key === 'aria-label' || key === 'role' || key === 'tabindex' || key === 'aria-hidden' ) {
118 node.setAttribute( key, attrs[ key ] );
119 }
120 } );
121 }
122 if ( children ) {
123 if ( ! Array.isArray( children ) ) {
124 children = [ children ];
125 }
126 children.forEach( function ( child ) {
127 if ( typeof child === 'string' ) {
128 node.appendChild( document.createTextNode( child ) );
129 } else if ( child instanceof Node ) {
130 node.appendChild( child );
131 }
132 } );
133 }
134 return node;
135 }
136
137 /* -------------------------------------------------------
138 * Collect enabled methods via hook
139 * ------------------------------------------------------- */
140 function collectMethods() {
141 /**
142 * Filter: wp2fa_wizard_methods
143 *
144 * Each method module should hook into this and push its descriptor:
145 * { id: 'totp', name: 'One-time code via 2FA App (TOTP)', description: '...', order: 1 }
146 *
147 * The second argument is the user context: { userId, userRole }
148 */
149 var methods = hooks.applyFilters( 'wp2fa_wizard_methods', [], {
150 userId: state.userId,
151 userRole: state.userRole
152 } );
153
154 // Sort by order (use != null to correctly handle order 0)
155 methods.sort( function ( a, b ) {
156 return ( a.order != null ? a.order : 99 ) - ( b.order != null ? b.order : 99 );
157 } );
158
159 state.methods = methods;
160 return methods;
161 }
162
163 /* -------------------------------------------------------
164 * Collect enabled backup/secondary methods via hook
165 * ------------------------------------------------------- */
166 function collectBackupMethods() {
167 /**
168 * Filter: wp2fa_wizard_backup_methods
169 *
170 * Each backup method module should hook into this and push its descriptor:
171 * { id: 'backup_codes', name: 'Backup Codes', description: '...', order: 1 }
172 *
173 * The second argument is the user context: { userId, userRole }
174 */
175 var backupMethods = hooks.applyFilters( 'wp2fa_wizard_backup_methods', [], {
176 userId: state.userId,
177 userRole: state.userRole
178 } );
179
180 // Deduplicate by method id (the same JS file may be loaded
181 // under different script handles, registering the filter twice).
182 var seen = {};
183 backupMethods = backupMethods.filter( function ( m ) {
184 if ( seen[ m.id ] ) {
185 return false;
186 }
187 seen[ m.id ] = true;
188 return true;
189 } );
190
191 // Sort by order (use != null to correctly handle order 0)
192 backupMethods.sort( function ( a, b ) {
193 return ( a.order != null ? a.order : 99 ) - ( b.order != null ? b.order : 99 );
194 } );
195
196 state.backupMethods = backupMethods;
197 return backupMethods;
198 }
199
200 /* -------------------------------------------------------
201 * Post-validation flow: backup methods or close
202 *
203 * Listens for wp2fa_wizard_method_validated and decides
204 * whether to show backup method steps or finish the wizard.
205 * ------------------------------------------------------- */
206 function handleMethodValidated( methodId ) {
207 // Collect backup methods available for this user
208 collectBackupMethods();
209
210 // Only allow email backup when the primary method is one of the
211 // supported types (TOTP, Authy, Yubico, Twilio, Clickatell).
212 var emailBackupAllowedMethods = [ 'totp', 'authy', 'yubico', 'twilio', 'clickatell' ];
213 if ( emailBackupAllowedMethods.indexOf( state.selectedMethod ) === -1 ) {
214 state.backupMethods = state.backupMethods.filter( function ( m ) {
215 return m.id !== 'backup_email';
216 } );
217 }
218
219 if ( state.backupMethods.length > 1 ) {
220 // Multiple backup methods — show the selection step.
221 state.selectedBackupMethod = null;
222 setTimeout( function () {
223 renderBackupMethodSelectionStep();
224 }, 800 );
225 } else if ( state.backupMethods.length === 1 ) {
226 // Only one backup method — go directly to its setup.
227 state.currentBackupIndex = 0;
228 setTimeout( function () {
229 renderBackupMethodStep( state.currentBackupIndex );
230 }, 800 );
231 } else {
232 // No backup methods — show completion step
233 setTimeout( function () {
234 renderCompletionStep();
235 }, 800 );
236 }
237 }
238
239 /* -------------------------------------------------------
240 * Render the backup method selection step
241 *
242 * Shown after primary method validation when more than one
243 * backup method is available. The user picks which backup
244 * method to configure next.
245 * ------------------------------------------------------- */
246 function renderBackupMethodSelectionStep() {
247 var body = state.modal.querySelector( '#wp2fa-wizard-body' );
248 body.innerHTML = '';
249
250 var title = state.modal.querySelector( '#wp2fa-wizard-title' );
251 title.innerHTML = '';
252
253 // Decide intro text: use multi-method text when available, fall back to the single-method continue text.
254 var introTitle = wp2faWizardData.i18n.backupSelectTitle || '';
255 var introDesc = wp2faWizardData.i18n.backupSelectDescription || '';
256 var sectionTitle = wp2faWizardData.i18n.backupSelectSectionTitle || '';
257
258 // Render intro banner (green checkmark + text).
259 var introTmpl = wp.template( 'wp2fa-wizard-backup-select-intro' );
260 body.innerHTML = introTmpl( {
261 title: introTitle,
262 description: introDesc,
263 sectionTitle: sectionTitle
264 } );
265
266 // Build the radio list of backup methods.
267 var methodItemTmpl = wp.template( 'wp2fa-wizard-backup-method-item' );
268 var list = document.createElement( 'ul' );
269 list.className = 'wp2fa-wizard-methods-list';
270 list.id = 'wp2fa-wizard-backup-methods-list';
271 list.setAttribute( 'role', 'radiogroup' );
272
273 state.backupMethods.forEach( function ( method, index ) {
274 var checked = false;
275 if ( index === 0 && ! state.selectedBackupMethod ) {
276 checked = true;
277 state.selectedBackupMethod = method.id;
278 } else if ( state.selectedBackupMethod === method.id ) {
279 checked = true;
280 }
281
282 list.innerHTML += methodItemTmpl( {
283 id: method.id,
284 name: method.name,
285 description: method.description || '',
286 checked: checked
287 } );
288 } );
289
290 // Attach radio change listeners.
291 var radios = list.querySelectorAll( '.wp2fa-wizard-backup-method-radio' );
292 radios.forEach( function ( radio ) {
293 radio.addEventListener( 'change', function () {
294 state.selectedBackupMethod = this.value;
295 } );
296 } );
297
298 body.appendChild( list );
299
300 // Footer buttons.
301 var footer = state.modal.querySelector( '#wp2fa-wizard-footer' );
302 footer.innerHTML = '';
303
304 var footerTmpl = wp.template( 'wp2fa-wizard-backup-select-footer' );
305 footer.innerHTML = footerTmpl( {
306 closeLaterLabel: wp2faWizardData.i18n.closeLater || 'Close wizard',
307 configureLabel: wp2faWizardData.i18n.configureBackup || 'Continue',
308 disabled: ! state.selectedBackupMethod
309 } );
310
311 footer.querySelector( '#wp2fa-wizard-backup-select-btn-close' ).addEventListener( 'click', function () {
312 finishWizard();
313 } );
314
315 footer.querySelector( '#wp2fa-wizard-backup-select-btn-configure' ).addEventListener( 'click', function () {
316 if ( ! state.selectedBackupMethod ) {
317 return;
318 }
319 // Find the index of the selected backup method and render its setup step.
320 for ( var i = 0; i < state.backupMethods.length; i++ ) {
321 if ( state.backupMethods[ i ].id === state.selectedBackupMethod ) {
322 state.currentBackupIndex = i;
323 renderBackupMethodStep( i );
324 return;
325 }
326 }
327 } );
328
329 state.currentStep = 'select-backup-method';
330 hooks.doAction( 'wp2fa_wizard_step_shown', 'select-backup-method' );
331 focusFirstInteractive();
332 }
333
334 /* -------------------------------------------------------
335 * Render a backup method step
336 * ------------------------------------------------------- */
337 function renderBackupMethodStep( index ) {
338 if ( index >= state.backupMethods.length ) {
339 finishWizard();
340 return;
341 }
342
343 var backupMethod = state.backupMethods[ index ];
344 var body = state.modal.querySelector( '#wp2fa-wizard-body' );
345 body.innerHTML = '';
346
347 var title = state.modal.querySelector( '#wp2fa-wizard-title' );
348 title.innerHTML = hooks.applyFilters(
349 'wp2fa_wizard_step_title',
350 backupMethod.name || 'Backup Method',
351 'backup-method-' + backupMethod.id
352 );
353
354 var container = el( 'div', {
355 className: 'wp2fa-wizard-backup-method-configure',
356 id: 'wp2fa-wizard-backup-method-configure-' + backupMethod.id,
357 'data-backup-method-id': backupMethod.id
358 } );
359
360 body.appendChild( container );
361
362 /**
363 * Action: wp2fa_wizard_render_backup_method
364 *
365 * Each backup method module listens for this and renders its UI
366 * into the provided container element.
367 */
368 hooks.doAction( 'wp2fa_wizard_render_backup_method', backupMethod.id, container, {
369 userId: state.userId,
370 userRole: state.userRole,
371 nonce: state.nonce
372 } );
373
374 // Footer: Skip + Close (backup methods can add their own buttons).
375 var footer = state.modal.querySelector( '#wp2fa-wizard-footer' );
376 footer.innerHTML = '';
377
378 var backupFooterTmpl = wp.template( 'wp2fa-wizard-backup-footer' );
379 footer.innerHTML = backupFooterTmpl( {
380 skipLabel: wp2faWizardData.i18n.skipBackup || "I'll set this up later"
381 } );
382
383 footer.querySelector( '#wp2fa-wizard-backup-btn-skip' ).addEventListener( 'click', function () {
384 finishWizard();
385 } );
386
387 state.currentStep = 'backup-method';
388 hooks.doAction( 'wp2fa_wizard_step_shown', 'backup-method-' + backupMethod.id );
389 focusFirstInteractive();
390 }
391
392 /* -------------------------------------------------------
393 * Advance to the next backup method (called by modules)
394 * ------------------------------------------------------- */
395 function nextBackupMethod() {
396 finishWizard();
397 }
398
399 /* -------------------------------------------------------
400 * Render the completion step
401 *
402 * Shown at the end of the wizard when no backup methods
403 * are available. Displays the "no further action" white-label
404 * text with a close button.
405 * ------------------------------------------------------- */
406 function renderCompletionStep() {
407 var body = state.modal.querySelector( '#wp2fa-wizard-body' );
408 body.innerHTML = '';
409
410 var title = state.modal.querySelector( '#wp2fa-wizard-title' );
411 title.innerHTML = '';
412
413 var contentHtml = wp2faWizardData.i18n.noFurtherAction || '';
414
415 var bodyTmpl = wp.template( 'wp2fa-wizard-completion' );
416 body.innerHTML = bodyTmpl( {
417 contentHtml: contentHtml
418 } );
419
420 // Footer with close button.
421 var footer = state.modal.querySelector( '#wp2fa-wizard-footer' );
422 footer.innerHTML = '';
423
424 var footerTmpl = wp.template( 'wp2fa-wizard-completion-footer' );
425 footer.innerHTML = footerTmpl( {
426 closeLabel: wp2faWizardData.i18n.closeWizard || 'Close wizard'
427 } );
428
429 // Close button handler.
430 var closeBtn = footer.querySelector( '#wp2fa-wizard-btn-completion-close' );
431 if ( closeBtn ) {
432 closeBtn.addEventListener( 'click', function () {
433 finishWizard();
434 } );
435 }
436 }
437
438 /* -------------------------------------------------------
439 * Finish wizard: fire complete action, then close + reload
440 * ------------------------------------------------------- */
441 function finishWizard() {
442 state.wizardCompleted = true;
443 hooks.doAction( 'wp2fa_wizard_complete' );
444 setTimeout( function () {
445 closeWizard( true );
446
447 // If re-login after 2FA setup is enabled, log the user out first.
448 if ( wp2faWizardData.reLoginEnabled && wp2faWizardData.reLogin && wp2faWizardData.reLoginEnabled === wp2faWizardData.reLogin.trim() ) {
449 var xhr = new XMLHttpRequest();
450 xhr.open( 'POST', wp2faWizardData.ajaxUrl, true );
451 xhr.setRequestHeader( 'Content-Type', 'application/x-www-form-urlencoded' );
452 xhr.onreadystatechange = function () {
453 if ( xhr.readyState === 4 ) {
454 window.location.replace( safeSameOriginUrl( wp2faWizardData.loginUrl || wp2faWizardData.redirectToUrl ) );
455 }
456 };
457 xhr.send( 'action=custom_ajax_logout&_wpnonce=' + encodeURIComponent( wp2faWizardData.nonce ) );
458 return;
459 }
460
461 if ( wp2faWizardData.redirectToUrl && '' !== wp2faWizardData.redirectToUrl.trim() ) {
462 window.location.replace( safeSameOriginUrl( wp2faWizardData.redirectToUrl ) );
463 } else if ( wp2faWizardData.reloadOnSuccess ) {
464 var url = new URL( window.location.href );
465 if ( url.searchParams.get( 'show' ) === 'wp-2fa-setup' ) {
466 url.searchParams.delete( 'show' );
467 window.location.replace( url.toString() );
468 } else {
469 window.location.reload();
470 }
471 }
472 }, 800 );
473 }
474
475 /* -------------------------------------------------------
476 * Check whether the required intro step should be shown
477 *
478 * Shown only for enforced users who haven't seen it yet,
479 * when the setting content is provided and not empty.
480 * ------------------------------------------------------- */
481 function hasRequiredIntroStep() {
482 return typeof wp2faWizardData.requiredIntroHtml === 'string' && wp2faWizardData.requiredIntroHtml.length > 0;
483 }
484
485 /* -------------------------------------------------------
486 * Build & render the required intro step
487 *
488 * This is the very first step shown to enforced users.
489 * On "Continue", an AJAX call marks the intro as seen
490 * so it won't appear again.
491 * ------------------------------------------------------- */
492 function renderRequiredIntroStep() {
493 var body = state.modal.querySelector( '#wp2fa-wizard-body' );
494 body.innerHTML = '';
495
496 var title = state.modal.querySelector( '#wp2fa-wizard-title' );
497 title.innerHTML = hooks.applyFilters( 'wp2fa_wizard_step_title', '', 'required-intro' );
498
499 var introTmpl = wp.template( 'wp2fa-wizard-required-intro' );
500 body.innerHTML = introTmpl( {
501 contentHtml: wp2faWizardData.requiredIntroHtml
502 } );
503
504 // Footer.
505 var footer = state.modal.querySelector( '#wp2fa-wizard-footer' );
506 footer.innerHTML = '';
507
508 var footerTmpl = wp.template( 'wp2fa-wizard-required-intro-footer' );
509 footer.innerHTML = footerTmpl( {
510 continueLabel: wp2faWizardData.i18n.continueBtn || 'Continue'
511 } );
512
513 footer.querySelector( '#wp2fa-wizard-btn-required-intro-continue' ).addEventListener( 'click', function () {
514 // Mark intro as seen via AJAX (fire-and-forget).
515 var xhr = new XMLHttpRequest();
516 xhr.open( 'POST', wp2faWizardData.ajaxUrl, true );
517 xhr.setRequestHeader( 'Content-Type', 'application/x-www-form-urlencoded' );
518 xhr.send(
519 'action=wp2fa_dismiss_required_intro&nonce=' + encodeURIComponent( wp2faWizardData.dismissIntroNonce )
520 );
521
522 // Clear the HTML so the step won't appear again in this session.
523 wp2faWizardData.requiredIntroHtml = '';
524
525 hooks.doAction( 'wp2fa_wizard_before_proceed', 'required-intro', 'welcome' );
526
527 // Proceed to the next appropriate step.
528 if ( hasWelcomeStep() ) {
529 renderWelcomeStep();
530 } else if ( state.methods.length === 1 ) {
531 state.selectedMethod = state.methods[ 0 ].id;
532 hooks.doAction( 'wp2fa_wizard_method_selected', state.selectedMethod );
533 renderMethodConfigureStep();
534 } else {
535 renderMethodSelectionStep();
536 }
537
538 hooks.doAction( 'wp2fa_wizard_after_proceed', state.currentStep );
539 } );
540
541 state.currentStep = 'required-intro';
542 hooks.doAction( 'wp2fa_wizard_step_shown', 'required-intro' );
543 focusFirstInteractive();
544 }
545
546 /* -------------------------------------------------------
547 * Check whether the welcome step should be shown
548 * ------------------------------------------------------- */
549 function hasWelcomeStep() {
550 return typeof wp2faWizardData.welcomeHtml === 'string' && wp2faWizardData.welcomeHtml.length > 0;
551 }
552
553 /* -------------------------------------------------------
554 * Build & render the welcome step
555 * ------------------------------------------------------- */
556 function renderWelcomeStep() {
557 var body = state.modal.querySelector( '#wp2fa-wizard-body' );
558 body.innerHTML = '';
559
560 var title = state.modal.querySelector( '#wp2fa-wizard-title' );
561 title.innerHTML = hooks.applyFilters( 'wp2fa_wizard_step_title', '', 'welcome' );
562
563 var welcomeTmpl = wp.template( 'wp2fa-wizard-welcome' );
564 body.innerHTML = welcomeTmpl( {
565 contentHtml: wp2faWizardData.welcomeHtml
566 } );
567
568 // Footer.
569 var footer = state.modal.querySelector( '#wp2fa-wizard-footer' );
570 footer.innerHTML = '';
571
572 var footerTmpl = wp.template( 'wp2fa-wizard-welcome-footer' );
573 footer.innerHTML = footerTmpl( {
574 goBackLabel: wp2faWizardData.i18n.goBack || 'Go back',
575 continueLabel: wp2faWizardData.i18n.continueBtn || 'Continue'
576 } );
577
578 footer.querySelector( '#wp2fa-wizard-btn-goback' ).addEventListener( 'click', function () {
579 closeWizard();
580 } );
581
582 footer.querySelector( '#wp2fa-wizard-btn-continue' ).addEventListener( 'click', function () {
583 hooks.doAction( 'wp2fa_wizard_before_proceed', 'welcome', 'select-method' );
584 // If only one method, skip selection and go directly to configure.
585 if ( state.methods.length === 1 ) {
586 state.selectedMethod = state.methods[ 0 ].id;
587 hooks.doAction( 'wp2fa_wizard_method_selected', state.selectedMethod );
588 renderMethodConfigureStep();
589 hooks.doAction( 'wp2fa_wizard_after_proceed', 'configure-method' );
590 } else {
591 renderMethodSelectionStep();
592 hooks.doAction( 'wp2fa_wizard_after_proceed', 'select-method' );
593 }
594 } );
595
596 state.currentStep = 'welcome';
597 hooks.doAction( 'wp2fa_wizard_step_shown', 'welcome' );
598 focusFirstInteractive();
599 }
600
601 /* -------------------------------------------------------
602 * Reconfiguration helpers
603 * ------------------------------------------------------- */
604
605 /**
606 * Check if we are in reconfiguration mode (user already has 2FA set up).
607 */
608 function isReconfiguring() {
609 return !! wp2faWizardData.isReconfiguration;
610 }
611
612 /**
613 * Replace reconfiguration placeholders in text.
614 *
615 * @param {string} text - The raw text with placeholders.
616 * @param {string} methodId - The method ID being rendered.
617 * @return {string} Text with placeholders replaced.
618 */
619 function replaceReconfigurePlaceholders( text, methodId ) {
620 if ( ! text ) {
621 return '';
622 }
623
624 var currentMethod = wp2faWizardData.currentMethod || '';
625 var isCurrentMethod = ( currentMethod === methodId );
626 var capitalizedText = isCurrentMethod ? ( wp2faWizardData.i18n.reconfigureCapitalized || 'Reconfigure' ) : ( wp2faWizardData.i18n.configureCapitalized || 'Configure' );
627 var lowerText = isCurrentMethod ? ( wp2faWizardData.i18n.reconfigureLower || 'reconfigure' ) : ( wp2faWizardData.i18n.configureLower || 'configure' );
628
629 return text
630 .replace( /\{reconfigure_or_configure_capitalized\}/g, capitalizedText )
631 .replace( /\{reconfigure_or_configure\}/g, lowerText );
632 }
633
634 /**
635 * Get the reconfigure intro text for a given method.
636 *
637 * @param {string} methodId - The method ID.
638 * @return {string} The HTML intro text (with placeholders replaced), or empty string.
639 */
640 function getReconfigureIntro( methodId ) {
641 var intros = wp2faWizardData.reconfigureIntros || {};
642 return replaceReconfigurePlaceholders( intros[ methodId ] || '', methodId );
643 }
644
645 /**
646 * Get the unavailable reconfigure intro text for a given method.
647 *
648 * @param {string} methodId - The method ID.
649 * @return {string} The HTML unavailable text (with placeholders replaced), or empty string.
650 */
651 function getReconfigureIntroUnavailable( methodId ) {
652 var intros = wp2faWizardData.reconfigureIntrosUnavailable || {};
653 return replaceReconfigurePlaceholders( intros[ methodId ] || '', methodId );
654 }
655
656 /**
657 * Check if a method is marked as unavailable.
658 *
659 * @param {string} methodId - The method ID.
660 * @return {boolean}
661 */
662 function isMethodUnavailable( methodId ) {
663 var unavailable = wp2faWizardData.unavailableMethods || [];
664 return unavailable.indexOf( methodId ) !== -1;
665 }
666
667 /* -------------------------------------------------------
668 * Build & render the method selection step
669 * ------------------------------------------------------- */
670 function renderMethodSelectionStep() {
671 var body = state.modal.querySelector( '#wp2fa-wizard-body' );
672 body.innerHTML = '';
673
674 var title = state.modal.querySelector( '#wp2fa-wizard-title' );
675 var chooseMethodText = ( wp2faWizardData.i18n.chooseMethod || 'Choose 2FA Method' )
676 .replace( '{available_methods_count}', String( state.methods.length ) );
677 title.innerHTML = hooks.applyFilters( 'wp2fa_wizard_step_title', chooseMethodText, 'select-method' );
678
679 var methodItemTmpl = wp.template( 'wp2fa-wizard-method-item' );
680 var list = document.createElement( 'ul' );
681 list.className = 'wp2fa-wizard-methods-list';
682 list.id = 'wp2fa-wizard-methods-list';
683 list.setAttribute( 'role', 'radiogroup' );
684
685 state.methods.forEach( function ( method, index ) {
686 var checked = false;
687 if ( index === 0 && ! state.selectedMethod ) {
688 checked = true;
689 state.selectedMethod = method.id;
690 } else if ( state.selectedMethod === method.id ) {
691 checked = true;
692 }
693
694 var hints = wp2faWizardData.methodHints || {};
695 var description = method.description || '';
696 var unavailable = false;
697
698 // In reconfiguration mode, show reconfigure intro texts.
699 if ( isReconfiguring() ) {
700 if ( isMethodUnavailable( method.id ) ) {
701 unavailable = true;
702 description = getReconfigureIntroUnavailable( method.id ) || description;
703 // Do not pre-select unavailable methods.
704 if ( checked && state.selectedMethod === method.id ) {
705 checked = false;
706 state.selectedMethod = null;
707 }
708 } else {
709 var reconfigureText = getReconfigureIntro( method.id );
710 if ( reconfigureText ) {
711 description = reconfigureText;
712 }
713 }
714 }
715
716 list.innerHTML += methodItemTmpl( {
717 id: method.id,
718 name: method.name,
719 description: description,
720 hint: hints[ method.id ] || '',
721 checked: checked,
722 unavailable: unavailable
723 } );
724 } );
725
726 // If no method is selected (e.g. all pre-selected were unavailable), pick first available.
727 if ( ! state.selectedMethod ) {
728 for ( var i = 0; i < state.methods.length; i++ ) {
729 if ( ! isMethodUnavailable( state.methods[ i ].id ) ) {
730 state.selectedMethod = state.methods[ i ].id;
731 var radioToCheck = list.querySelector( '#wp2fa-wizard-method-' + state.methods[ i ].id );
732 if ( radioToCheck ) {
733 radioToCheck.checked = true;
734 }
735 break;
736 }
737 }
738 }
739
740 // Attach radio change listeners.
741 var radios = list.querySelectorAll( '.wp2fa-wizard-method-radio' );
742 radios.forEach( function ( radio ) {
743 radio.addEventListener( 'change', function () {
744 state.selectedMethod = this.value;
745 hooks.doAction( 'wp2fa_wizard_method_selected', this.value );
746 updateFooterButtons();
747 } );
748 } );
749
750 // Attach hint toggle listeners.
751 var hintToggles = list.querySelectorAll( '.wp2fa-wizard-hint-toggle' );
752 hintToggles.forEach( function ( btn ) {
753 btn.addEventListener( 'click', function ( e ) {
754 e.preventDefault();
755 e.stopPropagation();
756 var methodId = this.getAttribute( 'data-hint-for' );
757 var hintEl = list.querySelector( '#wp2fa-wizard-hint-' + methodId );
758 if ( hintEl ) {
759 var isVisible = hintEl.style.display !== 'none';
760 hintEl.style.display = isVisible ? 'none' : '';
761 this.classList.toggle( 'wp2fa-wizard-hint-active', ! isVisible );
762 }
763 } );
764 } );
765
766 body.appendChild( list );
767
768 // Footer.
769 renderFooter( 'select-method' );
770
771 state.currentStep = 'select-method';
772 hooks.doAction( 'wp2fa_wizard_step_shown', 'select-method' );
773 focusFirstInteractive();
774 }
775
776 /* -------------------------------------------------------
777 * Build & render the method configuration step
778 * ------------------------------------------------------- */
779 function renderMethodConfigureStep() {
780 var body = state.modal.querySelector( '#wp2fa-wizard-body' );
781 body.innerHTML = '';
782
783 var methodObj = getMethodById( state.selectedMethod );
784 var stepTitle = methodObj ? methodObj.name : 'Configure Method';
785 var title = state.modal.querySelector( '#wp2fa-wizard-title' );
786 title.innerHTML = hooks.applyFilters( 'wp2fa_wizard_step_title', stepTitle, 'configure-method' );
787
788 var container = el( 'div', {
789 className: 'wp2fa-wizard-method-configure',
790 id: 'wp2fa-wizard-method-configure-' + state.selectedMethod,
791 'data-method-id': state.selectedMethod
792 } );
793
794 body.appendChild( container );
795
796 /**
797 * Action: wp2fa_wizard_render_method
798 *
799 * Each method module listens for this and renders its configuration UI
800 * into the provided container element.
801 */
802 hooks.doAction( 'wp2fa_wizard_render_method', state.selectedMethod, container, {
803 userId: state.userId,
804 userRole: state.userRole,
805 nonce: state.nonce
806 } );
807
808 // Footer
809 // renderFooter( 'configure-method' );
810
811 state.currentStep = 'configure-method';
812 hooks.doAction( 'wp2fa_wizard_step_shown', 'configure-method' );
813 focusFirstInteractive();
814 }
815
816 /* -------------------------------------------------------
817 * Footer buttons
818 * ------------------------------------------------------- */
819 function renderFooter( step ) {
820 var footer = state.modal.querySelector( '#wp2fa-wizard-footer' );
821 footer.innerHTML = '';
822
823 if ( step === 'select-method' ) {
824 var selectFooterTmpl = wp.template( 'wp2fa-wizard-select-footer' );
825 var canProceed = hooks.applyFilters( 'wp2fa_wizard_can_proceed', !! state.selectedMethod, 'select-method' );
826
827 footer.innerHTML = selectFooterTmpl( {
828 goBackLabel: wp2faWizardData.i18n.goBack || 'Go back',
829 continueLabel: wp2faWizardData.i18n.continueBtn || 'Continue',
830 disabled: ! canProceed
831 } );
832
833 footer.querySelector( '#wp2fa-wizard-btn-goback' ).addEventListener( 'click', function () {
834 if ( hasWelcomeStep() ) {
835 hooks.doAction( 'wp2fa_wizard_before_back', 'select-method', 'welcome' );
836 renderWelcomeStep();
837 hooks.doAction( 'wp2fa_wizard_after_back', 'welcome' );
838 } else {
839 closeWizard();
840 }
841 } );
842
843 footer.querySelector( '#wp2fa-wizard-btn-continue' ).addEventListener( 'click', function () {
844 if ( ! state.selectedMethod ) {
845 return;
846 }
847 hooks.doAction( 'wp2fa_wizard_before_proceed', 'select-method', 'configure-method' );
848 renderMethodConfigureStep();
849 hooks.doAction( 'wp2fa_wizard_after_proceed', 'configure-method' );
850 } );
851
852 } else if ( step === 'configure-method' ) {
853 var backBtn = el( 'button', {
854 className: 'wp2fa-wizard-btn wp2fa-wizard-btn-secondary wp-2fa-button-secondary',
855 id: 'wp2fa-wizard-btn-back',
856 type: 'button',
857 textContent: wp2faWizardData.i18n.goBack || 'Go back'
858 } );
859
860 backBtn.addEventListener( 'click', function () {
861 if ( state.methods.length <= 1 ) {
862 // No method selection to go back to.
863 if ( hasWelcomeStep() ) {
864 hooks.doAction( 'wp2fa_wizard_before_back', 'configure-method', 'welcome' );
865 renderWelcomeStep();
866 hooks.doAction( 'wp2fa_wizard_after_back', 'welcome' );
867 } else {
868 requestCloseWizard();
869 }
870 } else {
871 hooks.doAction( 'wp2fa_wizard_before_back', 'configure-method', 'select-method' );
872 renderMethodSelectionStep();
873 hooks.doAction( 'wp2fa_wizard_after_back', 'select-method' );
874 }
875 } );
876
877 footer.appendChild( backBtn );
878 }
879 }
880
881 function updateFooterButtons() {
882 var continueBtn = state.modal ? state.modal.querySelector( '#wp2fa-wizard-btn-continue' ) : null;
883 if ( continueBtn ) {
884 var canProceed = hooks.applyFilters( 'wp2fa_wizard_can_proceed', !! state.selectedMethod, state.currentStep );
885 continueBtn.disabled = ! canProceed;
886 }
887 }
888
889 /* -------------------------------------------------------
890 * Utility
891 * ------------------------------------------------------- */
892 function getMethodById( id ) {
893 for ( var i = 0; i < state.methods.length; i++ ) {
894 if ( state.methods[ i ].id === id ) {
895 return state.methods[ i ];
896 }
897 }
898 return null;
899 }
900
901 /* -------------------------------------------------------
902 * Build modal DOM (using wp.template)
903 * ------------------------------------------------------- */
904 function buildModal() {
905 var additionalClasses = hooks.applyFilters( 'wp2fa_wizard_modal_classes', '' );
906 var title = wp2faWizardData.i18n.chooseMethod || 'Choose 2FA Method';
907
908 var overlay = document.createElement( 'div' );
909 overlay.className = 'wp2fa-setup-wizard-overlay';
910 overlay.id = 'wp2fa-setup-wizard-overlay';
911 overlay.setAttribute( 'aria-hidden', 'true' );
912
913 var tmpl = wp.template( 'wp2fa-wizard-modal' );
914 overlay.innerHTML = tmpl( {
915 additionalClasses: additionalClasses,
916 title: title,
917 closeLabel: wp2faWizardData.i18n.close || 'Close'
918 } );
919
920 var modal = overlay.querySelector( '#wp2fa-setup-wizard-modal' );
921
922 // Close button event.
923 modal.querySelector( '#wp2fa-wizard-close' ).addEventListener( 'click', function () {
924 requestCloseWizard();
925 } );
926
927 // Click on overlay backdrop to close.
928 overlay.addEventListener( 'click', function ( e ) {
929 if ( e.target === overlay ) {
930 requestCloseWizard();
931 }
932 } );
933
934 // Global keyboard handler for the wizard.
935 document.addEventListener( 'keydown', function ( e ) {
936 if ( ! overlay.classList.contains( 'wp2fa-wizard-open' ) ) {
937 return;
938 }
939
940 // If the confirm dialog is visible, handle its own keys.
941 var confirmOverlay = document.getElementById( 'wp2fa-wizard-confirm-overlay' );
942 if ( confirmOverlay ) {
943 if ( e.key === 'Escape' ) {
944 e.preventDefault();
945 dismissConfirmDialog();
946 } else if ( e.key === 'Enter' ) {
947 var focused = document.activeElement;
948 // If focused on the "No" button, dismiss; otherwise confirm.
949 if ( focused && focused.id === 'wp2fa-wizard-confirm-no' ) {
950 e.preventDefault();
951 dismissConfirmDialog();
952 } else {
953 e.preventDefault();
954 confirmCloseWizard();
955 }
956 } else if ( e.key === 'Tab' ) {
957 trapFocus( e, confirmOverlay );
958 }
959 return;
960 }
961
962 if ( e.key === 'Escape' ) {
963 e.preventDefault();
964 requestCloseWizard();
965 } else if ( e.key === 'Enter' ) {
966 // Trigger the primary button in the footer when Enter is pressed,
967 // unless the user is focused on a non-primary button or a textarea.
968 var active = document.activeElement;
969 if ( active && ( active.tagName === 'TEXTAREA' || ( active.tagName === 'BUTTON' && ! isPrimaryWizardButton( active ) ) || active.tagName === 'A' ) ) {
970 return;
971 }
972 var primaryBtn = getPrimaryFooterButton( modal );
973 if ( primaryBtn ) {
974 e.preventDefault();
975 primaryBtn.click();
976 }
977 } else if ( e.key === 'Tab' ) {
978 trapFocus( e, modal );
979 }
980 } );
981
982 document.body.appendChild( overlay );
983
984 state.overlay = overlay;
985 state.modal = modal;
986 }
987
988 /* -------------------------------------------------------
989 * Accessibility: trap Tab focus within a container
990 * ------------------------------------------------------- */
991 function trapFocus( e, container ) {
992 var focusable = container.querySelectorAll(
993 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
994 );
995 if ( focusable.length === 0 ) {
996 return;
997 }
998 var first = focusable[ 0 ];
999 var last = focusable[ focusable.length - 1 ];
1000
1001 if ( e.shiftKey ) {
1002 if ( document.activeElement === first ) {
1003 e.preventDefault();
1004 last.focus();
1005 }
1006 } else {
1007 if ( document.activeElement === last ) {
1008 e.preventDefault();
1009 first.focus();
1010 }
1011 }
1012 }
1013
1014 /* -------------------------------------------------------
1015 * Accessibility: focus the first interactive element
1016 * in the wizard body, or fall back to the primary footer
1017 * button.
1018 * ------------------------------------------------------- */
1019 function focusFirstInteractive() {
1020 if ( ! state.modal ) {
1021 return;
1022 }
1023 var body = state.modal.querySelector( '#wp2fa-wizard-body' );
1024 if ( body ) {
1025 var target = body.querySelector(
1026 'input:not([disabled]):not([type="hidden"]):not([readonly]), textarea:not([disabled]), select:not([disabled]), a[href], button:not([disabled])'
1027 );
1028 if ( target ) {
1029 target.focus();
1030 return;
1031 }
1032 }
1033 var footer = state.modal.querySelector( '#wp2fa-wizard-footer' );
1034 if ( footer ) {
1035 var btn = getPrimaryFooterButton( state.modal );
1036 if ( btn ) {
1037 btn.focus();
1038 return;
1039 }
1040 }
1041 state.modal.focus();
1042 }
1043
1044 /* -------------------------------------------------------
1045 * Close confirmation dialog
1046 * ------------------------------------------------------- */
1047 function requestCloseWizard() {
1048 // If the wizard just completed (success state), close without confirmation.
1049 if ( state.wizardCompleted ) {
1050 closeWizard( true );
1051 return;
1052 }
1053 showConfirmCloseDialog();
1054 }
1055
1056 function showConfirmCloseDialog() {
1057 // Don't stack dialogs.
1058 if ( document.getElementById( 'wp2fa-wizard-confirm-overlay' ) ) {
1059 return;
1060 }
1061
1062 var tmpl = wp.template( 'wp2fa-wizard-confirm-close' );
1063 var html = tmpl( {
1064 bodyHtml: wp2faWizardData.i18n.wizardCancelBody || '<h3>Are you sure?</h3><p>Any unsaved changes will be lost!</p>',
1065 yesLabel: wp2faWizardData.i18n.confirmYes || 'Yes',
1066 noLabel: wp2faWizardData.i18n.confirmNo || 'No'
1067 } );
1068
1069 var wrapper = document.createElement( 'div' );
1070 wrapper.innerHTML = html;
1071 var confirmOverlay = wrapper.firstElementChild;
1072 state.modal.appendChild( confirmOverlay );
1073
1074 // Focus the "No" button by default (safe option).
1075 var noBtn = confirmOverlay.querySelector( '#wp2fa-wizard-confirm-no' );
1076 if ( noBtn ) {
1077 noBtn.focus();
1078 }
1079
1080 confirmOverlay.querySelector( '#wp2fa-wizard-confirm-yes' ).addEventListener( 'click', function () {
1081 confirmCloseWizard();
1082 } );
1083
1084 confirmOverlay.querySelector( '#wp2fa-wizard-confirm-no' ).addEventListener( 'click', function () {
1085 dismissConfirmDialog();
1086 } );
1087
1088 // Clicking the backdrop of the confirm overlay dismisses it.
1089 confirmOverlay.addEventListener( 'click', function ( e ) {
1090 if ( e.target === confirmOverlay ) {
1091 dismissConfirmDialog();
1092 }
1093 } );
1094 }
1095
1096 function confirmCloseWizard() {
1097 dismissConfirmDialog();
1098 closeWizard( true );
1099 }
1100
1101 function dismissConfirmDialog() {
1102 var confirmOverlay = document.getElementById( 'wp2fa-wizard-confirm-overlay' );
1103 if ( confirmOverlay && confirmOverlay.parentNode ) {
1104 confirmOverlay.parentNode.removeChild( confirmOverlay );
1105 }
1106 // Return focus to the modal.
1107 if ( state.modal ) {
1108 state.modal.focus();
1109 }
1110 }
1111
1112 /* -------------------------------------------------------
1113 * Open / Close wizard
1114 * ------------------------------------------------------- */
1115 function openWizard() {
1116 if ( ! state.overlay ) {
1117 buildModal();
1118 }
1119
1120 collectMethods();
1121
1122 if ( state.methods.length === 0 ) {
1123 // eslint-disable-next-line no-console
1124 console.warn( '[WP2FA Wizard] No methods available for this user.' );
1125 return;
1126 }
1127
1128 // Reset state — pre-select the user's currently configured method if available.
1129 state.selectedMethod = wp2faWizardData.currentMethod || null;
1130 state.currentStep = 'select-method';
1131 state.wizardCompleted = false;
1132
1133 state.overlay.classList.add( 'wp2fa-wizard-open' );
1134 state.overlay.setAttribute( 'aria-hidden', 'false' );
1135 document.body.style.overflow = 'hidden';
1136
1137 // Show the required intro step first if applicable (enforced users
1138 // who haven't seen it yet), then the welcome step if configured,
1139 // otherwise proceed to method selection (or directly to configure
1140 // if only one method).
1141 /*if ( hasRequiredIntroStep() ) {
1142 renderRequiredIntroStep();
1143 } else*/ if ( hasWelcomeStep() ) {
1144 renderWelcomeStep();
1145 } else if ( state.methods.length === 1 ) {
1146 // If only one method is available, skip the selection step and go
1147 // directly to configuring that method.
1148 state.selectedMethod = state.methods[ 0 ].id;
1149 hooks.doAction( 'wp2fa_wizard_method_selected', state.selectedMethod );
1150 renderMethodConfigureStep();
1151 } else {
1152 renderMethodSelectionStep();
1153 }
1154
1155 focusFirstInteractive();
1156 hooks.doAction( 'wp2fa_wizard_opened' );
1157 }
1158
1159 function closeWizard( confirmed ) {
1160 if ( ! confirmed ) {
1161 requestCloseWizard();
1162 return;
1163 }
1164 if ( ! state.overlay ) {
1165 return;
1166 }
1167 state.overlay.classList.remove( 'wp2fa-wizard-open' );
1168 state.overlay.setAttribute( 'aria-hidden', 'true' );
1169 document.body.style.overflow = '';
1170 hooks.doAction( 'wp2fa_wizard_closed' );
1171 }
1172
1173 /* -------------------------------------------------------
1174 * Shared utilities for method sub-modules
1175 *
1176 * These are common helpers used across multiple wizard
1177 * method JS files (TOTP, Email, OOB, etc.) to avoid
1178 * repeating the same code in each module.
1179 * ------------------------------------------------------- */
1180
1181 /**
1182 * Display a success or error message inside a response element.
1183 *
1184 * @param {HTMLElement} el The response container element.
1185 * @param {string} type 'success' or 'error'.
1186 * @param {string} message The message text to display.
1187 */
1188 function showResponse( el, type, message ) {
1189 el.className = 'wp2fa-wizard-verification-response';
1190 if ( type === 'success' ) {
1191 el.classList.add( 'wp2fa-response-success' );
1192 } else {
1193 el.classList.add( 'wp2fa-response-error' );
1194 }
1195 el.textContent = message;
1196 setTimeout( function () {
1197 el.textContent = '';
1198 el.className = 'wp2fa-wizard-verification-response';
1199 }, 3000 );
1200 }
1201
1202 /**
1203 * Validate an email address with a simple pattern check.
1204 *
1205 * @param {string} email The email address to validate.
1206 * @return {boolean} True if the email looks valid.
1207 */
1208 function isValidEmail( email ) {
1209 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( email );
1210 }
1211
1212 /**
1213 * Create and append a spinner element to a button.
1214 *
1215 * @param {HTMLElement} btn The button to append the spinner to.
1216 * @return {HTMLElement} The spinner element (for later removal).
1217 */
1218 function addSpinner( btn ) {
1219 var spinner = document.createElement( 'span' );
1220 spinner.className = 'wp2fa-wizard-spinner';
1221 btn.appendChild( spinner );
1222 return spinner;
1223 }
1224
1225 /**
1226 * Remove a spinner element from the DOM.
1227 *
1228 * @param {HTMLElement} spinner The spinner element to remove.
1229 */
1230 function removeSpinner( spinner ) {
1231 if ( spinner && spinner.parentNode ) {
1232 spinner.parentNode.removeChild( spinner );
1233 }
1234 }
1235
1236 /**
1237 * Extract an error message from a WP AJAX JSON response.
1238 *
1239 * @param {Object} data The parsed JSON response.
1240 * @param {string} fallbackMessage Fallback message if none found in response.
1241 * @return {string} The error message.
1242 */
1243 function extractAjaxError( data, fallbackMessage ) {
1244 return ( data.data && ( data.data.error || data.data.message ) )
1245 ? ( data.data.error || data.data.message )
1246 : ( fallbackMessage || 'An error occurred.' );
1247 }
1248
1249 /**
1250 * Show or create an inline error message inside a container.
1251 *
1252 * Reuses an existing .wp2fa-wizard-verification-response element
1253 * or creates one and appends it to the container.
1254 *
1255 * @param {HTMLElement} container The parent container.
1256 * @param {string} message The error message to display.
1257 */
1258 function showInlineError( container, message ) {
1259 var existing = container.querySelector( '.wp2fa-wizard-verification-response' );
1260 if ( ! existing ) {
1261 existing = document.createElement( 'div' );
1262 existing.className = 'wp2fa-wizard-verification-response';
1263 container.appendChild( existing );
1264 }
1265 existing.className = 'wp2fa-wizard-verification-response wp2fa-response-error';
1266 existing.textContent = message;
1267 setTimeout( function () {
1268 existing.textContent = '';
1269 existing.className = 'wp2fa-wizard-verification-response';
1270 }, 3000 );
1271 }
1272
1273 /* -------------------------------------------------------
1274 * Public API exposed on window.wp2faWizard
1275 * ------------------------------------------------------- */
1276 window.wp2faWizard = {
1277 open: openWizard,
1278 close: requestCloseWizard,
1279 finish: finishWizard,
1280 nextBackupMethod: nextBackupMethod,
1281 focusFirstInteractive: focusFirstInteractive,
1282 getState: function () {
1283 return {
1284 currentStep: state.currentStep,
1285 selectedMethod: state.selectedMethod,
1286 selectedBackupMethod: state.selectedBackupMethod,
1287 methods: state.methods.slice(),
1288 backupMethods: state.backupMethods.slice(),
1289 currentBackupIndex: state.currentBackupIndex,
1290 userId: state.userId,
1291 userRole: state.userRole
1292 };
1293 },
1294 goToStep: function ( step ) {
1295 /*if ( step === 'required-intro' && hasRequiredIntroStep() ) {
1296 renderRequiredIntroStep();
1297 } else */ if ( step === 'welcome' && hasWelcomeStep() ) {
1298 renderWelcomeStep();
1299 } else if ( step === 'select-method' ) {
1300 // If only one method is available, going "back" to selection
1301 // should go to welcome if available, or close the wizard.
1302 if ( state.methods.length <= 1 ) {
1303 if ( hasWelcomeStep() ) {
1304 renderWelcomeStep();
1305 } else {
1306 requestCloseWizard();
1307 }
1308 } else {
1309 renderMethodSelectionStep();
1310 }
1311 } else if ( step === 'configure-method' && state.selectedMethod ) {
1312 renderMethodConfigureStep();
1313 }
1314 },
1315 utils: {
1316 showResponse: showResponse,
1317 isValidEmail: isValidEmail,
1318 addSpinner: addSpinner,
1319 removeSpinner: removeSpinner,
1320 extractAjaxError: extractAjaxError,
1321 showInlineError: showInlineError
1322 }
1323 };
1324
1325 /* -------------------------------------------------------
1326 * Init on DOM ready
1327 * ------------------------------------------------------- */
1328 function init() {
1329 if ( typeof wp2faWizardData === 'undefined' ) {
1330 // eslint-disable-next-line no-console
1331 console.error( '[WP2FA Wizard] wp2faWizardData localization object is missing.' );
1332 return;
1333 }
1334
1335 state.userId = wp2faWizardData.userId || 0;
1336 state.userRole = wp2faWizardData.userRole || '';
1337 state.nonce = wp2faWizardData.nonce || '';
1338
1339 // Bind open buttons
1340 var openButtons = document.querySelectorAll( '[data-wp2fa-open-wizard]' );
1341 openButtons.forEach( function ( btn ) {
1342 btn.addEventListener( 'click', function ( e ) {
1343 e.preventDefault();
1344 openWizard();
1345 } );
1346 } );
1347
1348 // Listen for primary method validation success — transition to backup methods or close.
1349 hooks.addAction( 'wp2fa_wizard_method_validated', 'wp2fa/wizard-core', handleMethodValidated );
1350
1351 hooks.doAction( 'wp2fa_wizard_init' );
1352
1353 // Auto-open wizard if server-side flagged it (enforced user, grace expired).
1354 if ( wp2faWizardData.autoOpenWizard ) {
1355 setTimeout( function () {
1356 openWizard();
1357 }, 100 );
1358 }
1359 }
1360
1361 if ( document.readyState === 'loading' ) {
1362 document.addEventListener( 'DOMContentLoaded', init );
1363 } else {
1364 init();
1365 }
1366 }() );
1367