| 1 |
/** |
| 2 |
* AtomicEdge Two-Factor Authentication JavaScript |
| 3 |
* |
| 4 |
* Handles enrollment, verification, and management UI. |
| 5 |
* |
| 6 |
* @package AtomicEdge |
| 7 |
* @since 1.7.0 |
| 8 |
*/ |
| 9 |
|
| 10 |
/* global jQuery, atomicedge2fa, QRCode */ |
| 11 |
|
| 12 |
(function($) { |
| 13 |
'use strict'; |
| 14 |
|
| 15 |
var AtomicEdge2FA = { |
| 16 |
/** |
| 17 |
* Current enrollment data. |
| 18 |
*/ |
| 19 |
enrollmentData: null, |
| 20 |
|
| 21 |
/** |
| 22 |
* Backup codes download content. |
| 23 |
*/ |
| 24 |
downloadContent: null, |
| 25 |
|
| 26 |
/** |
| 27 |
* Initialize the 2FA module. |
| 28 |
*/ |
| 29 |
init: function() { |
| 30 |
this.bindEvents(); |
| 31 |
this.handleHashScroll(); |
| 32 |
}, |
| 33 |
|
| 34 |
/** |
| 35 |
* Handle scrolling to 2FA section if hash is present. |
| 36 |
* WordPress profile pages may delay rendering, so we use a timeout. |
| 37 |
*/ |
| 38 |
handleHashScroll: function() { |
| 39 |
if (window.location.hash === '#atomicedge-2fa-section') { |
| 40 |
// Delay to ensure DOM is fully rendered |
| 41 |
setTimeout(function() { |
| 42 |
var $section = $('#atomicedge-2fa-section'); |
| 43 |
if ($section.length) { |
| 44 |
$('html, body').animate({ |
| 45 |
scrollTop: $section.offset().top - 50 |
| 46 |
}, 300); |
| 47 |
} |
| 48 |
}, 100); |
| 49 |
} |
| 50 |
}, |
| 51 |
|
| 52 |
/** |
| 53 |
* Bind event handlers. |
| 54 |
*/ |
| 55 |
bindEvents: function() { |
| 56 |
var self = this; |
| 57 |
|
| 58 |
// Enrollment flow. |
| 59 |
$('#atomicedge-2fa-start').on('click', function() { |
| 60 |
self.startEnrollment(); |
| 61 |
}); |
| 62 |
|
| 63 |
$('#atomicedge-2fa-verify').on('click', function() { |
| 64 |
self.verifyEnrollment(); |
| 65 |
}); |
| 66 |
|
| 67 |
$('#atomicedge-2fa-cancel').on('click', function() { |
| 68 |
self.cancelEnrollment(); |
| 69 |
}); |
| 70 |
|
| 71 |
$('#atomicedge-2fa-finish').on('click', function() { |
| 72 |
window.location.reload(); |
| 73 |
}); |
| 74 |
|
| 75 |
// Code input - auto-submit on 6 digits. |
| 76 |
$('#atomicedge-2fa-verify-code').on('input', function() { |
| 77 |
var code = $(this).val().replace(/\D/g, ''); |
| 78 |
$(this).val(code); |
| 79 |
if (code.length === 6) { |
| 80 |
self.verifyEnrollment(); |
| 81 |
} |
| 82 |
}); |
| 83 |
|
| 84 |
// Backup codes download. |
| 85 |
$('#atomicedge-2fa-download-new-codes, #atomicedge-2fa-download-codes').on('click', function() { |
| 86 |
self.downloadBackupCodes(); |
| 87 |
}); |
| 88 |
|
| 89 |
// Disable 2FA. |
| 90 |
$('#atomicedge-2fa-disable').on('click', function() { |
| 91 |
if (confirm(atomicedge2fa.strings.confirm_disable)) { |
| 92 |
$('#atomicedge-2fa-enabled').hide(); |
| 93 |
$('#atomicedge-2fa-disable-confirm').show(); |
| 94 |
$('#atomicedge-2fa-password').focus(); |
| 95 |
} |
| 96 |
}); |
| 97 |
|
| 98 |
$('#atomicedge-2fa-disable-cancel').on('click', function() { |
| 99 |
$('#atomicedge-2fa-disable-confirm').hide(); |
| 100 |
$('#atomicedge-2fa-enabled').show(); |
| 101 |
$('#atomicedge-2fa-password').val(''); |
| 102 |
}); |
| 103 |
|
| 104 |
$('#atomicedge-2fa-disable-confirm-btn').on('click', function() { |
| 105 |
self.disable2FA(); |
| 106 |
}); |
| 107 |
|
| 108 |
// Regenerate backup codes. |
| 109 |
$('#atomicedge-2fa-regenerate-codes').on('click', function() { |
| 110 |
if (confirm(atomicedge2fa.strings.confirm_regenerate)) { |
| 111 |
self.regenerateCodes(); |
| 112 |
} |
| 113 |
}); |
| 114 |
|
| 115 |
$('#atomicedge-2fa-codes-done').on('click', function() { |
| 116 |
$('#atomicedge-2fa-codes-display').hide(); |
| 117 |
$('#atomicedge-2fa-enabled').show(); |
| 118 |
window.location.reload(); |
| 119 |
}); |
| 120 |
}, |
| 121 |
|
| 122 |
/** |
| 123 |
* Show loading state. |
| 124 |
*/ |
| 125 |
showLoading: function() { |
| 126 |
$('#atomicedge-2fa-loading').show(); |
| 127 |
}, |
| 128 |
|
| 129 |
/** |
| 130 |
* Hide loading state. |
| 131 |
*/ |
| 132 |
hideLoading: function() { |
| 133 |
$('#atomicedge-2fa-loading').hide(); |
| 134 |
}, |
| 135 |
|
| 136 |
/** |
| 137 |
* Show a step in the enrollment flow. |
| 138 |
* |
| 139 |
* @param {string} step Step ID (intro, setup, codes). |
| 140 |
*/ |
| 141 |
showStep: function(step) { |
| 142 |
$('.atomicedge-2fa-step').removeClass('active'); |
| 143 |
$('#atomicedge-2fa-step-' + step).addClass('active'); |
| 144 |
}, |
| 145 |
|
| 146 |
/** |
| 147 |
* Start 2FA enrollment. |
| 148 |
*/ |
| 149 |
startEnrollment: function() { |
| 150 |
var self = this; |
| 151 |
|
| 152 |
this.showLoading(); |
| 153 |
|
| 154 |
$.ajax({ |
| 155 |
url: atomicedge2fa.ajax_url, |
| 156 |
type: 'POST', |
| 157 |
data: { |
| 158 |
action: 'atomicedge_2fa_start_enrollment', |
| 159 |
nonce: atomicedge2fa.nonce, |
| 160 |
user_id: atomicedge2fa.user_id |
| 161 |
}, |
| 162 |
success: function(response) { |
| 163 |
self.hideLoading(); |
| 164 |
|
| 165 |
if (response.success) { |
| 166 |
self.enrollmentData = response.data; |
| 167 |
self.renderQRCode(response.data.provisioning_uri); |
| 168 |
$('#atomicedge-2fa-secret-display').text( |
| 169 |
self.formatSecret(response.data.secret) |
| 170 |
); |
| 171 |
self.showStep('setup'); |
| 172 |
$('#atomicedge-2fa-verify-code').focus(); |
| 173 |
} else { |
| 174 |
alert(response.data.message || atomicedge2fa.strings.verification_failed); |
| 175 |
} |
| 176 |
}, |
| 177 |
error: function() { |
| 178 |
self.hideLoading(); |
| 179 |
alert(atomicedge2fa.strings.verification_failed); |
| 180 |
} |
| 181 |
}); |
| 182 |
}, |
| 183 |
|
| 184 |
/** |
| 185 |
* Verify enrollment code. |
| 186 |
*/ |
| 187 |
verifyEnrollment: function() { |
| 188 |
var self = this; |
| 189 |
var code = $('#atomicedge-2fa-verify-code').val().trim(); |
| 190 |
|
| 191 |
if (code.length !== 6) { |
| 192 |
$('#atomicedge-2fa-verify-error').text('Please enter a 6-digit code.').show(); |
| 193 |
return; |
| 194 |
} |
| 195 |
|
| 196 |
$('#atomicedge-2fa-verify-error').hide(); |
| 197 |
this.showLoading(); |
| 198 |
|
| 199 |
$.ajax({ |
| 200 |
url: atomicedge2fa.ajax_url, |
| 201 |
type: 'POST', |
| 202 |
data: { |
| 203 |
action: 'atomicedge_2fa_verify_enrollment', |
| 204 |
nonce: atomicedge2fa.nonce, |
| 205 |
user_id: atomicedge2fa.user_id, |
| 206 |
code: code |
| 207 |
}, |
| 208 |
success: function(response) { |
| 209 |
self.hideLoading(); |
| 210 |
|
| 211 |
if (response.success) { |
| 212 |
self.downloadContent = response.data.download_content; |
| 213 |
self.renderBackupCodes(response.data.backup_codes, '#atomicedge-2fa-new-codes'); |
| 214 |
self.showStep('codes'); |
| 215 |
} else { |
| 216 |
$('#atomicedge-2fa-verify-error').text(response.data.message).show(); |
| 217 |
$('#atomicedge-2fa-verify-code').val('').focus(); |
| 218 |
} |
| 219 |
}, |
| 220 |
error: function() { |
| 221 |
self.hideLoading(); |
| 222 |
$('#atomicedge-2fa-verify-error').text(atomicedge2fa.strings.verification_failed).show(); |
| 223 |
} |
| 224 |
}); |
| 225 |
}, |
| 226 |
|
| 227 |
/** |
| 228 |
* Cancel enrollment. |
| 229 |
*/ |
| 230 |
cancelEnrollment: function() { |
| 231 |
var self = this; |
| 232 |
|
| 233 |
$.ajax({ |
| 234 |
url: atomicedge2fa.ajax_url, |
| 235 |
type: 'POST', |
| 236 |
data: { |
| 237 |
action: 'atomicedge_2fa_cancel_enrollment', |
| 238 |
nonce: atomicedge2fa.nonce, |
| 239 |
user_id: atomicedge2fa.user_id |
| 240 |
}, |
| 241 |
complete: function() { |
| 242 |
self.enrollmentData = null; |
| 243 |
$('#atomicedge-2fa-verify-code').val(''); |
| 244 |
$('#atomicedge-2fa-verify-error').hide(); |
| 245 |
self.showStep('intro'); |
| 246 |
} |
| 247 |
}); |
| 248 |
}, |
| 249 |
|
| 250 |
/** |
| 251 |
* Disable 2FA. |
| 252 |
*/ |
| 253 |
disable2FA: function() { |
| 254 |
var self = this; |
| 255 |
var password = $('#atomicedge-2fa-password').val(); |
| 256 |
|
| 257 |
if (!password) { |
| 258 |
alert('Please enter your password.'); |
| 259 |
return; |
| 260 |
} |
| 261 |
|
| 262 |
this.showLoading(); |
| 263 |
|
| 264 |
$.ajax({ |
| 265 |
url: atomicedge2fa.ajax_url, |
| 266 |
type: 'POST', |
| 267 |
data: { |
| 268 |
action: 'atomicedge_2fa_disable', |
| 269 |
nonce: atomicedge2fa.nonce, |
| 270 |
user_id: atomicedge2fa.user_id, |
| 271 |
password: password |
| 272 |
}, |
| 273 |
success: function(response) { |
| 274 |
self.hideLoading(); |
| 275 |
|
| 276 |
if (response.success) { |
| 277 |
window.location.reload(); |
| 278 |
} else { |
| 279 |
alert(response.data.message); |
| 280 |
$('#atomicedge-2fa-password').val('').focus(); |
| 281 |
} |
| 282 |
}, |
| 283 |
error: function() { |
| 284 |
self.hideLoading(); |
| 285 |
alert(atomicedge2fa.strings.verification_failed); |
| 286 |
} |
| 287 |
}); |
| 288 |
}, |
| 289 |
|
| 290 |
/** |
| 291 |
* Regenerate backup codes. |
| 292 |
*/ |
| 293 |
regenerateCodes: function() { |
| 294 |
var self = this; |
| 295 |
|
| 296 |
this.showLoading(); |
| 297 |
|
| 298 |
$.ajax({ |
| 299 |
url: atomicedge2fa.ajax_url, |
| 300 |
type: 'POST', |
| 301 |
data: { |
| 302 |
action: 'atomicedge_2fa_regenerate_codes', |
| 303 |
nonce: atomicedge2fa.nonce, |
| 304 |
user_id: atomicedge2fa.user_id |
| 305 |
}, |
| 306 |
success: function(response) { |
| 307 |
self.hideLoading(); |
| 308 |
|
| 309 |
if (response.success) { |
| 310 |
self.downloadContent = response.data.download_content; |
| 311 |
self.renderBackupCodes(response.data.backup_codes, '#atomicedge-2fa-codes-list'); |
| 312 |
$('#atomicedge-2fa-enabled').hide(); |
| 313 |
$('#atomicedge-2fa-codes-display').show(); |
| 314 |
} else { |
| 315 |
alert(response.data.message); |
| 316 |
} |
| 317 |
}, |
| 318 |
error: function() { |
| 319 |
self.hideLoading(); |
| 320 |
alert(atomicedge2fa.strings.verification_failed); |
| 321 |
} |
| 322 |
}); |
| 323 |
}, |
| 324 |
|
| 325 |
/** |
| 326 |
* Render QR code. |
| 327 |
* |
| 328 |
* @param {string} uri The otpauth:// URI. |
| 329 |
*/ |
| 330 |
renderQRCode: function(uri) { |
| 331 |
var canvas = document.getElementById('atomicedge-2fa-qrcode'); |
| 332 |
if (!canvas) { |
| 333 |
return; |
| 334 |
} |
| 335 |
|
| 336 |
// Clear existing QR code. |
| 337 |
var ctx = canvas.getContext('2d'); |
| 338 |
ctx.clearRect(0, 0, canvas.width, canvas.height); |
| 339 |
|
| 340 |
// Generate QR code using the bundled library. |
| 341 |
if (typeof QRCode !== 'undefined') { |
| 342 |
QRCode.toCanvas(canvas, uri, { |
| 343 |
width: 200, |
| 344 |
margin: 2, |
| 345 |
color: { |
| 346 |
dark: '#000000', |
| 347 |
light: '#ffffff' |
| 348 |
} |
| 349 |
}, function(error) { |
| 350 |
if (error) { |
| 351 |
console.error('QR Code generation error:', error); |
| 352 |
} |
| 353 |
}); |
| 354 |
} |
| 355 |
}, |
| 356 |
|
| 357 |
/** |
| 358 |
* Render backup codes. |
| 359 |
* |
| 360 |
* @param {Array} codes Array of backup codes. |
| 361 |
* @param {string} selector Container selector. |
| 362 |
*/ |
| 363 |
renderBackupCodes: function(codes, selector) { |
| 364 |
var $container = $(selector); |
| 365 |
$container.empty(); |
| 366 |
|
| 367 |
codes.forEach(function(code) { |
| 368 |
$container.append('<code>' + code + '</code>'); |
| 369 |
}); |
| 370 |
}, |
| 371 |
|
| 372 |
/** |
| 373 |
* Format secret for display (add spaces). |
| 374 |
* |
| 375 |
* @param {string} secret The raw secret. |
| 376 |
* @return {string} Formatted secret. |
| 377 |
*/ |
| 378 |
formatSecret: function(secret) { |
| 379 |
return secret.match(/.{1,4}/g).join(' '); |
| 380 |
}, |
| 381 |
|
| 382 |
/** |
| 383 |
* Download backup codes as a text file. |
| 384 |
*/ |
| 385 |
downloadBackupCodes: function() { |
| 386 |
if (!this.downloadContent) { |
| 387 |
return; |
| 388 |
} |
| 389 |
|
| 390 |
var blob = new Blob([this.downloadContent], { type: 'text/plain' }); |
| 391 |
var url = URL.createObjectURL(blob); |
| 392 |
var a = document.createElement('a'); |
| 393 |
a.href = url; |
| 394 |
a.download = 'atomicedge-backup-codes.txt'; |
| 395 |
document.body.appendChild(a); |
| 396 |
a.click(); |
| 397 |
document.body.removeChild(a); |
| 398 |
URL.revokeObjectURL(url); |
| 399 |
} |
| 400 |
}; |
| 401 |
|
| 402 |
// Initialize on document ready. |
| 403 |
$(document).ready(function() { |
| 404 |
if ($('#atomicedge-2fa-section').length) { |
| 405 |
AtomicEdge2FA.init(); |
| 406 |
} |
| 407 |
}); |
| 408 |
|
| 409 |
})(jQuery); |
| 410 |
|