PageSourceSearch

https://live.memzo.ai/adminapp/assets/js/memzo-passkey.js

js memzo.ai collected 2026-10-05 12:15:11 UTC 38,200 bytes, 929 lines download raw bytes

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.