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 / assets / js / save-settings-new.js
wp-2fa / includes / assets / js Last commit date
customize-styles.js 5 days ago exclude-self-prompt.js 5 days ago intlTelInput-vanilla.js 5 days ago policies-hash-state.js 5 days ago save-policies-new.js 5 days ago save-settings-new.js 5 days ago settings-design-logic.js 5 days ago settings-hash-state.js 5 days ago test-email-new.js 5 days ago wizard-new.js 5 days ago
save-settings-new.js
408 lines
1 /**
2 * Save Settings New
3 *
4 * Handles the AJAX submission of all settings found inside .main-settings-new.
5 * Attaches to every element carrying the class js-save-settings-new.
6 *
7 * No jQuery. No transpiler. Plain, readable vanilla JS.
8 *
9 * @package wp-2fa
10 * @since latest
11 */
12
13 ( function () {
14 'use strict';
15
16 /* ------------------------------------------------------------------ */
17 /* Field collection */
18 /* ------------------------------------------------------------------ */
19
20 /**
21 * Collect all named form fields inside a container element.
22 *
23 * Handles: text, number, email, url, hidden, checkbox, radio, select,
24 * textarea, and colour-picker inputs.
25 *
26 * Returns a plain object whose keys are the raw `name` attribute values
27 * (bracket notation preserved, e.g. "option_group[field]") and whose
28 * values are either a string or an array of strings for multi-value fields.
29 *
30 * Unchecked checkboxes are intentionally omitted so PHP receives nothing
31 * for them — callers should treat a missing key as "false / off".
32 *
33 * @param {HTMLElement} container
34 * @returns {Object}
35 */
36 function collectFields( container ) {
37 var data = {};
38 var elements = container.querySelectorAll( 'input, select, textarea' );
39
40 elements.forEach( function ( el ) {
41 // Skip unnamed, disabled, and submit/button/image controls.
42 if ( ! el.name || el.disabled ) {
43 return;
44 }
45 if ( el.type === 'submit' || el.type === 'button' || el.type === 'image' ) {
46 return;
47 }
48
49 var name = el.name;
50 var value;
51
52 if ( el.type === 'checkbox' ) {
53 if ( ! el.checked ) {
54 // Send empty string for unchecked checkboxes so the key is
55 // still present in POST — PHP merge logic needs it to know
56 // the setting was intentionally turned off.
57 value = '';
58 } else {
59 value = el.value !== '' ? el.value : '1';
60 }
61
62 } else if ( el.type === 'radio' ) {
63 // Skip deselected radios; the selected one wins.
64 if ( ! el.checked ) {
65 return;
66 }
67 value = el.value;
68
69 } else {
70 // Check if this is a textarea with a TinyMCE editor instance.
71 if ( el.tagName === 'TEXTAREA' && window.tinymce && el.id ) {
72 var editor = window.tinymce.get( el.id );
73 if ( editor ) {
74 value = editor.getContent();
75 } else {
76 value = el.value;
77 }
78 } else {
79 value = el.value;
80 }
81 }
82
83 // Accumulate multiple values under the same name into an array
84 // (handles `name[]` patterns used by some WP settings).
85 if ( Object.prototype.hasOwnProperty.call( data, name ) ) {
86 if ( ! Array.isArray( data[ name ] ) ) {
87 data[ name ] = [ data[ name ] ];
88 }
89 data[ name ].push( value );
90 } else {
91 data[ name ] = value;
92 }
93 } );
94
95 return data;
96 }
97
98 /* ------------------------------------------------------------------ */
99 /* Request body serialisation */
100 /* ------------------------------------------------------------------ */
101
102 /**
103 * Serialise a flat or array-valued object into an x-www-form-urlencoded
104 * string, preserving bracket notation so PHP can parse it natively.
105 *
106 * @param {Object} data
107 * @returns {string}
108 */
109 function buildFormBody( data ) {
110 var pairs = [];
111
112 Object.keys( data ).forEach( function ( key ) {
113 var val = data[ key ];
114
115 if ( Array.isArray( val ) ) {
116 val.forEach( function ( v ) {
117 pairs.push( encodeURIComponent( key ) + '=' + encodeURIComponent( v ) );
118 } );
119 } else {
120 pairs.push( encodeURIComponent( key ) + '=' + encodeURIComponent( val ) );
121 }
122 } );
123
124 return pairs.join( '&' );
125 }
126
127 /* ------------------------------------------------------------------ */
128 /* Inline feedback notice */
129 /* ------------------------------------------------------------------ */
130
131 /**
132 * Insert a temporary feedback notice immediately after the clicked button.
133 * Any previous notice is removed first.
134 *
135 * @param {HTMLElement} btn
136 * @param {string} message
137 * @param {string} type 'success' | 'error'
138 */
139 function showNotice( btn, message, type ) {
140 var existing = document.querySelector( '.wp2fa-save-notice' );
141 if ( existing ) {
142 existing.remove();
143 }
144
145 var notice = document.createElement( 'span' );
146 notice.className = 'wp2fa-save-notice wp2fa-save-notice--' + type;
147 notice.textContent = message;
148 notice.style.cssText =
149 'margin-left:12px;font-weight:500;' +
150 ( type === 'error' ? 'color:#d63638;' : 'color:#00a32a;' );
151
152 btn.parentNode.insertBefore( notice, btn.nextSibling );
153
154 // Auto-dismiss after 3.5 s.
155 setTimeout( function () {
156 if ( notice.parentNode ) {
157 notice.remove();
158 }
159 }, 3500 );
160 }
161
162 /**
163 * Show a brief toast notification (coloured bar).
164 *
165 * @param {string} message
166 * @param {string} type 'success' or 'error'
167 */
168 function showToast( message, type ) {
169 var existing = document.querySelector( '.wp2fa-toast' );
170 if ( existing ) {
171 existing.remove();
172 }
173
174 var toast = document.createElement( 'div' );
175 toast.className = 'wp2fa-toast wp2fa-toast--' + type;
176 toast.textContent = message;
177 document.body.appendChild( toast );
178
179 void toast.offsetWidth;
180 toast.classList.add( 'wp2fa-toast--visible' );
181
182 setTimeout( function () {
183 toast.classList.remove( 'wp2fa-toast--visible' );
184 setTimeout( function () {
185 toast.remove();
186 }, 400 );
187 }, 3000 );
188 }
189
190 /* ------------------------------------------------------------------ */
191 /* Error modal */
192 /* ------------------------------------------------------------------ */
193
194 /**
195 * Display a blocking modal dialog listing all settings errors returned
196 * by the server. The overlay prevents interaction with the page until
197 * the user explicitly dismisses it.
198 *
199 * @param {Array} errors Array of {code, message, type} objects.
200 * @param {string} summary Optional top-level summary message.
201 */
202 function showErrorModal( errors, summary ) {
203 // Remove any pre-existing modal.
204 var existing = document.getElementById( 'wp2fa-settings-error-modal' );
205 if ( existing ) {
206 existing.remove();
207 }
208
209 // --- Overlay (backdrop) ----------------------------------------------
210 var overlay = document.createElement( 'div' );
211 overlay.id = 'wp2fa-settings-error-modal';
212 overlay.setAttribute( 'role', 'dialog' );
213 overlay.setAttribute( 'aria-modal', 'true' );
214 overlay.setAttribute( 'aria-labelledby', 'wp2fa-error-modal-title' );
215 overlay.style.cssText =
216 'position:fixed;top:0;left:0;width:100%;height:100%;' +
217 'background:rgba(0,0,0,0.65);z-index:100000;' +
218 'display:flex;align-items:center;justify-content:center;';
219
220 // --- Modal box -------------------------------------------------------
221 var modal = document.createElement( 'div' );
222 modal.style.cssText =
223 'background:#fff;border-radius:4px;padding:28px 32px 24px;' +
224 'max-width:540px;width:90%;max-height:80vh;overflow-y:auto;' +
225 'box-shadow:0 8px 32px rgba(0,0,0,0.28);position:relative;';
226
227 // --- Title -----------------------------------------------------------
228 var title = document.createElement( 'h2' );
229 title.id = 'wp2fa-error-modal-title';
230 title.textContent = wp2faSaveSettingsNew.errorsModalTitle || 'Settings saved with warnings';
231 title.style.cssText = 'margin:0 0 8px;font-size:16px;font-weight:600;color:#1d2327;';
232
233 // --- Optional summary ------------------------------------------------
234 if ( summary ) {
235 var summaryEl = document.createElement( 'p' );
236 summaryEl.textContent = summary;
237 summaryEl.style.cssText = 'margin:0 0 16px;color:#50575e;font-size:13px;';
238 modal.appendChild( summaryEl );
239 }
240
241 // --- Error list ------------------------------------------------------
242 var list = document.createElement( 'ul' );
243 list.style.cssText = 'margin:12px 0 20px;padding:0;list-style:none;';
244
245 errors.forEach( function ( err ) {
246 var isError = err.type === 'error';
247 var li = document.createElement( 'li' );
248 li.style.cssText =
249 'display:flex;align-items:flex-start;gap:8px;' +
250 'padding:10px 12px;margin-bottom:6px;border-radius:3px;' +
251 'border-left:4px solid ' + ( isError ? '#d63638' : '#dba617' ) + ';' +
252 'background:' + ( isError ? '#fef7f7' : '#fffbf0' ) + ';';
253
254 var icon = document.createElement( 'span' );
255 icon.setAttribute( 'aria-hidden', 'true' );
256 icon.style.cssText =
257 'flex-shrink:0;font-size:16px;line-height:1.4;' +
258 'color:' + ( isError ? '#d63638' : '#dba617' ) + ';';
259 icon.textContent = isError ? '\u26A0' : '\u2022';
260
261 var text = document.createElement( 'span' );
262 text.style.cssText = 'font-size:13px;line-height:1.5;color:#1d2327;';
263 text.textContent = err.message;
264
265 li.appendChild( icon );
266 li.appendChild( text );
267 list.appendChild( li );
268 } );
269
270 // --- Dismiss button --------------------------------------------------
271 var closeBtn = document.createElement( 'button' );
272 closeBtn.type = 'button';
273 closeBtn.className = 'button button-primary';
274 closeBtn.textContent = wp2faSaveSettingsNew.errorsModalClose || 'OK, I understand';
275 closeBtn.style.cssText = 'display:block;margin-top:4px;';
276 closeBtn.addEventListener( 'click', function () {
277 overlay.remove();
278 } );
279
280 // Also close on backdrop click.
281 overlay.addEventListener( 'click', function ( e ) {
282 if ( e.target === overlay ) {
283 overlay.remove();
284 }
285 } );
286
287 // Close on Escape key.
288 var escHandler = function ( e ) {
289 if ( e.key === 'Escape' || e.keyCode === 27 ) {
290 overlay.remove();
291 document.removeEventListener( 'keydown', escHandler );
292 }
293 };
294 document.addEventListener( 'keydown', escHandler );
295
296 modal.appendChild( title );
297 modal.appendChild( list );
298 modal.appendChild( closeBtn );
299 overlay.appendChild( modal );
300 document.body.appendChild( overlay );
301
302 // Move focus into the modal so keyboard users can dismiss immediately.
303 closeBtn.focus();
304 }
305
306 /* ------------------------------------------------------------------ */
307 /* Core save routine */
308 /* ------------------------------------------------------------------ */
309
310 /**
311 * Collect settings from .main-settings-new and POST them via AJAX.
312 * Nonce + capability checks happen server-side in Settings_Page_New::ajax_save().
313 *
314 * @param {HTMLElement} btn The button that was activated.
315 */
316 function saveSettings( btn ) {
317 var container = document.querySelector( '.main-settings-new' );
318 if ( ! container ) {
319 return;
320 }
321
322 var fields = collectFields( container );
323
324 // Append the WordPress AJAX action identifier and the security nonce.
325 fields.action = wp2faSaveSettingsNew.action;
326 fields.nonce = wp2faSaveSettingsNew.nonce;
327
328 // Optimistic UI: disable the button while the request is in flight.
329 btn.disabled = true;
330 btn.textContent = wp2faSaveSettingsNew.savingText;
331
332 var xhr = new XMLHttpRequest();
333 xhr.open( 'POST', wp2faSaveSettingsNew.ajaxUrl, true );
334 xhr.setRequestHeader(
335 'Content-Type',
336 'application/x-www-form-urlencoded; charset=UTF-8'
337 );
338
339 xhr.onload = function () {
340 btn.disabled = false;
341 btn.textContent = wp2faSaveSettingsNew.saveText;
342
343 var response;
344 try {
345 response = JSON.parse( xhr.responseText );
346 } catch ( parseError ) {
347 showNotice( btn, wp2faSaveSettingsNew.errorText, 'error' );
348 return;
349 }
350
351 if ( response && response.success ) {
352 var errors = response.data && response.data.errors;
353 if ( errors && errors.length ) {
354 // Show blocking modal so the user must acknowledge each warning/error.
355 showErrorModal( errors, response.data.message || null );
356 } else {
357 showNotice(
358 btn,
359 ( response.data && response.data.message ) || wp2faSaveSettingsNew.successText,
360 'success'
361 );
362 }
363 } else {
364 showNotice(
365 btn,
366 ( response && response.data && response.data.message ) || wp2faSaveSettingsNew.errorText,
367 'error'
368 );
369 }
370 };
371
372 xhr.onerror = function () {
373 btn.disabled = false;
374 btn.textContent = wp2faSaveSettingsNew.saveText;
375 showNotice( btn, wp2faSaveSettingsNew.errorText, 'error' );
376 };
377
378 xhr.send( buildFormBody( fields ) );
379 }
380
381 /* ------------------------------------------------------------------ */
382 /* Event wiring */
383 /* ------------------------------------------------------------------ */
384
385 /**
386 * Use event delegation so dynamically inserted buttons are also handled.
387 */
388 function init() {
389 document.addEventListener( 'click', function ( event ) {
390 var btn = event.target.closest( '.js-save-settings-new' );
391 if ( ! btn ) {
392 return;
393 }
394
395 event.preventDefault();
396 saveSettings( btn );
397 } );
398 }
399
400 // Boot as soon as the DOM is interactive.
401 if ( document.readyState === 'loading' ) {
402 document.addEventListener( 'DOMContentLoaded', init );
403 } else {
404 init();
405 }
406
407 }() );
408