@qrauth/web-components 0.1.0

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/dist/login.js ADDED
@@ -0,0 +1,769 @@
1
+ import { QRAuthElement } from './base.js';
2
+ // Same shield SVG as the browser SDK
3
+ const SHIELD_SVG = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
4
+ <path d="M48 24 H152 Q166 24 166 38 V108 Q166 156 100 182 Q34 156 34 108 V38 Q34 24 48 24 Z"
5
+ fill="none" stroke="#fff" stroke-width="5"/>
6
+ <g fill="#fff" opacity="0.2">
7
+ <rect x="52" y="40" width="18" height="18" rx="3"/>
8
+ <rect x="78" y="40" width="18" height="18" rx="3"/>
9
+ <rect x="130" y="40" width="18" height="18" rx="3"/>
10
+ <rect x="52" y="66" width="18" height="18" rx="3"/>
11
+ <rect x="104" y="66" width="18" height="18" rx="3"/>
12
+ <rect x="78" y="92" width="18" height="18" rx="3"/>
13
+ <rect x="130" y="92" width="18" height="18" rx="3"/>
14
+ <rect x="52" y="118" width="18" height="18" rx="3"/>
15
+ <rect x="104" y="118" width="18" height="18" rx="3"/>
16
+ </g>
17
+ <path d="M62 104 L88 134 L144 64"
18
+ fill="none" stroke="#fff" stroke-width="15"
19
+ stroke-linecap="round" stroke-linejoin="round"/>
20
+ </svg>`;
21
+ // Check icon for success state
22
+ const CHECK_SVG = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
23
+ <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" fill="currentColor"/>
24
+ </svg>`;
25
+ // Spinner SVG
26
+ const SPINNER_SVG = `<svg class="spinner" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
27
+ <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2.5"
28
+ stroke-dasharray="31.416" stroke-dashoffset="10" stroke-linecap="round"/>
29
+ </svg>`;
30
+ /**
31
+ * <qrauth-login> — Drop-in QR authentication button / inline widget.
32
+ *
33
+ * Attributes:
34
+ * tenant — Your QRAuth App client ID (required)
35
+ * theme — "light" (default) | "dark"
36
+ * base-url — API base URL (default: https://qrauth.io)
37
+ * scopes — Space-separated scopes (default: "identity")
38
+ * redirect-url — Optional redirect after successful auth
39
+ * on-auth — Name of a global callback function to call on success
40
+ * display — "button" (default) | "inline"
41
+ * animated — If present, enables subtle QR pulse animation
42
+ *
43
+ * Events (bubbles + composed, cross shadow-DOM):
44
+ * qrauth:authenticated — detail: { sessionId, user, signature }
45
+ * qrauth:expired — fired when session times out
46
+ * qrauth:error — detail: { message }
47
+ * qrauth:scanned — fired when QR is scanned (before approval)
48
+ *
49
+ * @example
50
+ * <qrauth-login tenant="qrauth_app_xxx"></qrauth-login>
51
+ * <qrauth-login tenant="qrauth_app_xxx" display="inline" theme="dark"></qrauth-login>
52
+ */
53
+ export class QRAuthLogin extends QRAuthElement {
54
+ static get observedAttributes() {
55
+ return [...super.observedAttributes, 'scopes', 'redirect-url', 'on-auth', 'display', 'animated'];
56
+ }
57
+ _sessionId = null;
58
+ _codeVerifier = null;
59
+ _status = 'idle';
60
+ _expiresAt = null;
61
+ _timerInterval = null;
62
+ _pollTimeout = null;
63
+ get scopes() { return this.getAttribute('scopes') || 'identity'; }
64
+ get redirectUrl() { return this.getAttribute('redirect-url'); }
65
+ get onAuth() { return this.getAttribute('on-auth'); }
66
+ get display() { return this.getAttribute('display') || 'button'; }
67
+ get animated() { return this.hasAttribute('animated'); }
68
+ // ---- Lifecycle ---------------------------------------------------------
69
+ disconnectedCallback() {
70
+ super.disconnectedCallback();
71
+ this._clearTimers();
72
+ }
73
+ // ---- Render ------------------------------------------------------------
74
+ render() {
75
+ if (this.display === 'inline') {
76
+ this._renderInline();
77
+ }
78
+ else {
79
+ this._renderButton();
80
+ }
81
+ }
82
+ _renderButton() {
83
+ this.shadow.innerHTML = `
84
+ <style>
85
+ ${this.getBaseStyles()}
86
+ :host { display: inline-block; }
87
+
88
+ .btn {
89
+ display: inline-flex;
90
+ align-items: center;
91
+ gap: 10px;
92
+ padding: 12px 24px;
93
+ background: var(--_btn-bg);
94
+ color: #fff;
95
+ border: none;
96
+ border-radius: var(--_radius);
97
+ font-size: 15px;
98
+ font-weight: 600;
99
+ cursor: pointer;
100
+ transition: background 0.2s, transform 0.15s, box-shadow 0.2s;
101
+ user-select: none;
102
+ line-height: 1.4;
103
+ font-family: inherit;
104
+ }
105
+ .btn:hover:not(:disabled) {
106
+ background: var(--_btn-hover);
107
+ transform: translateY(-1px);
108
+ box-shadow: 0 4px 12px rgba(27,42,74,0.3);
109
+ }
110
+ .btn:active:not(:disabled) { transform: translateY(0); }
111
+ .btn:disabled { opacity: 0.6; cursor: not-allowed; }
112
+ .btn-icon { width: 22px; height: 22px; flex-shrink: 0; }
113
+
114
+ /* Modal overlay */
115
+ .overlay {
116
+ position: fixed;
117
+ inset: 0;
118
+ background: rgba(0,0,0,0.55);
119
+ z-index: 99999;
120
+ display: flex;
121
+ align-items: center;
122
+ justify-content: center;
123
+ animation: fade-in 0.2s ease;
124
+ }
125
+ .overlay.hidden { display: none; }
126
+
127
+ @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
128
+ @keyframes slide-up {
129
+ from { transform: translateY(24px); opacity: 0; }
130
+ to { transform: translateY(0); opacity: 1; }
131
+ }
132
+
133
+ .modal {
134
+ background: var(--_bg);
135
+ border-radius: 16px;
136
+ padding: 32px 28px 24px;
137
+ max-width: 380px;
138
+ width: 90vw;
139
+ box-shadow: var(--_shadow);
140
+ text-align: center;
141
+ animation: slide-up 0.3s ease;
142
+ position: relative;
143
+ }
144
+
145
+ ${this._sharedModalStyles()}
146
+ </style>
147
+
148
+ <button class="btn" id="open-btn" aria-label="Sign in with QRAuth">
149
+ <span class="btn-icon">${SHIELD_SVG}</span>
150
+ Sign in with QRAuth
151
+ </button>
152
+
153
+ <div class="overlay hidden" id="overlay" role="dialog" aria-modal="true" aria-label="QRAuth sign-in">
154
+ <div class="modal">
155
+ ${this._modalContent()}
156
+ </div>
157
+ </div>
158
+ `;
159
+ this._bindButtonEvents();
160
+ }
161
+ _renderInline() {
162
+ this.shadow.innerHTML = `
163
+ <style>
164
+ ${this.getBaseStyles()}
165
+ :host { display: block; }
166
+
167
+ .inline-container {
168
+ background: var(--_bg);
169
+ border: 1px solid var(--_border);
170
+ border-radius: 16px;
171
+ padding: 28px 24px 20px;
172
+ text-align: center;
173
+ max-width: 360px;
174
+ margin: 0 auto;
175
+ }
176
+
177
+ /* Idle state: show start button */
178
+ .start-btn {
179
+ display: inline-flex;
180
+ align-items: center;
181
+ gap: 10px;
182
+ padding: 12px 24px;
183
+ background: var(--_btn-bg);
184
+ color: #fff;
185
+ border: none;
186
+ border-radius: var(--_radius);
187
+ font-size: 15px;
188
+ font-weight: 600;
189
+ cursor: pointer;
190
+ transition: background 0.2s, transform 0.15s, box-shadow 0.2s;
191
+ font-family: inherit;
192
+ width: 100%;
193
+ justify-content: center;
194
+ }
195
+ .start-btn:hover:not(:disabled) {
196
+ background: var(--_btn-hover);
197
+ transform: translateY(-1px);
198
+ }
199
+ .start-btn:disabled { opacity: 0.6; cursor: not-allowed; }
200
+ .btn-icon { width: 22px; height: 22px; flex-shrink: 0; }
201
+
202
+ ${this._sharedModalStyles()}
203
+ </style>
204
+
205
+ <div class="inline-container">
206
+ <div id="inline-body">
207
+ ${this._inlineIdleContent()}
208
+ </div>
209
+ </div>
210
+ `;
211
+ this._bindInlineEvents();
212
+ }
213
+ _inlineIdleContent() {
214
+ return `
215
+ <button class="start-btn" id="start-btn" aria-label="Sign in with QRAuth">
216
+ <span class="btn-icon">${SHIELD_SVG}</span>
217
+ Sign in with QRAuth
218
+ </button>
219
+ `;
220
+ }
221
+ // ---- Shared modal HTML -------------------------------------------------
222
+ _modalContent() {
223
+ return `
224
+ <button class="close-btn" id="close-btn" aria-label="Close">&times;</button>
225
+ <div id="modal-body">
226
+ ${this._bodyForStatus('idle')}
227
+ </div>
228
+ `;
229
+ }
230
+ _bodyForStatus(status, data) {
231
+ switch (status) {
232
+ case 'idle':
233
+ return `
234
+ <div class="state-header">
235
+ <div class="brand-icon">${SHIELD_SVG}</div>
236
+ <h3>Sign in with QRAuth</h3>
237
+ <p>Secure, passwordless authentication</p>
238
+ </div>
239
+ `;
240
+ case 'loading':
241
+ return `
242
+ <div class="state-loading">
243
+ <div class="spinner-wrap">${SPINNER_SVG}</div>
244
+ <p class="status-text">Generating QR code...</p>
245
+ </div>
246
+ `;
247
+ case 'pending':
248
+ case 'SCANNED': {
249
+ const qrImageUrl = data?.qrUrl
250
+ ? `https://api.qrserver.com/v1/create-qr-code/?size=440x440&data=${encodeURIComponent(data.qrUrl)}`
251
+ : '';
252
+ const isScanned = status === 'SCANNED';
253
+ const scanClass = isScanned ? 'scanned' : '';
254
+ return `
255
+ <h3>Sign in with QRAuth</h3>
256
+ <p class="subtitle">Scan this QR code with your phone camera</p>
257
+ <div class="qr-frame ${scanClass}${this.animated ? ' animated' : ''}">
258
+ <img src="${qrImageUrl}" alt="QR Code for authentication" width="220" height="220" loading="lazy"/>
259
+ <div class="qr-badge">${SHIELD_SVG}</div>
260
+ ${isScanned ? '<div class="scanned-overlay"><span class="scanned-check">' + CHECK_SVG + '</span></div>' : ''}
261
+ </div>
262
+ <div class="status-text ${isScanned ? 'status-scanned' : ''}">
263
+ ${isScanned
264
+ ? '&#10003; QR code scanned &mdash; waiting for approval...'
265
+ : 'Waiting for scan...'}
266
+ </div>
267
+ <div class="timer" id="timer">${data?.timerHtml ?? ''}</div>
268
+ <div class="footer">Secured by <a href="https://qrauth.io" target="_blank" rel="noopener">QRAuth</a></div>
269
+ `;
270
+ }
271
+ case 'APPROVED':
272
+ return `
273
+ <div class="state-approved">
274
+ <div class="success-icon">${CHECK_SVG}</div>
275
+ <h3>Identity verified!</h3>
276
+ <p class="subtitle">You are now signed in</p>
277
+ </div>
278
+ `;
279
+ case 'DENIED':
280
+ return `
281
+ <div class="state-denied">
282
+ <div class="error-icon">&#10007;</div>
283
+ <h3>Authentication denied</h3>
284
+ <p class="subtitle">The request was declined on your device.</p>
285
+ <button class="retry-btn" id="retry-btn">Try again</button>
286
+ </div>
287
+ `;
288
+ case 'EXPIRED':
289
+ return `
290
+ <div class="state-expired">
291
+ <div class="expired-icon">&#8987;</div>
292
+ <h3>Session expired</h3>
293
+ <p class="subtitle">The QR code has timed out.</p>
294
+ <button class="retry-btn" id="retry-btn">Try again</button>
295
+ </div>
296
+ `;
297
+ case 'error':
298
+ return `
299
+ <div class="state-error">
300
+ <div class="error-icon">&#9888;</div>
301
+ <h3>Something went wrong</h3>
302
+ <p class="subtitle">${data?.errorMessage ?? 'Failed to start authentication.'}</p>
303
+ <button class="retry-btn" id="retry-btn">Try again</button>
304
+ </div>
305
+ `;
306
+ default:
307
+ return '';
308
+ }
309
+ }
310
+ // ---- Shared CSS for modal content --------------------------------------
311
+ _sharedModalStyles() {
312
+ return `
313
+ /* Typography */
314
+ h3 {
315
+ font-size: 18px;
316
+ font-weight: 700;
317
+ color: var(--_text);
318
+ margin: 0 0 6px;
319
+ }
320
+ .subtitle {
321
+ font-size: 13px;
322
+ color: var(--_text-muted);
323
+ margin: 0 0 20px;
324
+ line-height: 1.5;
325
+ }
326
+
327
+ /* Close button */
328
+ .close-btn {
329
+ position: absolute;
330
+ top: 12px;
331
+ right: 14px;
332
+ background: none;
333
+ border: none;
334
+ cursor: pointer;
335
+ color: var(--_text-muted);
336
+ font-size: 22px;
337
+ line-height: 1;
338
+ padding: 4px 6px;
339
+ border-radius: 6px;
340
+ transition: color 0.15s, background 0.15s;
341
+ font-family: inherit;
342
+ }
343
+ .close-btn:hover { color: var(--_text); background: var(--_surface); }
344
+
345
+ /* Brand icon in idle state */
346
+ .state-header { padding: 8px 0 4px; }
347
+ .brand-icon {
348
+ width: 56px;
349
+ height: 56px;
350
+ background: var(--_btn-bg);
351
+ border-radius: 14px;
352
+ display: inline-flex;
353
+ align-items: center;
354
+ justify-content: center;
355
+ margin-bottom: 14px;
356
+ padding: 10px;
357
+ }
358
+ .brand-icon svg { width: 36px; height: 36px; }
359
+
360
+ /* Loading state */
361
+ .state-loading {
362
+ padding: 32px 0;
363
+ display: flex;
364
+ flex-direction: column;
365
+ align-items: center;
366
+ gap: 16px;
367
+ }
368
+ .spinner-wrap { color: var(--_primary); }
369
+ .spinner {
370
+ width: 40px;
371
+ height: 40px;
372
+ animation: spin 0.8s linear infinite;
373
+ }
374
+ @keyframes spin { to { transform: rotate(360deg); } }
375
+
376
+ /* QR frame */
377
+ .qr-frame {
378
+ display: inline-block;
379
+ padding: 14px;
380
+ background: #fff;
381
+ border: 2px solid var(--_border);
382
+ border-radius: var(--_radius);
383
+ margin-bottom: 14px;
384
+ position: relative;
385
+ transition: border-color 0.3s;
386
+ }
387
+ .qr-frame.scanned { border-color: var(--_success); }
388
+ .qr-frame.animated { animation: qr-pulse 2s ease-in-out infinite; }
389
+ @keyframes qr-pulse {
390
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(0,167,111,0); }
391
+ 50% { box-shadow: 0 0 0 8px rgba(0,167,111,0.15); }
392
+ }
393
+ .qr-frame img { display: block; width: 220px; height: 220px; border-radius: 4px; }
394
+ .qr-badge {
395
+ position: absolute;
396
+ top: 50%;
397
+ left: 50%;
398
+ transform: translate(-50%, -50%);
399
+ width: 52px;
400
+ height: 52px;
401
+ background: #1b2a4a;
402
+ border-radius: 10px;
403
+ padding: 6px;
404
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
405
+ display: flex;
406
+ align-items: center;
407
+ justify-content: center;
408
+ }
409
+ .qr-badge svg { width: 100%; height: 100%; }
410
+
411
+ /* Scanned overlay on QR */
412
+ .scanned-overlay {
413
+ position: absolute;
414
+ inset: 0;
415
+ background: rgba(0,167,111,0.08);
416
+ border-radius: calc(var(--_radius) - 2px);
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: center;
420
+ }
421
+ .scanned-check {
422
+ width: 48px;
423
+ height: 48px;
424
+ background: var(--_success);
425
+ border-radius: 50%;
426
+ display: flex;
427
+ align-items: center;
428
+ justify-content: center;
429
+ color: #fff;
430
+ opacity: 0;
431
+ animation: pop-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.1s forwards;
432
+ }
433
+ .scanned-check svg { width: 28px; height: 28px; }
434
+ @keyframes pop-in {
435
+ from { transform: scale(0.5); opacity: 0; }
436
+ to { transform: scale(1); opacity: 1; }
437
+ }
438
+
439
+ /* Status text */
440
+ .status-text {
441
+ font-size: 13px;
442
+ color: var(--_text-muted);
443
+ min-height: 20px;
444
+ }
445
+ .status-scanned {
446
+ color: var(--_success);
447
+ font-weight: 600;
448
+ }
449
+
450
+ /* Timer */
451
+ .timer {
452
+ font-size: 12px;
453
+ color: var(--_disabled);
454
+ margin-top: 8px;
455
+ font-variant-numeric: tabular-nums;
456
+ min-height: 18px;
457
+ }
458
+
459
+ /* Success state */
460
+ .state-approved {
461
+ padding: 24px 0 12px;
462
+ animation: fade-scale-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
463
+ }
464
+ @keyframes fade-scale-in {
465
+ from { transform: scale(0.85); opacity: 0; }
466
+ to { transform: scale(1); opacity: 1; }
467
+ }
468
+ .success-icon {
469
+ width: 64px;
470
+ height: 64px;
471
+ background: var(--_success);
472
+ border-radius: 50%;
473
+ display: inline-flex;
474
+ align-items: center;
475
+ justify-content: center;
476
+ color: #fff;
477
+ margin-bottom: 16px;
478
+ }
479
+ .success-icon svg { width: 36px; height: 36px; }
480
+
481
+ /* Denied / expired / error states */
482
+ .state-denied, .state-expired, .state-error {
483
+ padding: 20px 0 8px;
484
+ }
485
+ .error-icon, .expired-icon {
486
+ font-size: 48px;
487
+ margin-bottom: 12px;
488
+ line-height: 1;
489
+ display: block;
490
+ }
491
+ .state-denied .error-icon { color: var(--_error); }
492
+ .state-expired .expired-icon { color: var(--_disabled); }
493
+ .state-error .error-icon { color: var(--_warning); }
494
+
495
+ /* Retry button */
496
+ .retry-btn {
497
+ margin-top: 14px;
498
+ padding: 9px 22px;
499
+ background: var(--_surface);
500
+ border: 1px solid var(--_border);
501
+ border-radius: 8px;
502
+ font-size: 13px;
503
+ font-weight: 500;
504
+ cursor: pointer;
505
+ color: var(--_text-muted);
506
+ font-family: inherit;
507
+ transition: background 0.15s, border-color 0.15s;
508
+ }
509
+ .retry-btn:hover { background: var(--_border); color: var(--_text); }
510
+
511
+ /* Footer */
512
+ .footer {
513
+ margin-top: 18px;
514
+ font-size: 10px;
515
+ color: #c4cdd5;
516
+ }
517
+ .footer a { color: var(--_disabled); text-decoration: none; }
518
+ .footer a:hover { text-decoration: underline; }
519
+ `;
520
+ }
521
+ // ---- Event binding -----------------------------------------------------
522
+ _bindButtonEvents() {
523
+ const openBtn = this.shadow.getElementById('open-btn');
524
+ openBtn?.addEventListener('click', () => {
525
+ this._openModal();
526
+ this._startAuth();
527
+ });
528
+ // Close on overlay click
529
+ const overlay = this.shadow.getElementById('overlay');
530
+ overlay?.addEventListener('click', (e) => {
531
+ if (e.target === overlay)
532
+ this._closeModal();
533
+ });
534
+ this._bindModalButtons();
535
+ }
536
+ _bindInlineEvents() {
537
+ const startBtn = this.shadow.getElementById('start-btn');
538
+ startBtn?.addEventListener('click', () => {
539
+ this._startAuth();
540
+ });
541
+ }
542
+ _bindModalButtons() {
543
+ this.shadow.getElementById('close-btn')?.addEventListener('click', () => this._closeModal());
544
+ this.shadow.getElementById('retry-btn')?.addEventListener('click', () => this._retry());
545
+ }
546
+ // ---- Modal open / close (button display mode) --------------------------
547
+ _openModal() {
548
+ const overlay = this.shadow.getElementById('overlay');
549
+ overlay?.classList.remove('hidden');
550
+ this._updateBody(this._bodyForStatus('idle'));
551
+ }
552
+ _closeModal() {
553
+ const overlay = this.shadow.getElementById('overlay');
554
+ overlay?.classList.add('hidden');
555
+ this._clearTimers();
556
+ this._sessionId = null;
557
+ this._codeVerifier = null;
558
+ this._status = 'idle';
559
+ this._expiresAt = null;
560
+ }
561
+ // ---- Auth flow ---------------------------------------------------------
562
+ async _startAuth() {
563
+ this._status = 'loading';
564
+ this._updateBody(this._bodyForStatus('loading'));
565
+ try {
566
+ this._codeVerifier = await this.generateCodeVerifier();
567
+ const challenge = await this.computeCodeChallenge(this._codeVerifier);
568
+ const headers = {
569
+ 'Content-Type': 'application/json',
570
+ 'X-Client-Id': this.tenant,
571
+ };
572
+ const body = {
573
+ scopes: this.scopes.split(/\s+/).filter(Boolean),
574
+ codeChallenge: challenge,
575
+ codeChallengeMethod: 'S256',
576
+ };
577
+ const res = await fetch(`${this.baseUrl}/api/v1/auth-sessions`, {
578
+ method: 'POST',
579
+ headers,
580
+ body: JSON.stringify(body),
581
+ });
582
+ if (!res.ok) {
583
+ const err = await res.json().catch(() => ({ message: 'Failed to create session' }));
584
+ throw new Error(err.message ?? 'Failed to create session');
585
+ }
586
+ const session = await res.json();
587
+ this._sessionId = session.sessionId;
588
+ this._expiresAt = new Date(session.expiresAt).getTime();
589
+ this._status = 'pending';
590
+ this._updateBody(this._bodyForStatus('pending', { qrUrl: session.qrUrl }));
591
+ this._bindModalButtons();
592
+ this._startCountdown();
593
+ this._beginPolling(session.sessionId);
594
+ }
595
+ catch (err) {
596
+ const message = err instanceof Error ? err.message : 'Unexpected error';
597
+ this._status = 'error';
598
+ this._updateBody(this._bodyForStatus('error', { errorMessage: message }));
599
+ this._bindModalButtons();
600
+ this.emit('qrauth:error', { message });
601
+ }
602
+ }
603
+ _retry() {
604
+ this._clearTimers();
605
+ this._sessionId = null;
606
+ this._codeVerifier = null;
607
+ this._status = 'idle';
608
+ this._expiresAt = null;
609
+ if (this.display === 'inline') {
610
+ // Reset inline to show the start button again
611
+ const inlineBody = this.shadow.getElementById('inline-body');
612
+ if (inlineBody) {
613
+ inlineBody.innerHTML = this._inlineIdleContent();
614
+ this._bindInlineEvents();
615
+ }
616
+ }
617
+ else {
618
+ this._startAuth();
619
+ }
620
+ }
621
+ // ---- Polling -----------------------------------------------------------
622
+ _beginPolling(sessionId) {
623
+ let lastStatus = 'PENDING';
624
+ let interval = 2000;
625
+ const maxInterval = 5000;
626
+ let errorCount = 0;
627
+ const maxErrors = 20;
628
+ const poll = async () => {
629
+ if (!this._sessionId)
630
+ return; // component was reset
631
+ let url = `${this.baseUrl}/api/v1/auth-sessions/${sessionId}`;
632
+ if (this._codeVerifier) {
633
+ url += `?code_verifier=${encodeURIComponent(this._codeVerifier)}`;
634
+ }
635
+ try {
636
+ const res = await fetch(url, {
637
+ headers: { 'X-Client-Id': this.tenant },
638
+ });
639
+ const data = await res.json();
640
+ errorCount = 0;
641
+ interval = 2000;
642
+ if (data.status !== lastStatus) {
643
+ lastStatus = data.status;
644
+ this._handleStatusChange(data.status, data);
645
+ if (['APPROVED', 'DENIED', 'EXPIRED'].includes(data.status)) {
646
+ return; // stop polling
647
+ }
648
+ }
649
+ this._pollTimeout = setTimeout(poll, interval);
650
+ }
651
+ catch {
652
+ errorCount++;
653
+ if (errorCount >= maxErrors) {
654
+ this._handleStatusChange('EXPIRED', undefined);
655
+ return;
656
+ }
657
+ interval = Math.min(interval * 1.5, maxInterval);
658
+ this._pollTimeout = setTimeout(poll, interval);
659
+ }
660
+ };
661
+ this._pollTimeout = setTimeout(poll, interval);
662
+ }
663
+ // ---- Status handling ---------------------------------------------------
664
+ _handleStatusChange(status, data) {
665
+ this._status = status;
666
+ switch (status) {
667
+ case 'SCANNED': {
668
+ // Keep QR visible but show scanned overlay
669
+ const currentQrUrl = this.shadow.querySelector('.qr-frame img')?.src;
670
+ // Extract the data param from the QR image URL
671
+ const rawQrUrl = currentQrUrl
672
+ ? decodeURIComponent(currentQrUrl.replace(/.*[?&]data=/, '').split('&')[0])
673
+ : '';
674
+ this._updateBody(this._bodyForStatus('SCANNED', {
675
+ qrUrl: rawQrUrl,
676
+ timerHtml: this._formatTimer(),
677
+ }));
678
+ this._bindModalButtons();
679
+ this.emit('qrauth:scanned', data);
680
+ break;
681
+ }
682
+ case 'APPROVED': {
683
+ this._clearTimers();
684
+ this._updateBody(this._bodyForStatus('APPROVED'));
685
+ this._bindModalButtons();
686
+ this.emit('qrauth:authenticated', {
687
+ sessionId: data?.sessionId,
688
+ user: data?.user,
689
+ signature: data?.signature,
690
+ });
691
+ // Call the on-auth global callback if set
692
+ const callbackName = this.onAuth;
693
+ if (callbackName) {
694
+ const globalCallback = window[callbackName];
695
+ if (typeof globalCallback === 'function') {
696
+ globalCallback(data);
697
+ }
698
+ }
699
+ // Redirect if configured
700
+ if (this.redirectUrl) {
701
+ setTimeout(() => { window.location.href = this.redirectUrl; }, 1500);
702
+ }
703
+ else if (this.display === 'button') {
704
+ // Auto-close modal after success animation
705
+ setTimeout(() => this._closeModal(), 2200);
706
+ }
707
+ break;
708
+ }
709
+ case 'DENIED':
710
+ this._clearTimers();
711
+ this._updateBody(this._bodyForStatus('DENIED'));
712
+ this._bindModalButtons();
713
+ this.emit('qrauth:denied');
714
+ break;
715
+ case 'EXPIRED':
716
+ this._clearTimers();
717
+ this._status = 'EXPIRED';
718
+ this._updateBody(this._bodyForStatus('EXPIRED'));
719
+ this._bindModalButtons();
720
+ this.emit('qrauth:expired');
721
+ break;
722
+ }
723
+ }
724
+ // ---- Countdown timer ---------------------------------------------------
725
+ _startCountdown() {
726
+ this._timerInterval = setInterval(() => {
727
+ const timerEl = this.shadow.getElementById('timer');
728
+ if (!timerEl || !this._expiresAt)
729
+ return;
730
+ const remaining = Math.max(0, Math.floor((this._expiresAt - Date.now()) / 1000));
731
+ timerEl.textContent = remaining > 0 ? this._formatTimer(remaining) : '';
732
+ if (remaining <= 0) {
733
+ clearInterval(this._timerInterval);
734
+ this._timerInterval = null;
735
+ if (this._status === 'pending' || this._status === 'SCANNED') {
736
+ this._handleStatusChange('EXPIRED', undefined);
737
+ }
738
+ }
739
+ }, 1000);
740
+ }
741
+ _formatTimer(seconds) {
742
+ const s = seconds ?? (this._expiresAt
743
+ ? Math.max(0, Math.floor((this._expiresAt - Date.now()) / 1000))
744
+ : 0);
745
+ const mins = Math.floor(s / 60);
746
+ const secs = s % 60;
747
+ return `Expires in ${mins}:${secs < 10 ? '0' : ''}${secs}`;
748
+ }
749
+ // ---- DOM helpers -------------------------------------------------------
750
+ /** Update just the inner body content without re-rendering the whole component */
751
+ _updateBody(html) {
752
+ const bodyEl = this.display === 'inline'
753
+ ? this.shadow.getElementById('inline-body')
754
+ : this.shadow.getElementById('modal-body');
755
+ if (bodyEl)
756
+ bodyEl.innerHTML = html;
757
+ }
758
+ _clearTimers() {
759
+ if (this._timerInterval) {
760
+ clearInterval(this._timerInterval);
761
+ this._timerInterval = null;
762
+ }
763
+ if (this._pollTimeout) {
764
+ clearTimeout(this._pollTimeout);
765
+ this._pollTimeout = null;
766
+ }
767
+ }
768
+ }
769
+ customElements.define('qrauth-login', QRAuthLogin);