1/** 2 * Memzo passkey (WebAuthn) client for dashboard login.php 3 */ 4(function (global) { 5 'use strict'; 6 7 function b64urlToBuffer(b64url) { 8 var s = String(b64url).replace(/-/g, '+').replace(/_/g, '/'); 9 while (s.length % 4) { 10 s += '='; 11 } 12 var bin = atob(s); 13 var out = new Uint8Array(bin.length); 14 for (var i = 0; i < bin.length; i++) { 15 out[i] = bin.charCodeAt(i); 16 } 17 return out.buffer; 18 } 19 20 function bufferToB64url(buffer) { 21 var bytes = new Uint8Array(buffer); 22 var s = ''; 23 for (var i = 0; i < bytes.length; i++) { 24 s += String.fromCharCode(bytes[i]); 25 } 26 return btoa(s) 27 .replace(/\+/g, '-') 28 .replace(/\//g, '_') 29 .replace(/=+$/, ''); 30 } 31 32 function isAppleDesktopBrowser() { 33 if (!global.navigator) { 34 return false; 35 } 36 var ua = navigator.userAgent || ''; 37 if (!/Macintosh|Mac OS/i.test(ua)) { 38 return false; 39 } 40 // iPadOS often reports as Mac; skip those. 41 if (navigator.maxTouchPoints > 1) { 42 return false; 43 } 44 return true; 45 } 46 47 function prepareCreationOptions(publicKey, preferPlatform) { 48 var pk = Object.assign({}, publicKey); 49 pk.challenge = b64urlToBuffer(pk.challenge); 50 pk.user = Object.assign({}, pk.user, { 51 id: b64urlToBuffer(pk.user.id), 52 }); 53 // Do not pass excludeCredentials to Chrome during re-setup. If localStorage or 54 // the OS keystore was cleared, Chrome can otherwise show "try another device" 55 // before the user gets a normal setup path. The server still handles duplicate 56 // credentials and we treat them as a local restore. 57 delete pk.excludeCredentials; 58 59 var selection = Object.assign({}, pk.authenticatorSelection || {}); 60 if (selection.residentKey === undefined || selection.residentKey === null || selection.residentKey === '') { 61 selection.residentKey = 'preferred'; 62 } 63 if (selection.userVerification === undefined || selection.userVerification === null || selection.userVerification === '') { 64 selection.userVerification = 'preferred'; 65 } 66 // Safari / Chrome on Mac: prefer Touch ID / iCloud Keychain when available. 67 if (preferPlatform) { 68 selection.authenticatorAttachment = 'platform'; 69 } else if (selection.authenticatorAttachment === 'platform') { 70 delete selection.authenticatorAttachment; 71 } 72 pk.authenticatorSelection = selection; 73 return pk; 74 } 75 76 function prepareRequestOptions(publicKey) { 77 var allow = (publicKey.allowCredentials || []).map(function (c) { 78 var item = { 79 type: c.type || 'public-key', 80 id: b64urlToBuffer(c.id), 81 }; 82 if (Array.isArray(c.transports) && c.transports.length > 0) { 83 item.transports = c.transports; 84 } 85 return item; 86 }); 87 var req = { 88 challenge: b64urlToBuffer(publicKey.challenge), 89 timeout: publicKey.timeout || 60000, 90 rpId: publicKey.rpId, 91 userVerification: publicKey.userVerification || 'preferred', 92 }; 93 if (allow.length > 0) { 94 req.allowCredentials = allow; 95 } 96 return req; 97 } 98 99 function credentialToRegisterJson(cred) { 100 return { 101 id: cred.id, 102 rawId: bufferToB64url(cred.rawId), 103 type: cred.type, 104 response: { 105 clientDataJSON: bufferToB64url(cred.response.clientDataJSON), 106 attestationObject: bufferToB64url(cred.response.attestationObject), 107 }, 108 }; 109 } 110 111 function credentialToLoginJson(cred) { 112 var json = { 113 id: cred.id, 114 rawId: bufferToB64url(cred.rawId), 115 type: cred.type, 116 response: { 117 clientDataJSON: bufferToB64url(cred.response.clientDataJSON), 118 authenticatorData: bufferToB64url(cred.response.authenticatorData), 119 signature: bufferToB64url(cred.response.signature), 120 }, 121 }; 122 if (cred.response.userHandle && cred.response.userHandle.byteLength) { 123 json.response.userHandle = bufferToB64url(cred.response.userHandle); 124 } 125 return json; 126 } 127 128 function parseJsonResponse(res) { 129 return res.text().then(function (text) { 130 var data; 131 try { 132 data = JSON.parse(text); 133 } catch (e) { 134 throw new Error('Invalid server response: ' + text.substring(0, 200)); 135 } 136 return data; 137 }); 138 } 139 140 function apiPost(url, body) { 141 return fetch(url, { 142 method: 'POST', 143 headers: { 'Content-Type': 'application/json' }, 144 credentials: 'same-origin', 145 body: JSON.stringify(body || {}), 146 }).then(parseJsonResponse); 147 } 148 149 function apiErrorMessage(data, fallback) { 150 if (!data) { 151 return fallback; 152 } 153 if (data.hint) { 154 return data.message + ' ' + data.hint; 155 } 156 if (data.detail) { 157 return data.message + ' (' + data.detail + ')'; 158 } 159 return data.message || fallback; 160 } 161 162 var LOCAL_PASSKEY_KEY = 'memzo_passkey_local_registered'; 163 var LOCAL_PASSKEY_PREFIX = 'memzo_passkey_registered_'; 164 var LOCAL_PASSKEY_LAST_CONTACT = 'memzo_passkey_last_contact'; 165 var LOCAL_PASSKEY_LAST_COUNTRY = 'memzo_passkey_last_country'; 166 var SESSION_PASSKEY_SETUP_PENDING = 'memzo_passkey_setup_pending'; 167 var SESSION_PASSKEY_RESETUP_AFTER_LOGIN = 'memzo_passkey_resetup_after_login'; 168 169 function isPortableDevice() { 170 if (!global.navigator) { 171 return false; 172 } 173 var ua = navigator.userAgent || ''; 174 if (/Android|iPhone|iPad|iPod|Mobile|Tablet/i.test(ua)) { 175 return true; 176 } 177 // iPadOS 13+ Safari often reports as Macintosh 178 if (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1) { 179 return true; 180 } 181 return false; 182 } 183 184 function MemzoPasskey(config) { 185 this.apiBase = config.apiBase || (global.location.origin + '/helpers/services/web-auth/index.php'); 186 this.sessionRegisterOptionsUrl = config.sessionRegisterOptionsUrl || ''; 187 this.sessionRegisterVerifyUrl = config.sessionRegisterVerifyUrl || ''; 188 this.sessionUrl = config.sessionUrl || ''; 189 this.statusUrl = config.statusUrl || ''; 190 this.accountType = config.accountType || 'dashboard'; 191 this._platformAuthenticatorAvailable = null; 192 } 193 194 MemzoPasskey.prototype.isSupported = function () { 195 return !!( 196 global.window 197 && global.isSecureContext 198 && window.PublicKeyCredential 199 && navigator.credentials 200 ); 201 }; 202 203 MemzoPasskey.prototype.isPortableDevice = function () { 204 return isPortableDevice(); 205 }; 206 207 /** @deprecated Use isPortableDevice() */ 208 MemzoPasskey.prototype.isMobile = function () { 209 return isPortableDevice(); 210 }; 211 212 MemzoPasskey.prototype.getDeviceLabel = function () { 213 var ua = (navigator && navigator.userAgent) ? navigator.userAgent : ''; 214 if (/iPhone/i.test(ua)) { 215 return 'iPhone'; 216 } 217 if (/iPad/i.test(ua) || (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1)) { 218 return 'iPad'; 219 } 220 if (/Android/i.test(ua)) { 221 if (/Mobile/i.test(ua)) {
222 return 'Android phone'; 223 } 224 return 'Android tablet'; 225 } 226 if (/Tablet/i.test(ua)) { 227 return 'Tablet'; 228 } 229 if (/Windows/i.test(ua)) { 230 return 'Windows'; 231 } 232 if (/Macintosh|Mac OS/i.test(ua)) { 233 return 'Mac'; 234 } 235 if (/Linux/i.test(ua)) { 236 return 'Linux'; 237 } 238 return 'Desktop'; 239 }; 240 241 MemzoPasskey.prototype.getLocalPasskeyStorageKey = function (contact, countryCode) { 242 var cc = String(countryCode || '').replace(/\s+/g, ''); 243 var num = String(contact || '').replace(/\D/g, '').replace(/^0+/, ''); 244 return 'memzo_passkey_registered_' + cc + '_' + num; 245 }; 246 247 MemzoPasskey.prototype.saveLastPasskeyIdentity = function (contact, countryCode) { 248 try { 249 if (contact) { 250 localStorage.setItem(LOCAL_PASSKEY_LAST_CONTACT, String(contact)); 251 localStorage.setItem(LOCAL_PASSKEY_LAST_COUNTRY, String(countryCode || '')); 252 } 253 } catch (e) { 254 /* ignore */ 255 } 256 }; 257 258 MemzoPasskey.prototype.getLastPasskeyIdentity = function () { 259 try { 260 var contact = localStorage.getItem(LOCAL_PASSKEY_LAST_CONTACT) || ''; 261 if (!contact) { 262 return null; 263 } 264 return { 265 contact: contact, 266 countryCode: localStorage.getItem(LOCAL_PASSKEY_LAST_COUNTRY) || '', 267 }; 268 } catch (e) { 269 return null; 270 } 271 }; 272 273 MemzoPasskey.prototype.hasAnyPasskeyRegisteredLocally = function () { 274 try { 275 if (localStorage.getItem(LOCAL_PASSKEY_KEY) === '1') { 276 return true; 277 } 278 for (var i = 0; i < localStorage.length; i++) { 279 var key = localStorage.key(i); 280 if (key && key.indexOf(LOCAL_PASSKEY_PREFIX) === 0 && localStorage.getItem(key) === '1') { 281 return true; 282 } 283 } 284 } catch (e) { 285 /* ignore */ 286 } 287 return false; 288 }; 289 290 MemzoPasskey.prototype.markPasskeyRegisteredLocally = function (contact, countryCode) { 291 try { 292 localStorage.setItem(LOCAL_PASSKEY_KEY, '1'); 293 if (contact) { 294 localStorage.setItem(this.getLocalPasskeyStorageKey(contact, countryCode), '1'); 295 this.saveLastPasskeyIdentity(contact, countryCode); 296 } 297 sessionStorage.removeItem(SESSION_PASSKEY_RESETUP_AFTER_LOGIN); 298 } catch (e) { 299 /* ignore */ 300 } 301 }; 302 303 MemzoPasskey.prototype.clearPasskeyRegisteredLocally = function (contact, countryCode) { 304 try { 305 if (contact) { 306 localStorage.removeItem(this.getLocalPasskeyStorageKey(contact, countryCode)); 307 } 308 var hasAnyContactKey = false; 309 for (var i = 0; i < localStorage.length; i++) { 310 var key = localStorage.key(i); 311 if (key && key.indexOf(LOCAL_PASSKEY_PREFIX) === 0 && localStorage.getItem(key) === '1') { 312 hasAnyContactKey = true; 313 break; 314 } 315 } 316 if (!hasAnyContactKey) { 317 localStorage.removeItem(LOCAL_PASSKEY_KEY); 318 } 319 } catch (e) { 320 /* ignore */ 321 } 322 }; 323 324 MemzoPasskey.prototype.hasPasskeyRegisteredLocally = function (contact, countryCode) { 325 try { 326 if (contact) { 327 return localStorage.getItem(this.getLocalPasskeyStorageKey(contact, countryCode)) === '1'; 328 } 329 return this.hasAnyPasskeyRegisteredLocally(); 330 } catch (e) { 331 return false; 332 } 333 }; 334 335 /** 336 * Check whether the server has any passkey credentials registered for this user. 337 * Uses loginOptions allowCredentials count â no WebAuthn prompt is triggered. 338 * Returns: true (server has passkeys), false (no passkeys on server), null (unknown/error). 339 */ 340 MemzoPasskey.prototype.probeServerPasskeyExists = function (contact, countryCode) { 341 if (!this.isSupported() || !contact) { 342 return Promise.resolve(null); 343 } 344 return this.loginOptions(contact, countryCode).then(function (optRes) { 345 if (optRes.error || !optRes.publicKey) { 346 return null; 347 } 348 var allowCount = (optRes.publicKey.allowCredentials || []).length; 349 return allowCount > 0; 350 }).catch(function () { 351 return null; 352 }); 353 }; 354 355 /** 356 * Check whether a passkey credential is present on this device. 357 * Only call when localStorage says passkey was set up (hadLocal=true). 358 * When the passkey was removed from the device, returns false without an OS dialog. 359 * Returns: true (on device), false (not on device), null (probe unsupported). 360 */ 361 MemzoPasskey.prototype.probeDevicePasskey = function (contact, countryCode) { 362 if (!this.isSupported() || !contact) { 363 return Promise.resolve(false); 364 } 365 return this.loginOptions(contact, countryCode).then(function (optRes) { 366 if (optRes.error || !optRes.publicKey) { 367 return false; 368 } 369 var allowCount = (optRes.publicKey.allowCredentials || []).length; 370 if (allowCount === 0) { 371 return false; 372 } 373 var publicKey = prepareRequestOptions(optRes.publicKey); 374 return navigator.credentials.get({ publicKey: publicKey, mediation: 'silent' }).then(function (cred) { 375 return !!cred; 376 }).catch(function (err) { 377 var name = err && err.name ? String(err.name) : ''; 378 var msg = err && err.message ? String(err.message) : ''; 379 if (name === 'NotSupportedError' || /not supported|mediation/i.test(msg)) { 380 return null; 381 } 382 return false; 383 }); 384 }).catch(function () { 385 return false; 386 }); 387 }; 388 389 MemzoPasskey.prototype.queueSetupAfterLogin = function (contact, countryCode) { 390 try { 391 sessionStorage.setItem(SESSION_PASSKEY_SETUP_PENDING, JSON.stringify({ 392 contact: contact || '', 393 country_code: countryCode || '', 394 })); 395 } catch (e) { 396 /* ignore */ 397 } 398 }; 399 400 MemzoPasskey.prototype.consumeQueuedSetupAfterLogin = function () { 401 try { 402 var raw = sessionStorage.getItem(SESSION_PASSKEY_SETUP_PENDING); 403 if (!raw) { 404 return null; 405 } 406 sessionStorage.removeItem(SESSION_PASSKEY_SETUP_PENDING); 407 return JSON.parse(raw); 408 } catch (e) { 409 return null; 410 } 411 }; 412 413 MemzoPasskey.prototype.isPasskeyAlreadyOnDeviceError = function (err) { 414 var name = err && err.name ? String(err.name) : ''; 415 var msg = err && err.message ? String(err.message) : ''; 416 return name === 'InvalidStateError' 417 || /already registered|credentials already registered|already registered on this device|different (authenticator|device)/i.test(msg); 418 }; 419 420 MemzoPasskey.prototype.completeSilentLocalPasskeyRestore = function (contact, countryCode) { 421 this.markPasskeyRegisteredLocally(contact, countryCode); 422 return { error: false, restored: true, message: 'Success' }; 423 }; 424 425 MemzoPasskey.prototype.accountNeedsFirstPasskeySetup = function (passkeyMeta) { 426 if (!passkeyMeta) { 427 return true; 428 } 429 return passkeyMeta.setup_recommended === true || passkeyMeta.has_passkey !== true; 430 }; 431 432 MemzoPasskey.prototype.markPasskeyDeviceMissingForReSetup = function (contact, countryCode) { 433 try { 434 sessionStorage.setItem(SESSION_PASSKEY_RESETUP_AFTER_LOGIN, JSON.stringify({ 435 contact: String(contact || ''), 436 country_code: String(countryCode || ''), 437 })); 438 } catch (e) { 439 /* ignore */ 440 } 441 }; 442 443 MemzoPasskey.prototype.consumePasskeyReSetupAfterLogin = function (contact, countryCode) { 444 try { 445 var raw = sessionStorage.getItem(SESSION_PASSKEY_RESETUP_AFTER_LOGIN); 446 if (!raw) { 447 return false; 448 } 449 var parsed = JSON.parse(raw); 450 if (!parsed || parsed.contact !== String(contact || '')) { 451 return false; 452 } 453 var storedCc = parsed.country_code != null ? String(parsed.country_code) : ''; 454 var cc = countryCode != null ? String(countryCode) : ''; 455 return storedCc === cc; 456 } catch (e) { 457 return false; 458 } 459 }; 460 461 /** 462 * After login: decide whether to offer passkey setup. 463 * Never calls navigator.credentials.get/create here â that triggers browser/OS WebAuthn UI. 464 * 465 * hadLocal=false (localStorage cleared / new browser): 466 * - Custom modal, so the user knowingly restores/setup local passkey state 467 * 468 * hadLocal=true (local flag still set, passkey may have been removed from OS): 469 * - Re-setup flagged â custom modal 470 * - Otherwise trust local flag and do not prompt again 471 * 472 * Returns { offer: boolean }. 473 */ 474 MemzoPasskey.prototype.resolveShouldOfferSetupAfterLogin = function (passkeyMeta, contact, countryCode) { 475 var self = this; 476 if (!contact) { 477 return Promise.resolve({ offer: !self.hasAnyPasskeyRegisteredLocally() }); 478 } 479 480 var hadLocal = self.hasPasskeyRegisteredLocally(contact, countryCode); 481 var needsFirstSetup = self.accountNeedsFirstPasskeySetup(passkeyMeta); 482 var wantsReSetup = self.consumePasskeyReSetupAfterLogin(contact, countryCode); 483 484 if (!hadLocal) { 485 return Promise.resolve({ offer: true });
486 } 487 488 if (needsFirstSetup || wantsReSetup) { 489 return Promise.resolve({ offer: true }); 490 } 491 492 return Promise.resolve({ offer: false }); 493 }; 494 495 /** 496 * Single WebAuthn prompt for sign-in (do not probe first â that caused double PIN). 497 */ 498 MemzoPasskey.prototype.tryLoginWithPasskey = function (contact, countryCode, rememberMe) { 499 return this.loginWithPasskey(contact, countryCode, rememberMe); 500 }; 501 502 MemzoPasskey.prototype.checkPlatformAuthenticator = function () { 503 var self = this; 504 if (!this.isSupported()) { 505 return Promise.resolve(false); 506 } 507 if (this._platformAuthenticatorAvailable !== null) { 508 return Promise.resolve(this._platformAuthenticatorAvailable); 509 } 510 if (typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable !== 'function') { 511 this._platformAuthenticatorAvailable = true; 512 return Promise.resolve(true); 513 } 514 return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(function (ok) { 515 self._platformAuthenticatorAvailable = !!ok; 516 return self._platformAuthenticatorAvailable; 517 }).catch(function () { 518 self._platformAuthenticatorAvailable = false; 519 return false; 520 }); 521 }; 522 523 MemzoPasskey.prototype.getSupportState = function () { 524 var self = this; 525 return this.checkPlatformAuthenticator().then(function (platformOk) { 526 // WebAuthn can work without a detected platform authenticator (e.g. security keys, some Windows setups). 527 return { 528 supported: self.isSupported(), 529 secureContext: !!global.isSecureContext, 530 portable: self.isPortableDevice(), 531 mobile: self.isPortableDevice(), 532 platformAuthenticator: platformOk, 533 appleDesktop: isAppleDesktopBrowser(), 534 }; 535 }); 536 }; 537 538 /** 539 * Offer passkey setup after login when this browser has not saved a passkey for this number yet. 540 * Includes first-time account setup and adding a passkey on a new device (OS prompt follows). 541 */ 542 MemzoPasskey.prototype.shouldOfferSetupAfterLogin = function (passkeyMeta, contact, countryCode) { 543 if (this.hasPasskeyRegisteredLocally(contact, countryCode)) { 544 return false; 545 } 546 // Per-browser setup: offer on this device until local flag is set, even if account has passkeys elsewhere. 547 return true; 548 }; 549 550 MemzoPasskey.prototype.formatRegisterError = function (err, contact, countryCode) { 551 var msg = err && err.message ? String(err.message) : ''; 552 var name = err && err.name ? String(err.name) : ''; 553 if (this.isPasskeyAlreadyOnDeviceError(err)) { 554 this.markPasskeyRegisteredLocally(contact, countryCode); 555 return 'PASSKEY_SILENT_RESTORE'; 556 } 557 if (name === 'NotAllowedError' || /not allowed|cancelled|canceled/i.test(msg)) { 558 return 'Passkey setup was cancelled. On Mac, use Touch ID or your device password when Safari or Chrome prompts you.'; 559 } 560 if (name === 'SecurityError' || /security error/i.test(msg)) { 561 return 'Passkey setup needs a secure HTTPS connection on this site domain.'; 562 } 563 return msg || 'Passkey setup failed'; 564 }; 565 566 MemzoPasskey.prototype.shouldPreferPlatformAuthenticator = function () { 567 return this.checkPlatformAuthenticator(); 568 }; 569 570 MemzoPasskey.prototype.createRegistrationCredential = function (publicKeyOptions, preferPlatform) { 571 var publicKey = prepareCreationOptions(publicKeyOptions, preferPlatform); 572 return navigator.credentials.create({ publicKey: publicKey }); 573 }; 574 575 MemzoPasskey.prototype.createRegistrationCredentialWithFallback = function (publicKeyOptions) { 576 var self = this; 577 return this.shouldPreferPlatformAuthenticator().then(function (preferPlatform) { 578 return self.createRegistrationCredential(publicKeyOptions, preferPlatform).catch(function (err) { 579 if (!preferPlatform || !isAppleDesktopBrowser()) { 580 throw err; 581 } 582 var name = err && err.name ? String(err.name) : ''; 583 var msg = err && err.message ? String(err.message) : ''; 584 var retryable = name === 'NotAllowedError' || name === 'InvalidStateError' || name === 'NotSupportedError'
585 || /not allowed|not supported|invalid state/i.test(msg); 586 if (!retryable) { 587 throw err; 588 } 589 return self.createRegistrationCredential(publicKeyOptions, false); 590 }); 591 }); 592 }; 593 594 /** 595 * OS-specific labels for the pre-browser setup modal (WebAuthn shows native OS options). 596 */ 597 MemzoPasskey.prototype.getPlatformAuthCopy = function () { 598 var ua = (navigator && navigator.userAgent) ? navigator.userAgent : ''; 599 if (/iPhone/i.test(ua)) { 600 return { 601 iconClass: 'ri-user-smile-line', 602 lead: 'Use Face ID or Touch ID to sign in faster next time.', 603 action: 'Tap <strong>Continue</strong>, then follow the prompt on your iPhone.', 604 }; 605 } 606 if (/iPad/i.test(ua) || (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1)) { 607 return { 608 iconClass: 'ri-user-smile-line', 609 lead: 'Use Face ID or Touch ID to sign in faster next time.', 610 action: 'Tap <strong>Continue</strong>, then follow the prompt on your iPad.', 611 }; 612 } 613 if (/Android/i.test(ua)) { 614 return { 615 iconClass: 'ri-fingerprint-line', 616 lead: 'Use your fingerprint or screen lock to sign in faster next time.', 617 action: 'Tap <strong>Continue</strong>, then follow the prompt on your device.', 618 }; 619 } 620 if (/Windows/i.test(ua)) { 621 return { 622 iconClass: 'ri-shield-keyhole-line', 623 lead: 'Use Windows Hello to sign in faster next time.', 624 action: 'Tap <strong>Continue</strong>, then complete setup when Windows prompts you (face, fingerprint, or PIN).', 625 }; 626 } 627 if (/Macintosh|Mac OS/i.test(ua)) { 628 return { 629 iconClass: 'ri-fingerprint-line', 630 lead: 'Use Touch ID or your Mac login password to sign in faster next time.', 631 action: 'Click <strong>Continue</strong>, then complete the passkey prompt in Safari or Chrome (Touch ID, password, or iCloud Keychain).', 632 }; 633 } 634 return { 635 iconClass: 'ri-shield-keyhole-line', 636 lead: 'Use a passkey to sign in faster next time.', 637 action: 'Tap <strong>Continue</strong>, then follow the prompt from your browser or device.', 638 }; 639 }; 640 641 MemzoPasskey.prototype.getSetupModalCopy = function () { 642 var platform = this.getPlatformAuthCopy(); 643 return { 644 title: 'Set up a passkey', 645 variant: 'platform', 646 iconClass: platform.iconClass, 647 bodyHtml: '<p class="mb-0">' + platform.action + '</p>', 648 body: platform.action.replace(/<[^>]+>/g, ''), 649 confirmLabel: 'Continue', 650 skipLabel: 'Not now', 651 }; 652 }; 653 654 MemzoPasskey.prototype.promptSetupModal = function () { 655 var copy = this.getSetupModalCopy(); 656 if (typeof global.memzoPasskeyShowSetupModal === 'function') { 657 return global.memzoPasskeyShowSetupModal(copy); 658 } 659 return Promise.resolve(global.confirm(copy.title + '\n\n' + copy.body)); 660 }; 661 662 MemzoPasskey.prototype.formatLoginOptionsError = function (optRes) { 663 if (!optRes) { 664 return 'Could not start passkey login'; 665 } 666 if (optRes.message_id === 'passkey_not_registered' || (optRes.passkey && !optRes.passkey.has_passkey)) { 667 return 'No passkey on this account yet. Sign in with OTP first, then use "Set up passkey" on this device.'; 668 } 669 return apiErrorMessage(optRes, 'Could not start passkey login'); 670 }; 671 672 MemzoPasskey.prototype.fetchPasskeyStatus = function (contact, countryCode) { 673 if (!this.statusUrl) { 674 return Promise.resolve(null); 675 } 676 return apiPost(this.statusUrl, { 677 contact: contact, 678 country_code: countryCode,
679 }); 680 }; 681 682 /** 683 * Show passkey login when WebAuthn is supported and either: 684 * - this browser has a saved passkey (show immediately), or 685 * - the entered mobile number has a passkey on the server. 686 */ 687 MemzoPasskey.prototype.getPasskeyLoginVisibility = function (contact, countryCode) { 688 var self = this; 689 var hasContact = contact && String(contact).length > 0; 690 return this.getSupportState().then(function (state) { 691 if (!state.supported) { 692 return { show: false, reason: 'unsupported' }; 693 } 694 if (!hasContact) { 695 if (self.hasAnyPasskeyRegisteredLocally()) { 696 return { show: true, reason: 'local_device' }; 697 } 698 return { show: false, reason: 'need_contact' }; 699 } 700 return self.fetchPasskeyStatus(contact, countryCode).then(function (statusRes) { 701 if (statusRes && !statusRes.error && statusRes.user_found && statusRes.has_passkey) { 702 return { show: true, reason: 'server_passkey' }; 703 } 704 if (self.hasPasskeyRegisteredLocally(contact, countryCode)) { 705 return { show: true, reason: 'local_passkey' }; 706 } 707 return { show: false, reason: 'no_passkey' }; 708 }); 709 }); 710 }; 711 712 MemzoPasskey.prototype.loginOptions = function (contact, countryCode) { 713 return apiPost(this.apiBase + '?action=login_options', { 714 contact: contact, 715 country_code: countryCode, 716 account_type: this.accountType, 717 }); 718 }; 719 720 MemzoPasskey.prototype.registerOptionsFromSession = function () { 721 if (!this.sessionRegisterOptionsUrl) { 722 return Promise.reject(new Error('Session register URL not configured')); 723 } 724 return apiPost(this.sessionRegisterOptionsUrl, {}); 725 }; 726 727 MemzoPasskey.prototype.registerVerifyFromSession = function (challenge, credential, deviceLabel) { 728 if (!this.sessionRegisterVerifyUrl) { 729 return Promise.reject(new Error('Session register verify URL not configured')); 730 } 731 return apiPost(this.sessionRegisterVerifyUrl, { 732 challenge: challenge, 733 credential: credential, 734 device_label: deviceLabel || '', 735 }); 736 }; 737 738 MemzoPasskey.prototype.registerPasskey = function (apiKey, deviceLabel, useSession, identity) { 739 var self = this; 740 var regContact = identity && identity.contact ? identity.contact : ''; 741 var regCountryCode = identity && identity.countryCode ? identity.countryCode : ''; 742 if (!this.isSupported()) { 743 return Promise.reject(new Error('Passkeys need HTTPS and a supported browser. Please use OTP instead.')); 744 } 745 var optionsPromise = useSession 746 ? this.registerOptionsFromSession() 747 : apiPost(this.apiBase + '?action=register_options', { api_key: apiKey }); 748 749 return optionsPromise.then(function (optRes) { 750 if (optRes.error) { 751 throw new Error(apiErrorMessage(optRes, 'Could not start passkey setup')); 752 } 753 return self.createRegistrationCredentialWithFallback(optRes.publicKey).then(function (cred) { 754 if (!cred) { 755 throw new Error('Passkey setup was cancelled'); 756 } 757 var credential = credentialToRegisterJson(cred); 758 if (useSession) { 759 return self.registerVerifyFromSession(optRes.challenge, credential, deviceLabel); 760 } 761 return apiPost(self.apiBase + '?action=register_verify', { 762 api_key: apiKey, 763 challenge: optRes.challenge, 764 credential: credential, 765 device_label: deviceLabel || '', 766 }); 767 }).catch(function (err) { 768 if (self.isPasskeyAlreadyOnDeviceError(err)) { 769 return self.completeSilentLocalPasskeyRestore(regContact, regCountryCode); 770 } 771 var formatted = self.formatRegisterError(err, regContact, regCountryCode); 772 if (formatted === 'PASSKEY_SILENT_RESTORE') { 773 return self.completeSilentLocalPasskeyRestore(regContact, regCountryCode); 774 } 775 throw new Error(formatted); 776 }
776).then(function (verifyRes) { 777 if (verifyRes && verifyRes.restored) { 778 return verifyRes; 779 } 780 if (verifyRes.error) { 781 var verifyMsg = apiErrorMessage(verifyRes, 'Passkey setup failed'); 782 if (self.isPasskeyAlreadyOnDeviceError({ message: verifyMsg }) || /already set up/i.test(verifyMsg)) { 783 // Browser/server confirmed an existing credential; restore local state without showing setup errors. 784 return self.completeSilentLocalPasskeyRestore(regContact, regCountryCode); 785 } 786 throw new Error(verifyMsg); 787 } 788 self.markPasskeyRegisteredLocally(regContact, regCountryCode); 789 return verifyRes; 790 }); 791 }); 792 }; 793 794 MemzoPasskey.prototype.loginWithPasskey = function (contact, countryCode, rememberMe) { 795 var self = this; 796 if (!this.isSupported()) { 797 return Promise.reject(new Error('Passkeys are not supported in this browser. Use OTP instead.')); 798 } 799 return self.loginOptions(contact, countryCode).then(function (optRes) { 800 if (optRes.error) { 801 throw new Error(self.formatLoginOptionsError(optRes)); 802 } 803 var allowCount = (optRes.publicKey && optRes.publicKey.allowCredentials) 804 ? optRes.publicKey.allowCredentials.length 805 : 0; 806 var publicKey = prepareRequestOptions(optRes.publicKey); 807 return navigator.credentials.get({ publicKey: publicKey }).then(function (cred) { 808 if (!cred) { 809 throw new Error('Passkey sign-in was cancelled'); 810 } 811 return apiPost(self.sessionUrl, { 812 contact: contact, 813 country_code: countryCode, 814 remember_me: rememberMe ? '1' : '0', 815 challenge: optRes.challenge, 816 credential: credentialToLoginJson(cred), 817 }); 818 }).catch(function (err) { 819 var name = err && err.name ? String(err.name) : ''; 820 var msg = err && err.message ? String(err.message) : ''; 821 if (name === 'NotAllowedError' || /not allowed|cancelled|canceled/i.test(msg)) { 822 throw new Error('Passkey sign-in was cancelled.'); 823 } 824 if (allowCount > 0) { 825 self.clearPasskeyRegisteredLocally(contact, countryCode); 826 self.markPasskeyDeviceMissingForReSetup(contact, countryCode); 827 var missingErr = new Error( 828 'No passkey on this device. Sign in to continue.' 829 ); 830 missingErr.code = 'PASSKEY_NOT_ON_DEVICE'; 831 throw missingErr; 832 } 833 throw err; 834 }).then(function (data) { 835 if (data.error) { 836 throw new Error(apiErrorMessage(data, 'Passkey sign-in failed')); 837 } 838 self.markPasskeyRegisteredLocally(contact, countryCode); 839 return data; 840 }); 841 }); 842 }; 843 844 MemzoPasskey.prototype.offerRegistrationAfterLogin = function (apiKey, passkeyMeta, contact, countryCode) { 845 var self = this; 846 return this.getSupportState().then(function (state) { 847 if (!state.supported) { 848 return false; 849 } 850 return self.resolveShouldOfferSetupAfterLogin(passkeyMeta, contact, countryCode); 851 }).then(function (result) { 852 if (!result || !result.offer) { 853 return false; 854 } 855 return self.getSupportState().then(function (state) { 856 if (!state.supported) { 857 return false; 858 } 859 return self.promptSetupModal().then(function (accepted) { 860 if (!accepted) { 861 return false; 862 } 863 return self.registerPasskey( 864 apiKey, 865 self.getDeviceLabel(), 866 !!self.sessionRegisterOptionsUrl, 867 { contact: contact, countryCode: countryCode } 868 ); 869 }); 870 }); 871 }); 872 }; 873 874 /** 875 * On dashboard after redirect: show Memzo setup modal only (OS prompt on Continue). 876 * ui.showModal(copy) => Promise<boolean> 877 */ 878 MemzoPasskey.prototype.runQueuedSetupPromptIfNeeded = function (ui) { 879 var pending = this.consumeQueuedSetupAfterLogin(); 880 if (!pending) { 881 return Promise.resolve(false); 882 } 883 var self = this;
884 return this.getSupportState().then(function (state) { 885 if (!state.supported) { 886 return false; 887 } 888 var copy = self.getSetupModalCopy(); 889 var identity = { 890 contact: pending.contact || '', 891 countryCode: pending.country_code || '', 892 }; 893 return ui.showModal(copy).then(function (accepted) { 894 if (!accepted) { 895 return false; 896 } 897 if (ui.setLoading) { 898 ui.setLoading(true); 899 } 900 return self.registerPasskey('', self.getDeviceLabel(), true, identity).then(function () { 901 if (ui.setLoading) { 902 ui.setLoading(false); 903 } 904 if (ui.onSuccess) { 905 ui.onSuccess('Passkey saved on this device. You can use it next time.'); 906 } 907 return true; 908 }).catch(function (err) { 909 if (ui.setLoading) { 910 ui.setLoading(false); 911 } 912 var msg = err.message || 'Passkey setup failed'; 913 if (/already set up/i.test(msg)) { 914 if (ui.onSuccess) { 915 ui.onSuccess(msg); 916 } 917 return true; 918 } 919 if (ui.onError) { 920 ui.onError(msg); 921 } 922 return false; 923 }); 924 }); 925 }); 926 }; 927 928 global.MemzoPasskey = MemzoPasskey; 929})(typeof window !== 'undefined' ? window : this);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.