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