@modernman00/shared-js-lib 1.2.89 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AddressAutocomplete.js +164 -15
- package/AiBillSplitter.js +230 -0
- package/AiEntertainmentPrompt.js +130 -0
- package/AiFlyerStudio.js +215 -0
- package/AiMediaGuard.js +95 -0
- package/AiPrivacyGuard.js +63 -0
- package/AiSemanticSearch.js +107 -0
- package/AiVoiceRecorder.js +196 -0
- package/CanvasPhysicsEngine.js +568 -0
- package/FileUpload.js +182 -1
- package/HyperReality3DEngine.js +404 -0
- package/Loader.js +6 -2
- package/PwaInstall.js +20 -2
- package/README.md +78 -0
- package/SovereignBroadcasterClient.js +325 -0
- package/VisualSupremacyRuntime.js +312 -0
- package/WebAuthnClient.js +470 -0
- package/WebPushClient.js +308 -0
- package/axiosWrapper.js +1 -0
- package/config/version.php +1 -1
- package/index.js +13 -1
- package/package.json +14 -1
- package/release-history.md +6 -0
- package/templates/sw.template.js +105 -57
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebAuthnClient.js
|
|
3
|
+
*
|
|
4
|
+
* Handles client-side WebAuthn interactions (FaceID, TouchID, Passcodes).
|
|
5
|
+
* Implements David's Gatewatcher mandate: Defensive Degradation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class WebAuthnClient {
|
|
9
|
+
/**
|
|
10
|
+
* Checks if the device supports WebAuthn (Biometrics/Passkeys)
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
static isSupported() {
|
|
14
|
+
return window.PublicKeyCredential !== undefined
|
|
15
|
+
&& typeof window.PublicKeyCredential === 'function';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Base64URL to ArrayBuffer converter
|
|
20
|
+
*/
|
|
21
|
+
static base64URLStringToBuffer(base64URLString) {
|
|
22
|
+
const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/');
|
|
23
|
+
const padLength = (4 - (base64.length % 4)) % 4;
|
|
24
|
+
const padded = base64.padEnd(base64.length + padLength, '=');
|
|
25
|
+
const binary = atob(padded);
|
|
26
|
+
const buffer = new ArrayBuffer(binary.length);
|
|
27
|
+
const bytes = new Uint8Array(buffer);
|
|
28
|
+
for (let i = 0; i < binary.length; i++) {
|
|
29
|
+
bytes[i] = binary.charCodeAt(i);
|
|
30
|
+
}
|
|
31
|
+
return buffer;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* ArrayBuffer to Base64URL converter
|
|
36
|
+
*/
|
|
37
|
+
static bufferToBase64URLString(buffer) {
|
|
38
|
+
const bytes = new Uint8Array(buffer);
|
|
39
|
+
let str = '';
|
|
40
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
41
|
+
str += String.fromCharCode(bytes[i]);
|
|
42
|
+
}
|
|
43
|
+
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Initiates the registration of a new Passkey
|
|
48
|
+
* @param {Object} optionsFromServer JSON options from /webauthn/register/options
|
|
49
|
+
*/
|
|
50
|
+
static async registerDevice(optionsFromServer) {
|
|
51
|
+
if (!this.isSupported()) {
|
|
52
|
+
throw new Error("Device does not support biometrics/passkeys.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const publicKey = {
|
|
57
|
+
...optionsFromServer,
|
|
58
|
+
challenge: this.base64URLStringToBuffer(optionsFromServer.challenge),
|
|
59
|
+
user: {
|
|
60
|
+
...optionsFromServer.user,
|
|
61
|
+
id: this.base64URLStringToBuffer(optionsFromServer.user.id)
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (Array.isArray(publicKey.excludeCredentials)) {
|
|
66
|
+
publicKey.excludeCredentials = publicKey.excludeCredentials.map(cred => ({
|
|
67
|
+
...cred,
|
|
68
|
+
id: typeof cred.id === 'string' ? this.base64URLStringToBuffer(cred.id) : cred.id
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const credential = await navigator.credentials.create({ publicKey });
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
id: credential.id,
|
|
76
|
+
rawId: this.bufferToBase64URLString(credential.rawId),
|
|
77
|
+
type: credential.type,
|
|
78
|
+
response: {
|
|
79
|
+
attestationObject: this.bufferToBase64URLString(credential.response.attestationObject),
|
|
80
|
+
clientDataJSON: this.bufferToBase64URLString(credential.response.clientDataJSON),
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// David's Rule: Defensive Degradation. If it fails or is canceled, log and swallow politely.
|
|
85
|
+
console.warn("WebAuthn Registration failed or was canceled:", error);
|
|
86
|
+
const wrapped = new Error("Passkey registration was canceled or failed.");
|
|
87
|
+
wrapped.code = error && error.name ? error.name : 'UnknownError';
|
|
88
|
+
wrapped.originalError = error;
|
|
89
|
+
throw wrapped;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Initiates login using an existing Passkey
|
|
95
|
+
* @param {Object} optionsFromServer JSON options from /webauthn/login/options
|
|
96
|
+
*/
|
|
97
|
+
static async loginWithDevice(optionsFromServer) {
|
|
98
|
+
if (!this.isSupported()) {
|
|
99
|
+
throw new Error("Device does not support biometrics/passkeys.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const publicKey = {
|
|
104
|
+
...optionsFromServer,
|
|
105
|
+
challenge: this.base64URLStringToBuffer(optionsFromServer.challenge)
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Convert allowed credentials to buffers if they exist
|
|
109
|
+
if (publicKey.allowCredentials) {
|
|
110
|
+
publicKey.allowCredentials = publicKey.allowCredentials.map(cred => ({
|
|
111
|
+
...cred,
|
|
112
|
+
id: this.base64URLStringToBuffer(cred.id)
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const assertion = await navigator.credentials.get({ publicKey });
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
id: assertion.id,
|
|
120
|
+
rawId: this.bufferToBase64URLString(assertion.rawId),
|
|
121
|
+
type: assertion.type,
|
|
122
|
+
response: {
|
|
123
|
+
authenticatorData: this.bufferToBase64URLString(assertion.response.authenticatorData),
|
|
124
|
+
clientDataJSON: this.bufferToBase64URLString(assertion.response.clientDataJSON),
|
|
125
|
+
signature: this.bufferToBase64URLString(assertion.response.signature),
|
|
126
|
+
userHandle: assertion.response.userHandle ? this.bufferToBase64URLString(assertion.response.userHandle) : null,
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
} catch (error) {
|
|
130
|
+
// David's Rule: Defensive Degradation.
|
|
131
|
+
console.warn("WebAuthn Login failed or was canceled:", error);
|
|
132
|
+
throw new Error("Passkey login was canceled or failed. Please use your password.");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Checks if a user-verifying platform authenticator (Face ID, Touch ID, Fingerprint, Windows Hello) is available.
|
|
138
|
+
* Uses PublicKeyCredential.getClientCapabilities() with fallback to isUserVerifyingPlatformAuthenticatorAvailable().
|
|
139
|
+
* @returns {Promise<boolean>}
|
|
140
|
+
*/
|
|
141
|
+
static async isPlatformAuthenticatorAvailable() {
|
|
142
|
+
if (!this.isSupported()) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
if (typeof window.PublicKeyCredential.getClientCapabilities === 'function') {
|
|
147
|
+
const caps = await window.PublicKeyCredential.getClientCapabilities();
|
|
148
|
+
if (caps && typeof caps.userVerifyingPlatformAuthenticator === 'boolean') {
|
|
149
|
+
return caps.userVerifyingPlatformAuthenticator;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
|
|
153
|
+
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
154
|
+
}
|
|
155
|
+
} catch (e) {
|
|
156
|
+
console.warn("WebAuthn capability check failed:", e);
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Detects the platform-appropriate biometric label in British English.
|
|
163
|
+
* @param {string} [ua]
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
static detectBiometricLabel(ua = (typeof navigator !== 'undefined' ? navigator.userAgent : '')) {
|
|
167
|
+
const agent = String(ua || '');
|
|
168
|
+
if (/iPhone|iPad|iPod|Macintosh|Mac OS X/i.test(agent)) {
|
|
169
|
+
return 'Face ID or Touch ID';
|
|
170
|
+
}
|
|
171
|
+
if (/Android/i.test(agent)) {
|
|
172
|
+
return 'Fingerprint or Face Unlock';
|
|
173
|
+
}
|
|
174
|
+
if (/Windows/i.test(agent)) {
|
|
175
|
+
return 'Windows Hello';
|
|
176
|
+
}
|
|
177
|
+
return 'Biometric Passkey';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Determines if the post-login Biometric Passkey Activation UI should appear.
|
|
182
|
+
* @param {Object} state
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
static shouldShowActivationPrompt(state) {
|
|
186
|
+
if (!state || state.authenticated !== true || state.hasPasskey === true) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
const key = state.userKey || state.userId || 'default';
|
|
190
|
+
try {
|
|
191
|
+
if (localStorage.getItem('biometric_enrolled_' + key) === '1') {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
const snoozeUntil = Number(localStorage.getItem('biometric_snooze_' + key) || '0');
|
|
195
|
+
if (snoozeUntil > Date.now()) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
} catch (_) {
|
|
199
|
+
// Ignore storage access errors in strict private modes
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Snoozes the activation prompt for the specified number of hours (default 72h).
|
|
206
|
+
* @param {string} userKey
|
|
207
|
+
* @param {number} [hours=72]
|
|
208
|
+
*/
|
|
209
|
+
static snoozeActivationPrompt(userKey, hours = 72) {
|
|
210
|
+
if (!userKey) return;
|
|
211
|
+
try {
|
|
212
|
+
const until = Date.now() + hours * 3600 * 1000;
|
|
213
|
+
localStorage.setItem('biometric_snooze_' + userKey, String(until));
|
|
214
|
+
} catch (_) {}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Marks the current device + user pair as enrolled in localStorage.
|
|
219
|
+
* @param {string} userKey
|
|
220
|
+
*/
|
|
221
|
+
static markEnrolled(userKey) {
|
|
222
|
+
if (!userKey) return;
|
|
223
|
+
try {
|
|
224
|
+
localStorage.setItem('biometric_enrolled_' + userKey, '1');
|
|
225
|
+
localStorage.removeItem('biometric_snooze_' + userKey);
|
|
226
|
+
} catch (_) {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Renders and mounts the native Biometric Passkey Activation UI modal / bottom-sheet.
|
|
231
|
+
* @param {Object} state
|
|
232
|
+
* @param {Object} [options]
|
|
233
|
+
* @returns {HTMLElement|null}
|
|
234
|
+
*/
|
|
235
|
+
static renderPromptModal(state = {}, options = {}) {
|
|
236
|
+
if (typeof document === 'undefined' || !document.body) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
const existing = document.getElementById('universal-passkey-prompt-backdrop');
|
|
240
|
+
if (existing) {
|
|
241
|
+
return existing;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const appName = state.appName || 'this app';
|
|
245
|
+
const userKey = state.userKey || state.userId || 'default';
|
|
246
|
+
const userEmail = state.userEmail || state.userName || 'Verified Account';
|
|
247
|
+
const bioLabel = this.detectBiometricLabel();
|
|
248
|
+
|
|
249
|
+
const backdrop = document.createElement('div');
|
|
250
|
+
backdrop.id = 'universal-passkey-prompt-backdrop';
|
|
251
|
+
backdrop.setAttribute('role', 'dialog');
|
|
252
|
+
backdrop.setAttribute('aria-modal', 'true');
|
|
253
|
+
backdrop.setAttribute('aria-labelledby', 'universal-passkey-prompt-title');
|
|
254
|
+
backdrop.style.cssText = 'position:fixed;inset:0;z-index:99998;background:rgba(15,23,42,0.68);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);display:flex;align-items:flex-end;justify-content:center;padding:0;opacity:0;transition:opacity 0.22s ease-out;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
|
255
|
+
|
|
256
|
+
const styleTag = document.createElement('style');
|
|
257
|
+
styleTag.textContent = `
|
|
258
|
+
@media (min-width: 640px) {
|
|
259
|
+
#universal-passkey-prompt-backdrop { align-items: center !important; padding: 16px !important; }
|
|
260
|
+
#universal-passkey-prompt-card { border-radius: 24px !important; max-width: 420px !important; transform: translateY(8px); }
|
|
261
|
+
}
|
|
262
|
+
@keyframes passkeySpin { to { transform: rotate(360deg); } }
|
|
263
|
+
`;
|
|
264
|
+
backdrop.appendChild(styleTag);
|
|
265
|
+
|
|
266
|
+
const card = document.createElement('div');
|
|
267
|
+
card.id = 'universal-passkey-prompt-card';
|
|
268
|
+
card.style.cssText = 'width:100%;max-width:480px;background:linear-gradient(180deg,#0f172a 0%,#1e293b 100%);color:#f8fafc;border:1px solid rgba(148,163,184,0.22);border-radius:24px 24px 0 0;padding:24px 24px calc(24px + env(safe-area-inset-bottom,0px));box-shadow:0 25px 50px -12px rgba(0,0,0,0.65);transform:translateY(16px);transition:transform 0.25s cubic-bezier(0.16,1,0.3,1);box-sizing:border-box;';
|
|
269
|
+
|
|
270
|
+
card.innerHTML = `
|
|
271
|
+
<div style="width:40px;height:4px;background:rgba(148,163,184,0.35);border-radius:999px;margin:0 auto 16px;"></div>
|
|
272
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
|
273
|
+
<div style="display:inline-flex;align-items:center;gap:10px;background:rgba(16,185,129,0.14);border:1px solid rgba(16,185,129,0.35);padding:6px 12px;border-radius:999px;">
|
|
274
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
275
|
+
<path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/>
|
|
276
|
+
</svg>
|
|
277
|
+
<span style="font-size:12px;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;color:#6ee7b7;white-space:nowrap;">Biometric Security</span>
|
|
278
|
+
</div>
|
|
279
|
+
<span id="universal-passkey-account-pill" style="font-size:12px;color:#94a3b8;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
|
|
280
|
+
</div>
|
|
281
|
+
<h2 id="universal-passkey-prompt-title" style="margin:0 0 8px;font-size:22px;line-height:1.25;font-weight:800;letter-spacing:-0.02em;color:#ffffff;">Activate Biometric Sign-In</h2>
|
|
282
|
+
<p id="universal-passkey-prompt-desc" style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#cbd5e1;"></p>
|
|
283
|
+
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:18px;">
|
|
284
|
+
<div style="background:rgba(30,41,59,0.75);border:1px solid rgba(148,163,184,0.16);border-radius:12px;padding:10px 8px;text-align:center;">
|
|
285
|
+
<div style="font-size:12px;font-weight:700;color:#f8fafc;">1-Tap Access</div>
|
|
286
|
+
<div style="font-size:11px;color:#94a3b8;margin-top:2px;">Instant sign-in</div>
|
|
287
|
+
</div>
|
|
288
|
+
<div style="background:rgba(30,41,59,0.75);border:1px solid rgba(148,163,184,0.16);border-radius:12px;padding:10px 8px;text-align:center;">
|
|
289
|
+
<div style="font-size:12px;font-weight:700;color:#f8fafc;">Device-Bound</div>
|
|
290
|
+
<div style="font-size:11px;color:#94a3b8;margin-top:2px;">Private enclave</div>
|
|
291
|
+
</div>
|
|
292
|
+
<div style="background:rgba(30,41,59,0.75);border:1px solid rgba(148,163,184,0.16);border-radius:12px;padding:10px 8px;text-align:center;">
|
|
293
|
+
<div style="font-size:12px;font-weight:700;color:#f8fafc;">Phishing-Proof</div>
|
|
294
|
+
<div style="font-size:11px;color:#94a3b8;margin-top:2px;">FIDO2 standard</div>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
<div id="universal-passkey-prompt-feedback" role="status" aria-live="polite" style="display:none;margin-bottom:14px;padding:10px 12px;border-radius:12px;font-size:13px;line-height:1.4;"></div>
|
|
298
|
+
<div style="display:flex;flex-direction:column;gap:10px;">
|
|
299
|
+
<button type="button" id="universal-passkey-activate-btn" style="min-height:48px;min-width:44px;width:100%;display:inline-flex;align-items:center;justify-content:center;gap:10px;padding:12px 20px;border-radius:14px;border:none;background:linear-gradient(135deg,#10b981 0%,#059669 100%);color:#ffffff;font-size:15px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;box-shadow:0 10px 20px -5px rgba(16,185,129,0.4);transition:transform 0.15s ease,filter 0.15s ease;">
|
|
300
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
301
|
+
<path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/>
|
|
302
|
+
</svg>
|
|
303
|
+
<span id="universal-passkey-activate-label"></span>
|
|
304
|
+
</button>
|
|
305
|
+
<button type="button" id="universal-passkey-later-btn" style="min-height:44px;min-width:44px;width:100%;display:inline-flex;align-items:center;justify-content:center;padding:10px 16px;border-radius:12px;border:1px solid rgba(148,163,184,0.22);background:transparent;color:#cbd5e1;font-size:14px;font-weight:600;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:background 0.15s ease,color 0.15s ease;">
|
|
306
|
+
Maybe Later
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
`;
|
|
310
|
+
|
|
311
|
+
backdrop.appendChild(card);
|
|
312
|
+
document.body.appendChild(backdrop);
|
|
313
|
+
|
|
314
|
+
const accountPill = card.querySelector('#universal-passkey-account-pill');
|
|
315
|
+
if (accountPill) {
|
|
316
|
+
accountPill.textContent = userEmail;
|
|
317
|
+
accountPill.setAttribute('title', userEmail);
|
|
318
|
+
}
|
|
319
|
+
const descEl = card.querySelector('#universal-passkey-prompt-desc');
|
|
320
|
+
if (descEl) {
|
|
321
|
+
descEl.textContent = `Sign in to ${appName} instantly next time using your device's recognised biometric sensor (${bioLabel}). Your biometric data never leaves this device.`;
|
|
322
|
+
}
|
|
323
|
+
const activateLabelEl = card.querySelector('#universal-passkey-activate-label');
|
|
324
|
+
if (activateLabelEl) {
|
|
325
|
+
activateLabelEl.textContent = `Activate ${bioLabel}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
requestAnimationFrame(() => {
|
|
329
|
+
backdrop.style.opacity = '1';
|
|
330
|
+
card.style.transform = 'translateY(0)';
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const closePrompt = () => {
|
|
334
|
+
backdrop.style.opacity = '0';
|
|
335
|
+
card.style.transform = 'translateY(16px)';
|
|
336
|
+
setTimeout(() => {
|
|
337
|
+
if (backdrop.parentNode) {
|
|
338
|
+
backdrop.parentNode.removeChild(backdrop);
|
|
339
|
+
}
|
|
340
|
+
}, 220);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const showFeedback = (msg, isSuccess = false) => {
|
|
344
|
+
const fb = card.querySelector('#universal-passkey-prompt-feedback');
|
|
345
|
+
if (!fb) return;
|
|
346
|
+
fb.style.display = 'block';
|
|
347
|
+
fb.style.background = isSuccess ? 'rgba(16,185,129,0.18)' : 'rgba(245,158,11,0.16)';
|
|
348
|
+
fb.style.border = isSuccess ? '1px solid rgba(16,185,129,0.4)' : '1px solid rgba(245,158,11,0.38)';
|
|
349
|
+
fb.style.color = isSuccess ? '#a7f3d0' : '#fde68a';
|
|
350
|
+
fb.textContent = msg;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const activateBtn = card.querySelector('#universal-passkey-activate-btn');
|
|
354
|
+
const laterBtn = card.querySelector('#universal-passkey-later-btn');
|
|
355
|
+
|
|
356
|
+
if (laterBtn) {
|
|
357
|
+
laterBtn.addEventListener('click', () => {
|
|
358
|
+
this.snoozeActivationPrompt(userKey, 72);
|
|
359
|
+
closePrompt();
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (activateBtn) {
|
|
364
|
+
const originalHTML = activateBtn.innerHTML;
|
|
365
|
+
activateBtn.addEventListener('click', async () => {
|
|
366
|
+
if (activateBtn.disabled) return;
|
|
367
|
+
activateBtn.disabled = true;
|
|
368
|
+
activateBtn.style.opacity = '0.85';
|
|
369
|
+
activateBtn.style.cursor = 'wait';
|
|
370
|
+
activateBtn.innerHTML = `
|
|
371
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="animation:passkeySpin 0.8s linear infinite;" aria-hidden="true">
|
|
372
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
373
|
+
</svg>
|
|
374
|
+
<span>Authorising Biometrics...</span>
|
|
375
|
+
`;
|
|
376
|
+
|
|
377
|
+
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
|
378
|
+
const headers = {
|
|
379
|
+
'Accept': 'application/json',
|
|
380
|
+
'Content-Type': 'application/json',
|
|
381
|
+
'X-Requested-With': 'XMLHttpRequest'
|
|
382
|
+
};
|
|
383
|
+
if (csrfMeta && csrfMeta.content) {
|
|
384
|
+
headers['X-CSRF-TOKEN'] = csrfMeta.content;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const optRes = await fetch(options.optionsEndpoint || '/webauthn/register/options', {
|
|
389
|
+
method: 'POST',
|
|
390
|
+
headers,
|
|
391
|
+
credentials: 'same-origin',
|
|
392
|
+
body: JSON.stringify({ role: state.userRole || 'users' })
|
|
393
|
+
});
|
|
394
|
+
const optJson = await optRes.json();
|
|
395
|
+
const creationOptions = optJson.token || optJson.data || optJson;
|
|
396
|
+
if (!optRes.ok || !creationOptions || !creationOptions.challenge) {
|
|
397
|
+
throw new Error(optJson.message || 'Unable to initialise biometric challenge.');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const credentialPayload = await this.registerDevice(creationOptions);
|
|
401
|
+
credentialPayload.role = state.userRole || 'users';
|
|
402
|
+
credentialPayload.deviceName = bioLabel;
|
|
403
|
+
|
|
404
|
+
const verifyRes = await fetch(options.registerEndpoint || '/webauthn/register', {
|
|
405
|
+
method: 'POST',
|
|
406
|
+
headers,
|
|
407
|
+
credentials: 'same-origin',
|
|
408
|
+
body: JSON.stringify(credentialPayload)
|
|
409
|
+
});
|
|
410
|
+
const verifyJson = await verifyRes.json();
|
|
411
|
+
if (!verifyRes.ok && verifyJson.status !== 'success') {
|
|
412
|
+
throw new Error(verifyJson.message || 'Failed to verify biometric passkey.');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this.markEnrolled(userKey);
|
|
416
|
+
showFeedback('Biometric Passkey activated! You can now sign in with 1 tap.', true);
|
|
417
|
+
setTimeout(closePrompt, 1100);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (err && err.code === 'InvalidStateError') {
|
|
420
|
+
// Self-healing: authenticator already holds a credential for this user/RP
|
|
421
|
+
this.markEnrolled(userKey);
|
|
422
|
+
showFeedback('Biometric Passkey is already active on this device.', true);
|
|
423
|
+
setTimeout(closePrompt, 1100);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
activateBtn.disabled = false;
|
|
427
|
+
activateBtn.style.opacity = '1';
|
|
428
|
+
activateBtn.style.cursor = 'pointer';
|
|
429
|
+
activateBtn.innerHTML = originalHTML;
|
|
430
|
+
const rehydratedLabel = activateBtn.querySelector('#universal-passkey-activate-label');
|
|
431
|
+
if (rehydratedLabel) {
|
|
432
|
+
rehydratedLabel.textContent = `Activate ${bioLabel}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let msg = 'Biometric verification was cancelled or did not complete. Tap Activate whenever you are ready to try again.';
|
|
436
|
+
if (err) {
|
|
437
|
+
if (err.name === 'SecurityError' || err.code === 'SecurityError') {
|
|
438
|
+
msg = 'Passkey registration requires HTTPS or a valid domain origin.';
|
|
439
|
+
} else if (err.name === 'NotSupportedError' || err.code === 'NotSupportedError') {
|
|
440
|
+
msg = 'Biometric authentication is not supported or enabled on this browser.';
|
|
441
|
+
} else if (err.message && err.name !== 'NotAllowedError' && err.code !== 'NotAllowedError') {
|
|
442
|
+
msg = `${err.message}. Tap Activate to try again.`;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
showFeedback(msg, false);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return backdrop;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Automatically evaluates post-login state and shows the Biometric Passkey Activation UI if eligible.
|
|
455
|
+
* @param {Object} [customState]
|
|
456
|
+
* @returns {Promise<boolean>}
|
|
457
|
+
*/
|
|
458
|
+
static async initPostLoginPrompt(customState = null) {
|
|
459
|
+
const state = customState || (typeof window !== 'undefined' ? window.__WEBAUTHN_PROMPT_STATE__ : null);
|
|
460
|
+
if (!this.shouldShowActivationPrompt(state)) {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
const available = await this.isPlatformAuthenticatorAvailable();
|
|
464
|
+
if (!available) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
this.renderPromptModal(state);
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
}
|