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