@qrauth/web-components 0.1.0 → 0.3.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/CHANGELOG.md +32 -0
- package/README.md +105 -0
- package/dist/base.d.ts +21 -0
- package/dist/ephemeral.d.ts +25 -8
- package/dist/login.d.ts +41 -8
- package/dist/qrauth-components.esm.js +170 -43
- package/dist/qrauth-components.js +170 -43
- package/dist/twofa.d.ts +41 -6
- package/package.json +12 -4
- package/dist/base.js +0 -148
- package/dist/ephemeral.js +0 -727
- package/dist/index.js +0 -4
- package/dist/login.js +0 -769
- package/dist/qrauth-components.esm.js.map +0 -7
- package/dist/qrauth-components.js.map +0 -7
- package/dist/twofa.js +0 -625
package/dist/login.js
DELETED
|
@@ -1,769 +0,0 @@
|
|
|
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">×</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
|
-
? '✓ QR code scanned — 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">✗</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">⌛</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">⚠</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);
|