PluginProbe ʕ •ᴥ•ʔ
WP 2FA – Two-factor authentication for WordPress / 4.1.0
WP 2FA – Two-factor authentication for WordPress v4.1.0
4.1.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 / passkeys / user-profile.js
wp-2fa / js / passkeys Last commit date
user-profile.js 3 days ago
user-profile.js
472 lines
1 /**
2 * Passkeys User Profile – Modal and UI handling.
3 *
4 * Vanilla JS replacement for the inline jQuery-based modal code.
5 * Works with wp.template templates loaded on the page.
6 *
7 * @package wp2fa
8 * @since 4.0.0
9 */
10
11 /* global wp, wp2faPasskeys */
12
13 ( function () {
14 'use strict';
15
16 // Unicode-safe regex: letters (any language), digits, dash, underscore, space.
17 var VALID_NAME_PATTERN = /^[\p{L}\p{N}\-_ ]+$/u;
18
19 var overlay = null;
20 var activeCleanup = null;
21
22 /* ------------------------------------------------------------------ */
23 /* Helpers */
24 /* ------------------------------------------------------------------ */
25
26 /**
27 * Create and return the shared overlay element (singleton).
28 */
29 function getOverlay() {
30 if ( overlay ) {
31 return overlay;
32 }
33 overlay = document.createElement( 'div' );
34 overlay.className = 'wp2fa-passkey-overlay';
35 overlay.id = 'wp2fa-passkey-overlay';
36 overlay.setAttribute( 'aria-hidden', 'true' );
37 document.body.appendChild( overlay );
38
39 // Attach the overlay-click-to-close handler once, not per modal open.
40 overlay.addEventListener( 'click', function ( e ) {
41 if ( e.target === overlay ) {
42 hideModal();
43 }
44 } );
45
46 return overlay;
47 }
48
49 /**
50 * Show the overlay with the given HTML content.
51 */
52 function showModal( html ) {
53 var el = getOverlay();
54 el.innerHTML = html;
55 el.classList.add( 'is-visible' );
56 el.setAttribute( 'aria-hidden', 'false' );
57
58 // Focus the dialog itself for screen readers.
59 var dialog = el.querySelector( '[role="dialog"]' );
60 if ( dialog ) {
61 dialog.focus();
62 }
63
64 // Trap focus & listen for Escape.
65 activeCleanup = trapFocus( el );
66 }
67
68 /**
69 * Hide the overlay and clean up.
70 */
71 function hideModal() {
72 var el = getOverlay();
73 el.classList.remove( 'is-visible' );
74 el.setAttribute( 'aria-hidden', 'true' );
75 el.innerHTML = '';
76 if ( activeCleanup ) {
77 activeCleanup();
78 activeCleanup = null;
79 }
80 }
81
82 /**
83 * Simple focus trap for modals.
84 * Returns a cleanup function that removes the listener.
85 */
86 function trapFocus( container ) {
87 function handler( e ) {
88 // Escape key closes.
89 if ( e.key === 'Escape' ) {
90 e.preventDefault();
91 hideModal();
92 return;
93 }
94
95 if ( e.key !== 'Tab' ) {
96 return;
97 }
98
99 var focusable = container.querySelectorAll(
100 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
101 );
102 if ( ! focusable.length ) {
103 return;
104 }
105
106 var first = focusable[ 0 ];
107 var last = focusable[ focusable.length - 1 ];
108
109 if ( e.shiftKey ) {
110 if ( document.activeElement === first ) {
111 e.preventDefault();
112 last.focus();
113 }
114 } else {
115 if ( document.activeElement === last ) {
116 e.preventDefault();
117 first.focus();
118 }
119 }
120 }
121
122 document.addEventListener( 'keydown', handler, true );
123 return function () {
124 document.removeEventListener( 'keydown', handler, true );
125 };
126 }
127
128 /* ------------------------------------------------------------------ */
129 /* Setup Modal */
130 /* ------------------------------------------------------------------ */
131
132 /**
133 * Open the "Set up Passkey Login" modal.
134 */
135 function openSetupModal() {
136 var tmpl = wp.template( 'wp2fa-passkey-setup-modal' );
137 var data = wp2faPasskeys;
138
139 showModal( tmpl( {
140 title: data.setupTitle,
141 description: data.setupDescription,
142 stepsHeading: data.stepsHeading,
143 step1: data.step1,
144 step2: data.step2,
145 step3: data.step3,
146 cancelLabel: data.cancelLabel,
147 usbLabel: data.usbLabel,
148 passkeyLabel: data.passkeyLabel,
149 nonce: data.nonce,
150 } ) );
151
152 // Bind close buttons.
153 getOverlay().querySelectorAll( '[data-wp2fa-passkey-close]' ).forEach( function ( btn ) {
154 btn.addEventListener( 'click', function ( e ) {
155 e.preventDefault();
156 hideModal();
157 } );
158 } );
159
160 // Bind register buttons.
161 var registerBtn = document.getElementById( 'wp2fa-passkey-register' );
162 var registerUsbBtn = document.getElementById( 'wp2fa-passkey-register-usb' );
163
164 if ( registerBtn ) {
165 registerBtn.addEventListener( 'click', function ( e ) {
166 e.preventDefault();
167 handleRegistration( false, this );
168 } );
169 }
170
171 if ( registerUsbBtn ) {
172 registerUsbBtn.addEventListener( 'click', function ( e ) {
173 e.preventDefault();
174 handleRegistration( true, this );
175 } );
176 }
177 }
178
179 /* ------------------------------------------------------------------ */
180 /* Registration flow */
181 /* ------------------------------------------------------------------ */
182
183 /**
184 * Handle passkey registration.
185 * Dispatches a custom event that the existing user-profile.js /
186 * user-profile-ajax.js can listen to, or calls the exposed API.
187 */
188 function handleRegistration( isUsb, buttonEl ) {
189 var errorEl = document.getElementById( 'wp2fa-passkey-setup-error' );
190 if ( errorEl ) {
191 errorEl.textContent = '';
192 }
193
194 // Disable buttons during registration.
195 var registerBtn = document.getElementById( 'wp2fa-passkey-register' );
196 var registerUsbBtn = document.getElementById( 'wp2fa-passkey-register-usb' );
197 if ( registerBtn ) {
198 registerBtn.disabled = true;
199 }
200 if ( registerUsbBtn ) {
201 registerUsbBtn.disabled = true;
202 }
203
204 // Dispatch event so the existing registration scripts can hook in.
205 var event = new CustomEvent( 'wp2fa-passkey-register', {
206 detail: {
207 isUsb: isUsb,
208 nonce: buttonEl.dataset.nonce,
209 button: buttonEl,
210 },
211 } );
212 document.dispatchEvent( event );
213 }
214
215 /* ------------------------------------------------------------------ */
216 /* Success / Name prompt modal */
217 /* ------------------------------------------------------------------ */
218
219 /**
220 * Open the "Passkey Added!" modal and return a Promise that resolves
221 * with the passkey name.
222 *
223 * This function is exposed globally as window.wp2faOpenPasskeyPrompt
224 * so the existing user-profile.js and user-profile-ajax.js can call it.
225 */
226 function openPrompt() {
227 return new Promise( function ( resolve ) {
228 hideModal();
229
230 var tmpl = wp.template( 'wp2fa-passkey-success' );
231 var data = wp2faPasskeys;
232
233 showModal( tmpl( {
234 title: data.successTitle,
235 description: data.successDescription,
236 placeholder: data.namePlaceholder,
237 submitLabel: data.submitLabel,
238 } ) );
239
240 var nameInput = document.getElementById( 'wp2fa-passkey-name-input' );
241 var nameError = document.getElementById( 'wp2fa-passkey-name-error' );
242 var nameSubmit = document.getElementById( 'wp2fa-passkey-name-submit' );
243
244 if ( nameInput ) {
245 nameInput.focus();
246 }
247
248 // Bind close buttons – resolve empty to use auto-generated name.
249 getOverlay().querySelectorAll( '[data-wp2fa-passkey-close]' ).forEach( function ( btn ) {
250 btn.addEventListener( 'click', function ( e ) {
251 e.preventDefault();
252 cleanup();
253 hideModal();
254 resolve( '' );
255 } );
256 } );
257
258 function validate( value ) {
259 if ( ! value ) {
260 // Allow empty – server will auto-generate a name.
261 return true;
262 }
263 return VALID_NAME_PATTERN.test( value );
264 }
265
266 function handleSubmit( e ) {
267 if ( e ) {
268 e.preventDefault();
269 e.stopPropagation();
270 }
271
272 var value = nameInput ? nameInput.value.trim() : '';
273
274 if ( ! validate( value ) ) {
275 if ( nameError ) {
276 nameError.textContent = data.nameValidationError || 'Only letters, numbers, dashes, underscores, and spaces allowed.';
277 }
278 if ( nameInput ) {
279 nameInput.classList.add( 'is-invalid' );
280 nameInput.focus();
281 }
282 return;
283 }
284
285 cleanup();
286 hideModal();
287 resolve( value );
288 }
289
290 function handleKeydown( e ) {
291 if ( e.key === 'Enter' ) {
292 e.preventDefault();
293 e.stopPropagation();
294 handleSubmit();
295 }
296 }
297
298 function cleanup() {
299 if ( nameInput ) {
300 nameInput.removeEventListener( 'keydown', handleKeydown );
301 }
302 if ( nameSubmit ) {
303 nameSubmit.removeEventListener( 'click', handleSubmit );
304 }
305 }
306
307 if ( nameSubmit ) {
308 nameSubmit.addEventListener( 'click', handleSubmit );
309 }
310 if ( nameInput ) {
311 nameInput.addEventListener( 'keydown', handleKeydown );
312 // Clear error on input.
313 nameInput.addEventListener( 'input', function () {
314 this.classList.remove( 'is-invalid' );
315 if ( nameError ) {
316 nameError.textContent = '';
317 }
318 } );
319 }
320 } );
321 }
322
323 /* ------------------------------------------------------------------ */
324 /* Error display helper */
325 /* ------------------------------------------------------------------ */
326
327 /**
328 * Show an error message in the setup modal.
329 * Can be called from external scripts.
330 */
331 function showSetupError( message ) {
332 var errorEl = document.getElementById( 'wp2fa-passkey-setup-error' );
333 if ( errorEl ) {
334 errorEl.textContent = message;
335 }
336
337 // Re-enable buttons.
338 var registerBtn = document.getElementById( 'wp2fa-passkey-register' );
339 var registerUsbBtn = document.getElementById( 'wp2fa-passkey-register-usb' );
340 if ( registerBtn ) {
341 registerBtn.disabled = false;
342 }
343 if ( registerUsbBtn ) {
344 registerUsbBtn.disabled = false;
345 }
346 }
347
348 /* ------------------------------------------------------------------ */
349 /* Table column sorting */
350 /* ------------------------------------------------------------------ */
351
352 /**
353 * Initialise client-side sorting for the passkey list table.
354 *
355 * Columns marked with class "sortable" (or "sorted") are made interactive.
356 * Clicking a column header toggles ascending ↔ descending order.
357 * Numeric and text sort types are supported via `data-sort-type` on <th>.
358 */
359 function initTableSorting() {
360 var table = document.getElementById( 'wp-2fa-passkey-table' );
361 if ( ! table ) {
362 return;
363 }
364
365 var allSortableHeaders = table.querySelectorAll( 'thead th.sortable a, thead th.sorted a, tfoot th.sortable a, tfoot th.sorted a' );
366
367 allSortableHeaders.forEach( function ( link ) {
368 link.addEventListener( 'click', function ( e ) {
369 e.preventDefault();
370
371 var th = this.closest( 'th' );
372 var columnIndex = Array.prototype.indexOf.call( th.parentNode.children, th );
373 var sortType = th.getAttribute( 'data-sort-type' ) || 'text';
374
375 // Determine new direction: toggle from current.
376 var isCurrentlyAsc = th.classList.contains( 'asc' );
377 var newDir = isCurrentlyAsc ? 'desc' : 'asc';
378
379 // Reset all sortable headers (thead + tfoot) to default state.
380 table.querySelectorAll( 'thead th, tfoot th' ).forEach( function ( h ) {
381 if ( h.classList.contains( 'sorted' ) || h.classList.contains( 'sortable' ) ) {
382 h.classList.remove( 'sorted', 'asc', 'desc' );
383 h.classList.add( 'sortable', 'asc' );
384 }
385 } );
386
387 // Mark the clicked column (and its mirror in tfoot/thead) as sorted.
388 var selector = 'thead th:nth-child(' + ( columnIndex + 1 ) + '), tfoot th:nth-child(' + ( columnIndex + 1 ) + ')';
389 table.querySelectorAll( selector ).forEach( function ( h ) {
390 h.classList.remove( 'sortable', 'asc', 'desc' );
391 h.classList.add( 'sorted', newDir );
392 } );
393
394 // Sort tbody rows.
395 var tbody = table.querySelector( 'tbody' );
396 var rows = Array.prototype.slice.call( tbody.querySelectorAll( 'tr' ) );
397
398 rows.sort( function ( a, b ) {
399 var cellA = a.children[ columnIndex ];
400 var cellB = b.children[ columnIndex ];
401
402 if ( ! cellA || ! cellB ) {
403 return 0;
404 }
405
406 var valA = cellA.getAttribute( 'data-sort-value' );
407 var valB = cellB.getAttribute( 'data-sort-value' );
408
409 if ( valA === null ) {
410 valA = cellA.textContent.trim();
411 }
412 if ( valB === null ) {
413 valB = cellB.textContent.trim();
414 }
415
416 var result;
417
418 if ( sortType === 'numeric' ) {
419 var numA = parseFloat( valA ) || 0;
420 var numB = parseFloat( valB ) || 0;
421 result = numA - numB;
422 } else {
423 result = valA.localeCompare( valB, undefined, { sensitivity: 'base' } );
424 }
425
426 return newDir === 'asc' ? result : -result;
427 } );
428
429 rows.forEach( function ( row ) {
430 tbody.appendChild( row );
431 } );
432 } );
433 } );
434 }
435
436 /* ------------------------------------------------------------------ */
437 /* Init on DOM ready */
438 /* ------------------------------------------------------------------ */
439
440 function init() {
441 // Bind the "Add a Passkey" button on the profile page.
442 document.addEventListener( 'click', function ( e ) {
443 var trigger = e.target.closest( '[data-open-configure-2fa-wizard-passkey]' );
444 if ( trigger ) {
445 e.preventDefault();
446 // Remove beforeunload to allow navigation.
447 window.onbeforeunload = null;
448 openSetupModal();
449 }
450 } );
451
452 // Initialise sortable column headers.
453 initTableSorting();
454 }
455
456 // Use wp.domReady if available, otherwise fallback to DOMContentLoaded.
457 if ( typeof wp !== 'undefined' && wp.domReady ) {
458 wp.domReady( init );
459 } else {
460 document.addEventListener( 'DOMContentLoaded', init );
461 }
462
463 /* ------------------------------------------------------------------ */
464 /* Public API */
465 /* ------------------------------------------------------------------ */
466
467 window.wp2faOpenPasskeyPrompt = openPrompt;
468 window.wp2faShowPasskeySetupError = showSetupError;
469 window.wp2faHidePasskeyModal = hideModal;
470 window.wp2faOpenPasskeySetup = openSetupModal;
471 } )();
472