@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.
@@ -1,5 +1,5 @@
1
- /* @qrauth/web-components v0.1.0 — https://qrauth.io */
2
- "use strict";var QRAuthComponents=(()=>{var v=Object.defineProperty;var E=Object.getOwnPropertyDescriptor;var k=Object.getOwnPropertyNames;var I=Object.prototype.hasOwnProperty;var S=(o,a,e)=>a in o?v(o,a,{enumerable:!0,configurable:!0,writable:!0,value:e}):o[a]=e;var C=(o,a)=>{for(var e in a)v(o,e,{get:a[e],enumerable:!0})},A=(o,a,e,t)=>{if(a&&typeof a=="object"||typeof a=="function")for(let i of k(a))!I.call(o,i)&&i!==e&&v(o,i,{get:()=>a[i],enumerable:!(t=E(a,i))||t.enumerable});return o};var T=o=>A(v({},"__esModule",{value:!0}),o);var n=(o,a,e)=>S(o,typeof a!="symbol"?a+"":a,e);var q={};C(q,{QRAuthElement:()=>p,QRAuthEphemeral:()=>g,QRAuthLogin:()=>u,QRAuthTwoFA:()=>b});var p=class extends HTMLElement{constructor(){super();n(this,"shadow");n(this,"_sseConnection",null);n(this,"_pollInterval",null);this.shadow=this.attachShadow({mode:"open"})}static get observedAttributes(){return["tenant","theme","base-url"]}get tenant(){return this.getAttribute("tenant")||""}get theme(){return this.getAttribute("theme")||"light"}get baseUrl(){let e=this.getAttribute("base-url");return e!==null?e:"https://qrauth.io"}connectedCallback(){this.render()}disconnectedCallback(){this.cleanup()}attributeChangedCallback(e,t,i){t!==i&&this.render()}connectSSE(e){this.disconnectSSE(),this._sseConnection=new EventSource(e),this._sseConnection.onerror=()=>{this.disconnectSSE()}}onSSEEvent(e,t){this._sseConnection&&this._sseConnection.addEventListener(e,i=>{let s=i;try{t(JSON.parse(s.data))}catch{}})}disconnectSSE(){this._sseConnection&&(this._sseConnection.close(),this._sseConnection=null)}startPolling(e,t,i,s){this.stopPolling();let r=async()=>{try{let d=await fetch(e,{headers:s});d.ok&&i(await d.json())}catch{}};r(),this._pollInterval=setInterval(r,t)}stopPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null)}cleanup(){this.disconnectSSE(),this.stopPolling()}emit(e,t){this.dispatchEvent(new CustomEvent(e,{bubbles:!0,composed:!0,detail:t}))}async generateCodeVerifier(){let e=new Uint8Array(32);return crypto.getRandomValues(e),this._base64url(e)}async computeCodeChallenge(e){let i=new TextEncoder().encode(e),s=await crypto.subtle.digest("SHA-256",i);return this._base64url(new Uint8Array(s))}_base64url(e){let t=Array.from(e,i=>String.fromCodePoint(i)).join("");return btoa(t).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}getBaseStyles(){return`
1
+ /* @qrauth/web-components v0.3.0 — https://qrauth.io */
2
+ "use strict";var QRAuthComponents=(()=>{var v=Object.defineProperty;var k=Object.getOwnPropertyDescriptor;var E=Object.getOwnPropertyNames;var I=Object.prototype.hasOwnProperty;var S=(o,a,t)=>a in o?v(o,a,{enumerable:!0,configurable:!0,writable:!0,value:t}):o[a]=t;var A=(o,a)=>{for(var t in a)v(o,t,{get:a[t],enumerable:!0})},T=(o,a,t,e)=>{if(a&&typeof a=="object"||typeof a=="function")for(let i of E(a))!I.call(o,i)&&i!==t&&v(o,i,{get:()=>a[i],enumerable:!(e=k(a,i))||e.enumerable});return o};var C=o=>T(v({},"__esModule",{value:!0}),o);var n=(o,a,t)=>S(o,typeof a!="symbol"?a+"":a,t);var $={};A($,{QRAuthElement:()=>p,QRAuthEphemeral:()=>b,QRAuthLogin:()=>g,QRAuthTwoFA:()=>m});var p=class extends HTMLElement{constructor(){super();n(this,"shadow");n(this,"_sseConnection",null);n(this,"_pollInterval",null);this.shadow=this.attachShadow({mode:"open"})}static get observedAttributes(){return["tenant","theme","base-url","force-mode","mobile-fallback-only"]}get tenant(){return this.getAttribute("tenant")||""}get theme(){return this.getAttribute("theme")||"light"}get baseUrl(){let t=this.getAttribute("base-url");return t!==null?t:"https://qrauth.io"}connectedCallback(){this.render()}disconnectedCallback(){this.cleanup()}attributeChangedCallback(t,e,i){e!==i&&this.render()}isMobileLike(){if(typeof window>"u")return!1;let t=this.getAttribute("force-mode");if(t==="mobile")return!0;if(t==="desktop"||this.hasAttribute("mobile-fallback-only"))return!1;if(typeof window.matchMedia=="function"){let e=window.matchMedia("(pointer: coarse)").matches,i=window.matchMedia("(hover: none)").matches;if(e&&i)return!0}return/Android|iPhone|iPad|iPod|Mobile|Tablet|BlackBerry|Opera Mini/i.test(navigator.userAgent)}connectSSE(t){this.disconnectSSE(),this._sseConnection=new EventSource(t),this._sseConnection.onerror=()=>{this.disconnectSSE()}}onSSEEvent(t,e){this._sseConnection&&this._sseConnection.addEventListener(t,i=>{let s=i;try{e(JSON.parse(s.data))}catch{}})}disconnectSSE(){this._sseConnection&&(this._sseConnection.close(),this._sseConnection=null)}startPolling(t,e,i,s){this.stopPolling();let r=async()=>{try{let d=await fetch(t,{headers:s});d.ok&&i(await d.json())}catch{}};r(),this._pollInterval=setInterval(r,e)}stopPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null)}cleanup(){this.disconnectSSE(),this.stopPolling()}emit(t,e){this.dispatchEvent(new CustomEvent(t,{bubbles:!0,composed:!0,detail:e}))}async generateCodeVerifier(){let t=new Uint8Array(32);return crypto.getRandomValues(t),this._base64url(t)}async computeCodeChallenge(t){let i=new TextEncoder().encode(t),s=await crypto.subtle.digest("SHA-256",i);return this._base64url(new Uint8Array(s))}_base64url(t){let e=Array.from(t,i=>String.fromCodePoint(i)).join("");return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}getBaseStyles(){return`
3
3
  :host {
4
4
  display: inline-block;
5
5
  font-family: var(--qrauth-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
@@ -30,7 +30,7 @@
30
30
  --_btn-hover: var(--qrauth-btn-hover, #2e4578);
31
31
  }
32
32
  * { box-sizing: border-box; margin: 0; padding: 0; }
33
- `}};var m=`<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
33
+ `}};var u=`<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
34
34
  <path d="M48 24 H152 Q166 24 166 38 V108 Q166 156 100 182 Q34 156 34 108 V38 Q34 24 48 24 Z"
35
35
  fill="none" stroke="#fff" stroke-width="5"/>
36
36
  <g fill="#fff" opacity="0.2">
@@ -52,7 +52,7 @@
52
52
  </svg>`,B=`<svg class="spinner" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
53
53
  <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2.5"
54
54
  stroke-dasharray="31.416" stroke-dashoffset="10" stroke-linecap="round"/>
55
- </svg>`,u=class extends p{constructor(){super(...arguments);n(this,"_sessionId",null);n(this,"_codeVerifier",null);n(this,"_status","idle");n(this,"_expiresAt",null);n(this,"_timerInterval",null);n(this,"_pollTimeout",null)}static get observedAttributes(){return[...super.observedAttributes,"scopes","redirect-url","on-auth","display","animated"]}get scopes(){return this.getAttribute("scopes")||"identity"}get redirectUrl(){return this.getAttribute("redirect-url")}get onAuth(){return this.getAttribute("on-auth")}get display(){return this.getAttribute("display")||"button"}get animated(){return this.hasAttribute("animated")}disconnectedCallback(){super.disconnectedCallback(),this._clearTimers()}render(){this.display==="inline"?this._renderInline():this._renderButton()}_renderButton(){this.shadow.innerHTML=`
55
+ </svg>`,g=class extends p{constructor(){super(...arguments);n(this,"_sessionId",null);n(this,"_sessionToken",null);n(this,"_codeVerifier",null);n(this,"_status","idle");n(this,"_expiresAt",null);n(this,"_timerInterval",null);n(this,"_pollTimeout",null)}static get observedAttributes(){return[...super.observedAttributes,"scopes","redirect-url","on-auth","display","animated"]}get scopes(){return this.getAttribute("scopes")||"identity"}get redirectUrl(){return this.getAttribute("redirect-url")}get onAuth(){return this.getAttribute("on-auth")}get display(){return this.getAttribute("display")||"button"}get animated(){return this.hasAttribute("animated")}get forceMode(){let t=this.getAttribute("force-mode");return t==="mobile"||t==="desktop"?t:"auto"}get mobileFallbackOnly(){return this.hasAttribute("mobile-fallback-only")}disconnectedCallback(){super.disconnectedCallback(),this._clearTimers()}render(){this.display==="inline"?this._renderInline():this._renderButton()}_renderButton(){this.shadow.innerHTML=`
56
56
  <style>
57
57
  ${this.getBaseStyles()}
58
58
  :host { display: inline-block; }
@@ -118,7 +118,7 @@
118
118
  </style>
119
119
 
120
120
  <button class="btn" id="open-btn" aria-label="Sign in with QRAuth">
121
- <span class="btn-icon">${m}</span>
121
+ <span class="btn-icon">${u}</span>
122
122
  Sign in with QRAuth
123
123
  </button>
124
124
 
@@ -177,7 +177,7 @@
177
177
  </div>
178
178
  `,this._bindInlineEvents()}_inlineIdleContent(){return`
179
179
  <button class="start-btn" id="start-btn" aria-label="Sign in with QRAuth">
180
- <span class="btn-icon">${m}</span>
180
+ <span class="btn-icon">${u}</span>
181
181
  Sign in with QRAuth
182
182
  </button>
183
183
  `}_modalContent(){return`
@@ -185,9 +185,9 @@
185
185
  <div id="modal-body">
186
186
  ${this._bodyForStatus("idle")}
187
187
  </div>
188
- `}_bodyForStatus(e,t){switch(e){case"idle":return`
188
+ `}_bodyForStatus(t,e){switch(t){case"idle":return`
189
189
  <div class="state-header">
190
- <div class="brand-icon">${m}</div>
190
+ <div class="brand-icon">${u}</div>
191
191
  <h3>Sign in with QRAuth</h3>
192
192
  <p>Secure, passwordless authentication</p>
193
193
  </div>
@@ -196,18 +196,14 @@
196
196
  <div class="spinner-wrap">${B}</div>
197
197
  <p class="status-text">Generating QR code...</p>
198
198
  </div>
199
- `;case"pending":case"SCANNED":{let i=t?.qrUrl?`https://api.qrserver.com/v1/create-qr-code/?size=440x440&data=${encodeURIComponent(t.qrUrl)}`:"",s=e==="SCANNED";return`
199
+ `;case"pending":case"SCANNED":{if(this.isMobileLike())return this._mobilePendingBody(t,e);let i=t==="SCANNED";return`
200
200
  <h3>Sign in with QRAuth</h3>
201
201
  <p class="subtitle">Scan this QR code with your phone camera</p>
202
- <div class="qr-frame ${s?"scanned":""}${this.animated?" animated":""}">
203
- <img src="${i}" alt="QR Code for authentication" width="220" height="220" loading="lazy"/>
204
- <div class="qr-badge">${m}</div>
205
- ${s?'<div class="scanned-overlay"><span class="scanned-check">'+_+"</span></div>":""}
202
+ ${this._qrFrameHtml(e?.qrUrl??"",i)}
203
+ <div class="status-text ${i?"status-scanned":""}">
204
+ ${i?"&#10003; QR code scanned &mdash; waiting for approval...":"Waiting for scan..."}
206
205
  </div>
207
- <div class="status-text ${s?"status-scanned":""}">
208
- ${s?"&#10003; QR code scanned &mdash; waiting for approval...":"Waiting for scan..."}
209
- </div>
210
- <div class="timer" id="timer">${t?.timerHtml??""}</div>
206
+ <div class="timer" id="timer">${e?.timerHtml??""}</div>
211
207
  <div class="footer">Secured by <a href="https://qrauth.io" target="_blank" rel="noopener">QRAuth</a></div>
212
208
  `}case"APPROVED":return`
213
209
  <div class="state-approved">
@@ -233,10 +229,32 @@
233
229
  <div class="state-error">
234
230
  <div class="error-icon">&#9888;</div>
235
231
  <h3>Something went wrong</h3>
236
- <p class="subtitle">${t?.errorMessage??"Failed to start authentication."}</p>
232
+ <p class="subtitle">${e?.errorMessage??"Failed to start authentication."}</p>
237
233
  <button class="retry-btn" id="retry-btn">Try again</button>
238
234
  </div>
239
- `;default:return""}}_sharedModalStyles(){return`
235
+ `;default:return""}}_qrFrameHtml(t,e){let i=t?`https://api.qrserver.com/v1/create-qr-code/?size=440x440&data=${encodeURIComponent(t)}`:"";return`
236
+ <div class="qr-frame ${e?"scanned":""}${this.animated?" animated":""}">
237
+ <img src="${i}" alt="QR Code for authentication" width="220" height="220" loading="lazy"/>
238
+ <div class="qr-badge">${u}</div>
239
+ ${e?'<div class="scanned-overlay"><span class="scanned-check">'+_+"</span></div>":""}
240
+ </div>
241
+ `}_mobilePendingBody(t,e){let i=t==="SCANNED";return`
242
+ <h3>Sign in with QRAuth</h3>
243
+ <p class="subtitle">Tap continue to verify on this device.</p>
244
+ <button class="mobile-cta" id="mobile-cta" type="button">
245
+ <span class="btn-icon">${u}</span>
246
+ Continue with QRAuth
247
+ </button>
248
+ <div class="status-text ${i?"status-scanned":""}">
249
+ ${i?"&#10003; Scanned &mdash; waiting for approval...":"Waiting for approval..."}
250
+ </div>
251
+ <div class="timer" id="timer">${e?.timerHtml??""}</div>
252
+ <details class="alt-device">
253
+ <summary>Use another device</summary>
254
+ ${this._qrFrameHtml(e?.qrUrl??"",i)}
255
+ </details>
256
+ <div class="footer">Secured by <a href="https://qrauth.io" target="_blank" rel="noopener">QRAuth</a></div>
257
+ `}_sharedModalStyles(){return`
240
258
  /* Typography */
241
259
  h3 {
242
260
  font-size: 18px;
@@ -435,6 +453,51 @@
435
453
  }
436
454
  .retry-btn:hover { background: var(--_border); color: var(--_text); }
437
455
 
456
+ /* Mobile-aware pending state: primary CTA + QR expander */
457
+ .mobile-cta {
458
+ display: flex;
459
+ align-items: center;
460
+ justify-content: center;
461
+ gap: 10px;
462
+ width: 100%;
463
+ padding: 14px 20px;
464
+ background: var(--_btn-bg);
465
+ color: #fff;
466
+ border: none;
467
+ border-radius: var(--_radius);
468
+ font-size: 15px;
469
+ font-weight: 600;
470
+ cursor: pointer;
471
+ font-family: inherit;
472
+ margin-bottom: 16px;
473
+ }
474
+ .mobile-cta:hover { background: var(--_btn-hover); }
475
+ .mobile-cta .btn-icon { width: 20px; height: 20px; }
476
+
477
+ details.alt-device {
478
+ margin-top: 14px;
479
+ text-align: left;
480
+ border-top: 1px solid var(--_border);
481
+ padding-top: 12px;
482
+ }
483
+ details.alt-device summary {
484
+ cursor: pointer;
485
+ font-size: 13px;
486
+ color: var(--_text-muted);
487
+ user-select: none;
488
+ padding: 6px 0;
489
+ text-align: center;
490
+ list-style: none;
491
+ }
492
+ details.alt-device summary::-webkit-details-marker { display: none; }
493
+ details.alt-device summary::before {
494
+ content: '\u25B8 ';
495
+ display: inline-block;
496
+ transition: transform 0.2s;
497
+ }
498
+ details.alt-device[open] summary::before { transform: rotate(90deg); }
499
+ details.alt-device > .qr-frame { margin-top: 10px; }
500
+
438
501
  /* Footer */
439
502
  .footer {
440
503
  margin-top: 18px;
@@ -443,7 +506,7 @@
443
506
  }
444
507
  .footer a { color: var(--_disabled); text-decoration: none; }
445
508
  .footer a:hover { text-decoration: underline; }
446
- `}_bindButtonEvents(){this.shadow.getElementById("open-btn")?.addEventListener("click",()=>{this._openModal(),this._startAuth()});let t=this.shadow.getElementById("overlay");t?.addEventListener("click",i=>{i.target===t&&this._closeModal()}),this._bindModalButtons()}_bindInlineEvents(){this.shadow.getElementById("start-btn")?.addEventListener("click",()=>{this._startAuth()})}_bindModalButtons(){this.shadow.getElementById("close-btn")?.addEventListener("click",()=>this._closeModal()),this.shadow.getElementById("retry-btn")?.addEventListener("click",()=>this._retry())}_openModal(){this.shadow.getElementById("overlay")?.classList.remove("hidden"),this._updateBody(this._bodyForStatus("idle"))}_closeModal(){this.shadow.getElementById("overlay")?.classList.add("hidden"),this._clearTimers(),this._sessionId=null,this._codeVerifier=null,this._status="idle",this._expiresAt=null}async _startAuth(){this._status="loading",this._updateBody(this._bodyForStatus("loading"));try{this._codeVerifier=await this.generateCodeVerifier();let e=await this.computeCodeChallenge(this._codeVerifier),t={"Content-Type":"application/json","X-Client-Id":this.tenant},i={scopes:this.scopes.split(/\s+/).filter(Boolean),codeChallenge:e,codeChallengeMethod:"S256"},s=await fetch(`${this.baseUrl}/api/v1/auth-sessions`,{method:"POST",headers:t,body:JSON.stringify(i)});if(!s.ok){let d=await s.json().catch(()=>({message:"Failed to create session"}));throw new Error(d.message??"Failed to create session")}let r=await s.json();this._sessionId=r.sessionId,this._expiresAt=new Date(r.expiresAt).getTime(),this._status="pending",this._updateBody(this._bodyForStatus("pending",{qrUrl:r.qrUrl})),this._bindModalButtons(),this._startCountdown(),this._beginPolling(r.sessionId)}catch(e){let t=e instanceof Error?e.message:"Unexpected error";this._status="error",this._updateBody(this._bodyForStatus("error",{errorMessage:t})),this._bindModalButtons(),this.emit("qrauth:error",{message:t})}}_retry(){if(this._clearTimers(),this._sessionId=null,this._codeVerifier=null,this._status="idle",this._expiresAt=null,this.display==="inline"){let e=this.shadow.getElementById("inline-body");e&&(e.innerHTML=this._inlineIdleContent(),this._bindInlineEvents())}else this._startAuth()}_beginPolling(e){let t="PENDING",i=2e3,s=5e3,r=0,d=20,h=async()=>{if(!this._sessionId)return;let l=`${this.baseUrl}/api/v1/auth-sessions/${e}`;this._codeVerifier&&(l+=`?code_verifier=${encodeURIComponent(this._codeVerifier)}`);try{let c=await(await fetch(l,{headers:{"X-Client-Id":this.tenant}})).json();if(r=0,i=2e3,c.status!==t&&(t=c.status,this._handleStatusChange(c.status,c),["APPROVED","DENIED","EXPIRED"].includes(c.status)))return;this._pollTimeout=setTimeout(h,i)}catch{if(r++,r>=d){this._handleStatusChange("EXPIRED",void 0);return}i=Math.min(i*1.5,s),this._pollTimeout=setTimeout(h,i)}};this._pollTimeout=setTimeout(h,i)}_handleStatusChange(e,t){switch(this._status=e,e){case"SCANNED":{let i=this.shadow.querySelector(".qr-frame img")?.src,s=i?decodeURIComponent(i.replace(/.*[?&]data=/,"").split("&")[0]):"";this._updateBody(this._bodyForStatus("SCANNED",{qrUrl:s,timerHtml:this._formatTimer()})),this._bindModalButtons(),this.emit("qrauth:scanned",t);break}case"APPROVED":{this._clearTimers(),this._updateBody(this._bodyForStatus("APPROVED")),this._bindModalButtons(),this.emit("qrauth:authenticated",{sessionId:t?.sessionId,user:t?.user,signature:t?.signature});let i=this.onAuth;if(i){let s=window[i];typeof s=="function"&&s(t)}this.redirectUrl?setTimeout(()=>{window.location.href=this.redirectUrl},1500):this.display==="button"&&setTimeout(()=>this._closeModal(),2200);break}case"DENIED":this._clearTimers(),this._updateBody(this._bodyForStatus("DENIED")),this._bindModalButtons(),this.emit("qrauth:denied");break;case"EXPIRED":this._clearTimers(),this._status="EXPIRED",this._updateBody(this._bodyForStatus("EXPIRED")),this._bindModalButtons(),this.emit("qrauth:expired");break}}_startCountdown(){this._timerInterval=setInterval(()=>{let e=this.shadow.getElementById("timer");if(!e||!this._expiresAt)return;let t=Math.max(0,Math.floor((this._expiresAt-Date.now())/1e3));e.textContent=t>0?this._formatTimer(t):"",t<=0&&(clearInterval(this._timerInterval),this._timerInterval=null,(this._status==="pending"||this._status==="SCANNED")&&this._handleStatusChange("EXPIRED",void 0))},1e3)}_formatTimer(e){let t=e??(this._expiresAt?Math.max(0,Math.floor((this._expiresAt-Date.now())/1e3)):0),i=Math.floor(t/60),s=t%60;return`Expires in ${i}:${s<10?"0":""}${s}`}_updateBody(e){let t=this.display==="inline"?this.shadow.getElementById("inline-body"):this.shadow.getElementById("modal-body");t&&(t.innerHTML=e)}_clearTimers(){this._timerInterval&&(clearInterval(this._timerInterval),this._timerInterval=null),this._pollTimeout&&(clearTimeout(this._pollTimeout),this._pollTimeout=null)}};customElements.define("qrauth-login",u);var x=`<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
509
+ `}_bindButtonEvents(){this.shadow.getElementById("open-btn")?.addEventListener("click",()=>{this._openModal(),this._startAuth()});let e=this.shadow.getElementById("overlay");e?.addEventListener("click",i=>{i.target===e&&this._closeModal()}),this._bindModalButtons()}_bindInlineEvents(){this.shadow.getElementById("start-btn")?.addEventListener("click",()=>{this._startAuth()})}_bindModalButtons(){this.shadow.getElementById("close-btn")?.addEventListener("click",()=>this._closeModal()),this.shadow.getElementById("retry-btn")?.addEventListener("click",()=>this._retry()),this.shadow.getElementById("mobile-cta")?.addEventListener("click",()=>this._openApprovalPage())}_openApprovalPage(){if(!this._sessionToken)return;let t=`${this.baseUrl}/a/${this._sessionToken}`;window.open(t,"_blank","noopener")||(window.location.href=t)}_openModal(){this.shadow.getElementById("overlay")?.classList.remove("hidden"),this._updateBody(this._bodyForStatus("idle"))}_closeModal(){this.shadow.getElementById("overlay")?.classList.add("hidden"),this._clearTimers(),this._sessionId=null,this._sessionToken=null,this._codeVerifier=null,this._status="idle",this._expiresAt=null}async _startAuth(){this._status="loading",this._updateBody(this._bodyForStatus("loading"));try{this._codeVerifier=await this.generateCodeVerifier();let t=await this.computeCodeChallenge(this._codeVerifier),e={"Content-Type":"application/json","X-Client-Id":this.tenant},i={scopes:this.scopes.split(/\s+/).filter(Boolean),codeChallenge:t,codeChallengeMethod:"S256"},s=await fetch(`${this.baseUrl}/api/v1/auth-sessions`,{method:"POST",headers:e,body:JSON.stringify(i)});if(!s.ok){let d=await s.json().catch(()=>({message:"Failed to create session"}));throw new Error(d.message??"Failed to create session")}let r=await s.json();this._sessionId=r.sessionId,this._sessionToken=r.token||(r.qrUrl?r.qrUrl.split("/").pop()??null:null),this._expiresAt=new Date(r.expiresAt).getTime(),this._status="pending",this._updateBody(this._bodyForStatus("pending",{qrUrl:r.qrUrl})),this._bindModalButtons(),this._startCountdown(),this._beginPolling(r.sessionId)}catch(t){let e=t instanceof Error?t.message:"Unexpected error";this._status="error",this._updateBody(this._bodyForStatus("error",{errorMessage:e})),this._bindModalButtons(),this.emit("qrauth:error",{message:e})}}_retry(){if(this._clearTimers(),this._sessionId=null,this._sessionToken=null,this._codeVerifier=null,this._status="idle",this._expiresAt=null,this.display==="inline"){let t=this.shadow.getElementById("inline-body");t&&(t.innerHTML=this._inlineIdleContent(),this._bindInlineEvents())}else this._startAuth()}_beginPolling(t){let e="PENDING",i=2e3,s=5e3,r=0,d=20,h=async()=>{if(!this._sessionId)return;let l=`${this.baseUrl}/api/v1/auth-sessions/${t}`;this._codeVerifier&&(l+=`?code_verifier=${encodeURIComponent(this._codeVerifier)}`);try{let c=await(await fetch(l,{headers:{"X-Client-Id":this.tenant}})).json();if(r=0,i=2e3,c.status!==e&&(e=c.status,this._handleStatusChange(c.status,c),["APPROVED","DENIED","EXPIRED"].includes(c.status)))return;this._pollTimeout=setTimeout(h,i)}catch{if(r++,r>=d){this._handleStatusChange("EXPIRED",void 0);return}i=Math.min(i*1.5,s),this._pollTimeout=setTimeout(h,i)}};this._pollTimeout=setTimeout(h,i)}_handleStatusChange(t,e){switch(this._status=t,t){case"SCANNED":{let i=this.shadow.querySelector(".qr-frame img")?.src,s=i?decodeURIComponent(i.replace(/.*[?&]data=/,"").split("&")[0]):"";this._updateBody(this._bodyForStatus("SCANNED",{qrUrl:s,timerHtml:this._formatTimer()})),this._bindModalButtons(),this.emit("qrauth:scanned",e);break}case"APPROVED":{this._clearTimers(),this._updateBody(this._bodyForStatus("APPROVED")),this._bindModalButtons(),this.emit("qrauth:authenticated",{sessionId:e?.sessionId,user:e?.user,signature:e?.signature});let i=this.onAuth;if(i){let s=window[i];typeof s=="function"&&s(e)}this.redirectUrl?setTimeout(()=>{window.location.href=this.redirectUrl},1500):this.display==="button"&&setTimeout(()=>this._closeModal(),2200);break}case"DENIED":this._clearTimers(),this._updateBody(this._bodyForStatus("DENIED")),this._bindModalButtons(),this.emit("qrauth:denied");break;case"EXPIRED":this._clearTimers(),this._status="EXPIRED",this._updateBody(this._bodyForStatus("EXPIRED")),this._bindModalButtons(),this.emit("qrauth:expired");break}}_startCountdown(){this._timerInterval=setInterval(()=>{let t=this.shadow.getElementById("timer");if(!t||!this._expiresAt)return;let e=Math.max(0,Math.floor((this._expiresAt-Date.now())/1e3));t.textContent=e>0?this._formatTimer(e):"",e<=0&&(clearInterval(this._timerInterval),this._timerInterval=null,(this._status==="pending"||this._status==="SCANNED")&&this._handleStatusChange("EXPIRED",void 0))},1e3)}_formatTimer(t){let e=t??(this._expiresAt?Math.max(0,Math.floor((this._expiresAt-Date.now())/1e3)):0),i=Math.floor(e/60),s=e%60;return`Expires in ${i}:${s<10?"0":""}${s}`}_updateBody(t){let e=this.display==="inline"?this.shadow.getElementById("inline-body"):this.shadow.getElementById("modal-body");e&&(e.innerHTML=t)}_clearTimers(){this._timerInterval&&(clearInterval(this._timerInterval),this._timerInterval=null),this._pollTimeout&&(clearTimeout(this._pollTimeout),this._pollTimeout=null)}};customElements.define("qrauth-login",g);var x=`<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
447
510
  <path d="M48 24 H152 Q166 24 166 38 V108 Q166 156 100 182 Q34 156 34 108 V38 Q34 24 48 24 Z"
448
511
  fill="none" stroke="#fff" stroke-width="5"/>
449
512
  <g fill="#fff" opacity="0.2">
@@ -465,7 +528,7 @@
465
528
  </svg>`,M=`<svg class="spinner" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
466
529
  <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2.5"
467
530
  stroke-dasharray="31.416" stroke-dashoffset="10" stroke-linecap="round"/>
468
- </svg>`,g=class extends p{constructor(){super(...arguments);n(this,"_sessionId",null);n(this,"_claimUrl",null);n(this,"_status","idle");n(this,"_expiresAt",null);n(this,"_timerInterval",null);n(this,"_pollTimeout",null)}static get observedAttributes(){return[...super.observedAttributes,"scopes","ttl","max-uses","device-binding","display"]}get scopes(){return this.getAttribute("scopes")||"access"}get ttl(){return this.getAttribute("ttl")||"30m"}get maxUses(){return parseInt(this.getAttribute("max-uses")||"1",10)}get deviceBinding(){return this.hasAttribute("device-binding")}get display(){return this.getAttribute("display")||"inline"}disconnectedCallback(){super.disconnectedCallback(),this._clearTimers()}render(){this.display==="button"?this._renderButton():this._renderInline()}_renderButton(){this.shadow.innerHTML=`
531
+ </svg>`,b=class extends p{constructor(){super(...arguments);n(this,"_sessionId",null);n(this,"_claimUrl",null);n(this,"_status","idle");n(this,"_expiresAt",null);n(this,"_timerInterval",null);n(this,"_pollTimeout",null)}static get observedAttributes(){return[...super.observedAttributes,"scopes","ttl","max-uses","device-binding","display"]}get scopes(){return this.getAttribute("scopes")||"access"}get ttl(){return this.getAttribute("ttl")||"30m"}get maxUses(){return parseInt(this.getAttribute("max-uses")||"1",10)}get deviceBinding(){return this.hasAttribute("device-binding")}get display(){return this.getAttribute("display")||"inline"}get forceMode(){let t=this.getAttribute("force-mode");return t==="mobile"||t==="desktop"?t:"auto"}get mobileFallbackOnly(){return this.hasAttribute("mobile-fallback-only")}disconnectedCallback(){super.disconnectedCallback(),this._clearTimers()}render(){this.display==="button"?this._renderButton():this._renderInline()}_renderButton(){this.shadow.innerHTML=`
469
532
  <style>
470
533
  ${this.getBaseStyles()}
471
534
  :host { display: inline-block; }
@@ -598,7 +661,7 @@
598
661
  <div id="modal-body">
599
662
  ${this._bodyForStatus("idle")}
600
663
  </div>
601
- `}_bodyForStatus(e,t){switch(e){case"idle":return`
664
+ `}_bodyForStatus(t,e){switch(t){case"idle":return`
602
665
  <div class="state-header">
603
666
  <div class="brand-icon">${x}</div>
604
667
  <h3>Ephemeral Access</h3>
@@ -609,7 +672,7 @@
609
672
  <div class="spinner-wrap">${M}</div>
610
673
  <p class="status-text">Generating access QR...</p>
611
674
  </div>
612
- `;case"active":{let i=t?.claimUrl?`https://api.qrserver.com/v1/create-qr-code/?size=440x440&data=${encodeURIComponent(t.claimUrl)}`:"",s=t?.useCount??0,r=t?.maxUses??1,h=r>1?`<div class="use-badge">${s}/${r} claimed</div>`:"";return`
675
+ `;case"active":{let i=e?.claimUrl?`https://api.qrserver.com/v1/create-qr-code/?size=440x440&data=${encodeURIComponent(e.claimUrl)}`:"",s=e?.useCount??0,r=e?.maxUses??1,h=r>1?`<div class="use-badge">${s}/${r} claimed</div>`:"";return`
613
676
  <h3>Scan to Access</h3>
614
677
  <p class="subtitle">Present this QR code to gain entry</p>
615
678
  <div class="qr-frame">
@@ -617,7 +680,7 @@
617
680
  <div class="qr-badge">${x}</div>
618
681
  </div>
619
682
  ${h}
620
- <div class="timer" id="timer">${t?.timerHtml??""}</div>
683
+ <div class="timer" id="timer">${e?.timerHtml??""}</div>
621
684
  <div class="footer">Secured by <a href="https://qrauth.io" target="_blank" rel="noopener">QRAuth</a></div>
622
685
  `}case"CLAIMED":return`
623
686
  <div class="state-claimed">
@@ -642,7 +705,7 @@
642
705
  <div class="state-error">
643
706
  <div class="error-icon">&#9888;</div>
644
707
  <h3>Something went wrong</h3>
645
- <p class="subtitle">${t?.errorMessage??"Failed to create ephemeral session."}</p>
708
+ <p class="subtitle">${e?.errorMessage??"Failed to create ephemeral session."}</p>
646
709
  <button class="retry-btn" id="retry-btn">Try again</button>
647
710
  </div>
648
711
  `;default:return""}}_sharedModalStyles(){return`
@@ -827,7 +890,7 @@
827
890
  }
828
891
  .footer a { color: var(--_disabled); text-decoration: none; }
829
892
  .footer a:hover { text-decoration: underline; }
830
- `}_bindButtonEvents(){this.shadow.getElementById("open-btn")?.addEventListener("click",()=>{this._openModal(),this._startSession()});let t=this.shadow.getElementById("overlay");t?.addEventListener("click",i=>{i.target===t&&this._closeModal()}),this._bindModalButtons()}_bindInlineEvents(){this.shadow.getElementById("start-btn")?.addEventListener("click",()=>{this._startSession()})}_bindModalButtons(){this.shadow.getElementById("close-btn")?.addEventListener("click",()=>this._closeModal()),this.shadow.getElementById("retry-btn")?.addEventListener("click",()=>this._retry())}_openModal(){this.shadow.getElementById("overlay")?.classList.remove("hidden"),this._updateBody(this._bodyForStatus("idle"))}_closeModal(){this.shadow.getElementById("overlay")?.classList.add("hidden"),this._clearTimers(),this._sessionId=null,this._claimUrl=null,this._status="idle",this._expiresAt=null}async _startSession(){this._status="loading",this._updateBody(this._bodyForStatus("loading"));try{let e=await fetch(`${this.baseUrl}/api/v1/ephemeral`,{method:"POST",headers:{"Content-Type":"application/json","X-Client-Id":this.tenant},body:JSON.stringify({scopes:this.scopes.split(/\s+/).filter(Boolean),ttl:this.ttl,maxUses:this.maxUses,deviceBinding:this.deviceBinding})});if(!e.ok){let i=await e.json().catch(()=>({message:"Failed to create session"}));throw new Error(i.message??"Failed to create session")}let t=await e.json();this._sessionId=t.sessionId,this._claimUrl=t.claimUrl,this._expiresAt=new Date(t.expiresAt).getTime(),this._status="active",this._updateBody(this._bodyForStatus("active",{claimUrl:t.claimUrl,timerHtml:this._formatTimer(),useCount:0,maxUses:t.maxUses})),this._bindModalButtons(),this._startCountdown(),this._beginPolling(t.sessionId)}catch(e){let t=e instanceof Error?e.message:"Unexpected error";this._status="error",this._updateBody(this._bodyForStatus("error",{errorMessage:t})),this._bindModalButtons(),this.emit("qrauth:error",{message:t})}}_retry(){if(this._clearTimers(),this._sessionId=null,this._claimUrl=null,this._status="idle",this._expiresAt=null,this.display==="inline"){let e=this.shadow.getElementById("inline-body");e&&(e.innerHTML=this._inlineIdleContent(),this._bindInlineEvents())}else this._startSession()}_beginPolling(e){let t=2e3,i=5e3,s=0,r=20,d=async()=>{if(this._sessionId)try{let l=await(await fetch(`${this.baseUrl}/api/v1/ephemeral/${e}`,{headers:{"X-Client-Id":this.tenant}})).json();if(s=0,t=2e3,this._handleStatusChange(l.status,l),["CLAIMED","EXPIRED","REVOKED"].includes(l.status)){if(l.status==="CLAIMED"&&l.useCount<l.maxUses){this._updateBody(this._bodyForStatus("active",{claimUrl:this._claimUrl??void 0,timerHtml:this._formatTimer(),useCount:l.useCount,maxUses:l.maxUses})),this._bindModalButtons(),this._pollTimeout=setTimeout(d,t);return}return}this._pollTimeout=setTimeout(d,t)}catch{if(s++,s>=r){this._handleStatusChange("EXPIRED",void 0);return}t=Math.min(t*1.5,i),this._pollTimeout=setTimeout(d,t)}};this._pollTimeout=setTimeout(d,t)}_handleStatusChange(e,t){switch(e){case"CLAIMED":{this._clearTimers(),this._status="CLAIMED",this._updateBody(this._bodyForStatus("CLAIMED")),this._bindModalButtons(),this.emit("qrauth:claimed",{sessionId:t?.sessionId,status:t?.status,scopes:t?.scopes,metadata:t?.metadata,useCount:t?.useCount,maxUses:t?.maxUses}),this.display==="button"&&setTimeout(()=>this._closeModal(),2200);break}case"EXPIRED":this._clearTimers(),this._status="EXPIRED",this._updateBody(this._bodyForStatus("EXPIRED")),this._bindModalButtons(),this.emit("qrauth:expired");break;case"REVOKED":this._clearTimers(),this._status="REVOKED",this._updateBody(this._bodyForStatus("REVOKED")),this._bindModalButtons(),this.emit("qrauth:revoked");break}}_startCountdown(){this._timerInterval=setInterval(()=>{let e=this.shadow.getElementById("timer");if(!e||!this._expiresAt)return;let t=Math.max(0,Math.floor((this._expiresAt-Date.now())/1e3));e.textContent=t>0?this._formatTimer(t):"",t<=0&&(clearInterval(this._timerInterval),this._timerInterval=null,this._status==="active"&&this._handleStatusChange("EXPIRED",void 0))},1e3)}_formatTimer(e){let t=e??(this._expiresAt?Math.max(0,Math.floor((this._expiresAt-Date.now())/1e3)):0),i=Math.floor(t/60),s=t%60;return`Expires in ${i}:${s<10?"0":""}${s}`}_updateBody(e){let t=this.display==="inline"?this.shadow.getElementById("inline-body"):this.shadow.getElementById("modal-body");t&&(t.innerHTML=e)}_clearTimers(){this._timerInterval&&(clearInterval(this._timerInterval),this._timerInterval=null),this._pollTimeout&&(clearTimeout(this._pollTimeout),this._pollTimeout=null)}};customElements.define("qrauth-ephemeral",g);var f=`<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
893
+ `}_bindButtonEvents(){this.shadow.getElementById("open-btn")?.addEventListener("click",()=>{this._openModal(),this._startSession()});let e=this.shadow.getElementById("overlay");e?.addEventListener("click",i=>{i.target===e&&this._closeModal()}),this._bindModalButtons()}_bindInlineEvents(){this.shadow.getElementById("start-btn")?.addEventListener("click",()=>{this._startSession()})}_bindModalButtons(){this.shadow.getElementById("close-btn")?.addEventListener("click",()=>this._closeModal()),this.shadow.getElementById("retry-btn")?.addEventListener("click",()=>this._retry())}_openModal(){this.shadow.getElementById("overlay")?.classList.remove("hidden"),this._updateBody(this._bodyForStatus("idle"))}_closeModal(){this.shadow.getElementById("overlay")?.classList.add("hidden"),this._clearTimers(),this._sessionId=null,this._claimUrl=null,this._status="idle",this._expiresAt=null}async _startSession(){this._status="loading",this._updateBody(this._bodyForStatus("loading"));try{let t=await fetch(`${this.baseUrl}/api/v1/ephemeral`,{method:"POST",headers:{"Content-Type":"application/json","X-Client-Id":this.tenant},body:JSON.stringify({scopes:this.scopes.split(/\s+/).filter(Boolean),ttl:this.ttl,maxUses:this.maxUses,deviceBinding:this.deviceBinding})});if(!t.ok){let i=await t.json().catch(()=>({message:"Failed to create session"}));throw new Error(i.message??"Failed to create session")}let e=await t.json();this._sessionId=e.sessionId,this._claimUrl=e.claimUrl,this._expiresAt=new Date(e.expiresAt).getTime(),this._status="active",this._updateBody(this._bodyForStatus("active",{claimUrl:e.claimUrl,timerHtml:this._formatTimer(),useCount:0,maxUses:e.maxUses})),this._bindModalButtons(),this._startCountdown(),this._beginPolling(e.sessionId)}catch(t){let e=t instanceof Error?t.message:"Unexpected error";this._status="error",this._updateBody(this._bodyForStatus("error",{errorMessage:e})),this._bindModalButtons(),this.emit("qrauth:error",{message:e})}}_retry(){if(this._clearTimers(),this._sessionId=null,this._claimUrl=null,this._status="idle",this._expiresAt=null,this.display==="inline"){let t=this.shadow.getElementById("inline-body");t&&(t.innerHTML=this._inlineIdleContent(),this._bindInlineEvents())}else this._startSession()}_beginPolling(t){let e=2e3,i=5e3,s=0,r=20,d=async()=>{if(this._sessionId)try{let l=await(await fetch(`${this.baseUrl}/api/v1/ephemeral/${t}`,{headers:{"X-Client-Id":this.tenant}})).json();if(s=0,e=2e3,this._handleStatusChange(l.status,l),["CLAIMED","EXPIRED","REVOKED"].includes(l.status)){if(l.status==="CLAIMED"&&l.useCount<l.maxUses){this._updateBody(this._bodyForStatus("active",{claimUrl:this._claimUrl??void 0,timerHtml:this._formatTimer(),useCount:l.useCount,maxUses:l.maxUses})),this._bindModalButtons(),this._pollTimeout=setTimeout(d,e);return}return}this._pollTimeout=setTimeout(d,e)}catch{if(s++,s>=r){this._handleStatusChange("EXPIRED",void 0);return}e=Math.min(e*1.5,i),this._pollTimeout=setTimeout(d,e)}};this._pollTimeout=setTimeout(d,e)}_handleStatusChange(t,e){switch(t){case"CLAIMED":{this._clearTimers(),this._status="CLAIMED",this._updateBody(this._bodyForStatus("CLAIMED")),this._bindModalButtons(),this.emit("qrauth:claimed",{sessionId:e?.sessionId,status:e?.status,scopes:e?.scopes,metadata:e?.metadata,useCount:e?.useCount,maxUses:e?.maxUses}),this.display==="button"&&setTimeout(()=>this._closeModal(),2200);break}case"EXPIRED":this._clearTimers(),this._status="EXPIRED",this._updateBody(this._bodyForStatus("EXPIRED")),this._bindModalButtons(),this.emit("qrauth:expired");break;case"REVOKED":this._clearTimers(),this._status="REVOKED",this._updateBody(this._bodyForStatus("REVOKED")),this._bindModalButtons(),this.emit("qrauth:revoked");break}}_startCountdown(){this._timerInterval=setInterval(()=>{let t=this.shadow.getElementById("timer");if(!t||!this._expiresAt)return;let e=Math.max(0,Math.floor((this._expiresAt-Date.now())/1e3));t.textContent=e>0?this._formatTimer(e):"",e<=0&&(clearInterval(this._timerInterval),this._timerInterval=null,this._status==="active"&&this._handleStatusChange("EXPIRED",void 0))},1e3)}_formatTimer(t){let e=t??(this._expiresAt?Math.max(0,Math.floor((this._expiresAt-Date.now())/1e3)):0),i=Math.floor(e/60),s=e%60;return`Expires in ${i}:${s<10?"0":""}${s}`}_updateBody(t){let e=this.display==="inline"?this.shadow.getElementById("inline-body"):this.shadow.getElementById("modal-body");e&&(e.innerHTML=t)}_clearTimers(){this._timerInterval&&(clearInterval(this._timerInterval),this._timerInterval=null),this._pollTimeout&&(clearTimeout(this._pollTimeout),this._pollTimeout=null)}};customElements.define("qrauth-ephemeral",b);var f=`<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
831
894
  <path d="M48 24 H152 Q166 24 166 38 V108 Q166 156 100 182 Q34 156 34 108 V38 Q34 24 48 24 Z"
832
895
  fill="none" stroke="#fff" stroke-width="5"/>
833
896
  <g fill="#fff" opacity="0.2">
@@ -846,10 +909,10 @@
846
909
  stroke-linecap="round" stroke-linejoin="round"/>
847
910
  </svg>`,y=`<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
848
911
  <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" fill="currentColor"/>
849
- </svg>`,D=`<svg class="spinner" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
912
+ </svg>`,q=`<svg class="spinner" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
850
913
  <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2.5"
851
914
  stroke-dasharray="31.416" stroke-dashoffset="10" stroke-linecap="round"/>
852
- </svg>`,b=class extends p{constructor(){super(...arguments);n(this,"_sessionId",null);n(this,"_codeVerifier",null);n(this,"_status","idle");n(this,"_expiresAt",null);n(this,"_timerInterval",null);n(this,"_pollTimeout",null)}static get observedAttributes(){return[...super.observedAttributes,"session-token","scopes","auto-start"]}get sessionToken(){return this.getAttribute("session-token")}get scopes(){return this.getAttribute("scopes")||"identity"}get autoStart(){return this.hasAttribute("auto-start")}connectedCallback(){super.connectedCallback(),this.autoStart&&this._startAuth()}disconnectedCallback(){super.disconnectedCallback(),this._clearTimers()}render(){this.shadow.innerHTML=`
915
+ </svg>`,m=class extends p{constructor(){super(...arguments);n(this,"_sessionId",null);n(this,"_sessionToken",null);n(this,"_codeVerifier",null);n(this,"_status","idle");n(this,"_expiresAt",null);n(this,"_timerInterval",null);n(this,"_pollTimeout",null)}static get observedAttributes(){return[...super.observedAttributes,"session-token","scopes","auto-start"]}get sessionToken(){return this.getAttribute("session-token")}get scopes(){return this.getAttribute("scopes")||"identity"}get autoStart(){return this.hasAttribute("auto-start")}get forceMode(){let t=this.getAttribute("force-mode");return t==="mobile"||t==="desktop"?t:"auto"}get mobileFallbackOnly(){return this.hasAttribute("mobile-fallback-only")}connectedCallback(){super.connectedCallback(),this.autoStart&&this._startAuth()}disconnectedCallback(){super.disconnectedCallback(),this._clearTimers()}render(){this.shadow.innerHTML=`
853
916
  <style>
854
917
  ${this.getBaseStyles()}
855
918
  :host { display: block; }
@@ -924,12 +987,12 @@
924
987
  ${this._bodyForStatus("idle")}
925
988
  </div>
926
989
  </div>
927
- `,this._bindBodyEvents()}_bodyForStatus(e,t){let i=`
990
+ `,this._bindBodyEvents()}_bodyForStatus(t,e){let i=`
928
991
  <div class="step-label">
929
992
  <span class="step-dot"></span>
930
993
  Step 2 of 2 &mdash; Verify identity
931
994
  </div>
932
- `;switch(e){case"idle":return`
995
+ `;switch(t){case"idle":return`
933
996
  ${i}
934
997
  <div class="state-header">
935
998
  <div class="brand-icon">${f}</div>
@@ -943,22 +1006,18 @@
943
1006
  `;case"loading":return`
944
1007
  ${i}
945
1008
  <div class="state-loading">
946
- <div class="spinner-wrap">${D}</div>
1009
+ <div class="spinner-wrap">${q}</div>
947
1010
  <p class="status-text">Generating QR code...</p>
948
1011
  </div>
949
- `;case"pending":case"SCANNED":{let s=t?.qrUrl?`https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=${encodeURIComponent(t.qrUrl)}`:"",r=e==="SCANNED";return`
1012
+ `;case"pending":case"SCANNED":{if(this.isMobileLike())return this._mobilePendingBody(t,e,i);let s=t==="SCANNED";return`
950
1013
  ${i}
951
1014
  <h3>Scan to verify</h3>
952
1015
  <p class="subtitle">Use your phone camera to complete verification</p>
953
- <div class="qr-frame ${r?"scanned":""}">
954
- <img src="${s}" alt="QR Code for 2FA verification" width="160" height="160" loading="lazy"/>
955
- <div class="qr-badge">${f}</div>
956
- ${r?'<div class="scanned-overlay"><span class="scanned-check">'+y+"</span></div>":""}
957
- </div>
958
- <div class="status-text ${r?"status-scanned":""}">
959
- ${r?"&#10003; Scanned &mdash; waiting for approval...":"Waiting for scan..."}
1016
+ ${this._qrFrameHtml(e?.qrUrl??"",s)}
1017
+ <div class="status-text ${s?"status-scanned":""}">
1018
+ ${s?"&#10003; Scanned &mdash; waiting for approval...":"Waiting for scan..."}
960
1019
  </div>
961
- <div class="timer" id="timer">${t?.timerHtml??""}</div>
1020
+ <div class="timer" id="timer">${e?.timerHtml??""}</div>
962
1021
  <div class="footer">Secured by <a href="https://qrauth.io" target="_blank" rel="noopener">QRAuth</a></div>
963
1022
  `}case"APPROVED":return`
964
1023
  <div class="state-approved">
@@ -984,10 +1043,33 @@
984
1043
  <div class="state-error">
985
1044
  <div class="error-icon">&#9888;</div>
986
1045
  <h3>Something went wrong</h3>
987
- <p class="subtitle">${t?.errorMessage??"Failed to start verification."}</p>
1046
+ <p class="subtitle">${e?.errorMessage??"Failed to start verification."}</p>
988
1047
  <button class="retry-btn" id="retry-btn">Try again</button>
989
1048
  </div>
990
- `;default:return""}}_sharedStyles(){return`
1049
+ `;default:return""}}_qrFrameHtml(t,e){let i=t?`https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=${encodeURIComponent(t)}`:"";return`
1050
+ <div class="qr-frame ${e?"scanned":""}">
1051
+ <img src="${i}" alt="QR Code for 2FA verification" width="160" height="160" loading="lazy"/>
1052
+ <div class="qr-badge">${f}</div>
1053
+ ${e?'<div class="scanned-overlay"><span class="scanned-check">'+y+"</span></div>":""}
1054
+ </div>
1055
+ `}_mobilePendingBody(t,e,i=""){let s=t==="SCANNED";return`
1056
+ ${i}
1057
+ <h3>Verify your identity</h3>
1058
+ <p class="subtitle">Tap continue to verify on this device.</p>
1059
+ <button class="mobile-cta" id="mobile-cta" type="button">
1060
+ <span class="btn-icon">${f}</span>
1061
+ Continue with QRAuth
1062
+ </button>
1063
+ <div class="status-text ${s?"status-scanned":""}">
1064
+ ${s?"&#10003; Scanned &mdash; waiting for approval...":"Waiting for approval..."}
1065
+ </div>
1066
+ <div class="timer" id="timer">${e?.timerHtml??""}</div>
1067
+ <details class="alt-device">
1068
+ <summary>Use another device</summary>
1069
+ ${this._qrFrameHtml(e?.qrUrl??"",s)}
1070
+ </details>
1071
+ <div class="footer">Secured by <a href="https://qrauth.io" target="_blank" rel="noopener">QRAuth</a></div>
1072
+ `}_sharedStyles(){return`
991
1073
  /* Typography */
992
1074
  h3 {
993
1075
  font-size: 16px;
@@ -1165,6 +1247,51 @@
1165
1247
  }
1166
1248
  .retry-btn:hover { background: var(--_border); color: var(--_text); }
1167
1249
 
1250
+ /* Mobile-aware pending state: primary CTA + QR expander */
1251
+ .mobile-cta {
1252
+ display: flex;
1253
+ align-items: center;
1254
+ justify-content: center;
1255
+ gap: 10px;
1256
+ width: 100%;
1257
+ padding: 12px 18px;
1258
+ background: var(--_btn-bg);
1259
+ color: #fff;
1260
+ border: none;
1261
+ border-radius: var(--_radius);
1262
+ font-size: 14px;
1263
+ font-weight: 600;
1264
+ cursor: pointer;
1265
+ font-family: inherit;
1266
+ margin-bottom: 12px;
1267
+ }
1268
+ .mobile-cta:hover { background: var(--_btn-hover); }
1269
+ .mobile-cta .btn-icon { width: 18px; height: 18px; }
1270
+
1271
+ details.alt-device {
1272
+ margin-top: 12px;
1273
+ text-align: left;
1274
+ border-top: 1px solid var(--_border);
1275
+ padding-top: 10px;
1276
+ }
1277
+ details.alt-device summary {
1278
+ cursor: pointer;
1279
+ font-size: 12px;
1280
+ color: var(--_text-muted);
1281
+ user-select: none;
1282
+ padding: 4px 0;
1283
+ text-align: center;
1284
+ list-style: none;
1285
+ }
1286
+ details.alt-device summary::-webkit-details-marker { display: none; }
1287
+ details.alt-device summary::before {
1288
+ content: '\u25B8 ';
1289
+ display: inline-block;
1290
+ transition: transform 0.2s;
1291
+ }
1292
+ details.alt-device[open] summary::before { transform: rotate(90deg); }
1293
+ details.alt-device > .qr-frame { margin-top: 8px; }
1294
+
1168
1295
  /* Footer */
1169
1296
  .footer {
1170
1297
  margin-top: 14px;
@@ -1173,5 +1300,5 @@
1173
1300
  }
1174
1301
  .footer a { color: var(--_disabled); text-decoration: none; }
1175
1302
  .footer a:hover { text-decoration: underline; }
1176
- `}_bindBodyEvents(){this.shadow.getElementById("start-btn")?.addEventListener("click",()=>this._startAuth()),this.shadow.getElementById("retry-btn")?.addEventListener("click",()=>this._retry())}async _startAuth(){this._status="loading",this._updateBody(this._bodyForStatus("loading"));try{this._codeVerifier=await this.generateCodeVerifier();let e=await this.computeCodeChallenge(this._codeVerifier),t={"Content-Type":"application/json","X-Client-Id":this.tenant},i={scopes:this.scopes.split(/\s+/).filter(Boolean),codeChallenge:e,codeChallengeMethod:"S256"},s=await fetch(`${this.baseUrl}/api/v1/auth-sessions`,{method:"POST",headers:t,body:JSON.stringify(i)});if(!s.ok){let d=await s.json().catch(()=>({message:"Failed to create session"}));throw new Error(d.message??"Failed to create session")}let r=await s.json();this._sessionId=r.sessionId,this._expiresAt=new Date(r.expiresAt).getTime(),this._status="pending",this._updateBody(this._bodyForStatus("pending",{qrUrl:r.qrUrl})),this._bindBodyEvents(),this._startCountdown(),this._beginPolling(r.sessionId)}catch(e){let t=e instanceof Error?e.message:"Unexpected error";this._status="error",this._updateBody(this._bodyForStatus("error",{errorMessage:t})),this._bindBodyEvents(),this.emit("qrauth:error",{message:t})}}_retry(){this._clearTimers(),this._sessionId=null,this._codeVerifier=null,this._status="idle",this._expiresAt=null,this._updateBody(this._bodyForStatus("idle")),this._bindBodyEvents()}_beginPolling(e){let t="PENDING",i=2e3,s=5e3,r=0,d=20,h=async()=>{if(!this._sessionId)return;let l=`${this.baseUrl}/api/v1/auth-sessions/${e}`;this._codeVerifier&&(l+=`?code_verifier=${encodeURIComponent(this._codeVerifier)}`);try{let c=await(await fetch(l,{headers:{"X-Client-Id":this.tenant}})).json();if(r=0,i=2e3,c.status!==t&&(t=c.status,this._handleStatusChange(c.status,c),["APPROVED","DENIED","EXPIRED"].includes(c.status)))return;this._pollTimeout=setTimeout(h,i)}catch{if(r++,r>=d){this._handleStatusChange("EXPIRED",void 0);return}i=Math.min(i*1.5,s),this._pollTimeout=setTimeout(h,i)}};this._pollTimeout=setTimeout(h,i)}_handleStatusChange(e,t){switch(this._status=e,e){case"SCANNED":{let i=this.shadow.querySelector(".qr-frame img")?.src,s=i?decodeURIComponent(i.replace(/.*[?&]data=/,"").split("&")[0]):"";this._updateBody(this._bodyForStatus("SCANNED",{qrUrl:s,timerHtml:this._formatTimer()})),this._bindBodyEvents();break}case"APPROVED":{this._clearTimers(),this._updateBody(this._bodyForStatus("APPROVED")),this.emit("qrauth:verified",{sessionId:t?.sessionId,user:t?.user,signature:t?.signature});break}case"DENIED":this._clearTimers(),this._updateBody(this._bodyForStatus("DENIED")),this._bindBodyEvents(),this.emit("qrauth:denied");break;case"EXPIRED":this._clearTimers(),this._status="EXPIRED",this._updateBody(this._bodyForStatus("EXPIRED")),this._bindBodyEvents(),this.emit("qrauth:expired");break}}_startCountdown(){this._timerInterval=setInterval(()=>{let e=this.shadow.getElementById("timer");if(!e||!this._expiresAt)return;let t=Math.max(0,Math.floor((this._expiresAt-Date.now())/1e3));e.textContent=t>0?this._formatTimer(t):"",t<=0&&(clearInterval(this._timerInterval),this._timerInterval=null,(this._status==="pending"||this._status==="SCANNED")&&this._handleStatusChange("EXPIRED",void 0))},1e3)}_formatTimer(e){let t=e??(this._expiresAt?Math.max(0,Math.floor((this._expiresAt-Date.now())/1e3)):0),i=Math.floor(t/60),s=t%60;return`Expires in ${i}:${s<10?"0":""}${s}`}_updateBody(e){let t=this.shadow.getElementById("body");t&&(t.innerHTML=e)}_clearTimers(){this._timerInterval&&(clearInterval(this._timerInterval),this._timerInterval=null),this._pollTimeout&&(clearTimeout(this._pollTimeout),this._pollTimeout=null)}};customElements.define("qrauth-2fa",b);return T(q);})();
1303
+ `}_bindBodyEvents(){this.shadow.getElementById("start-btn")?.addEventListener("click",()=>this._startAuth()),this.shadow.getElementById("retry-btn")?.addEventListener("click",()=>this._retry()),this.shadow.getElementById("mobile-cta")?.addEventListener("click",()=>this._openApprovalPage())}_openApprovalPage(){if(!this._sessionToken)return;let t=`${this.baseUrl}/a/${this._sessionToken}`;window.open(t,"_blank","noopener")||(window.location.href=t)}async _startAuth(){this._status="loading",this._updateBody(this._bodyForStatus("loading"));try{this._codeVerifier=await this.generateCodeVerifier();let t=await this.computeCodeChallenge(this._codeVerifier),e={"Content-Type":"application/json","X-Client-Id":this.tenant},i={scopes:this.scopes.split(/\s+/).filter(Boolean),codeChallenge:t,codeChallengeMethod:"S256"},s=await fetch(`${this.baseUrl}/api/v1/auth-sessions`,{method:"POST",headers:e,body:JSON.stringify(i)});if(!s.ok){let d=await s.json().catch(()=>({message:"Failed to create session"}));throw new Error(d.message??"Failed to create session")}let r=await s.json();this._sessionId=r.sessionId,this._sessionToken=r.token||(r.qrUrl?r.qrUrl.split("/").pop()??null:null),this._expiresAt=new Date(r.expiresAt).getTime(),this._status="pending",this._updateBody(this._bodyForStatus("pending",{qrUrl:r.qrUrl})),this._bindBodyEvents(),this._startCountdown(),this._beginPolling(r.sessionId)}catch(t){let e=t instanceof Error?t.message:"Unexpected error";this._status="error",this._updateBody(this._bodyForStatus("error",{errorMessage:e})),this._bindBodyEvents(),this.emit("qrauth:error",{message:e})}}_retry(){this._clearTimers(),this._sessionId=null,this._sessionToken=null,this._codeVerifier=null,this._status="idle",this._expiresAt=null,this._updateBody(this._bodyForStatus("idle")),this._bindBodyEvents()}_beginPolling(t){let e="PENDING",i=2e3,s=5e3,r=0,d=20,h=async()=>{if(!this._sessionId)return;let l=`${this.baseUrl}/api/v1/auth-sessions/${t}`;this._codeVerifier&&(l+=`?code_verifier=${encodeURIComponent(this._codeVerifier)}`);try{let c=await(await fetch(l,{headers:{"X-Client-Id":this.tenant}})).json();if(r=0,i=2e3,c.status!==e&&(e=c.status,this._handleStatusChange(c.status,c),["APPROVED","DENIED","EXPIRED"].includes(c.status)))return;this._pollTimeout=setTimeout(h,i)}catch{if(r++,r>=d){this._handleStatusChange("EXPIRED",void 0);return}i=Math.min(i*1.5,s),this._pollTimeout=setTimeout(h,i)}};this._pollTimeout=setTimeout(h,i)}_handleStatusChange(t,e){switch(this._status=t,t){case"SCANNED":{let i=this.shadow.querySelector(".qr-frame img")?.src,s=i?decodeURIComponent(i.replace(/.*[?&]data=/,"").split("&")[0]):"";this._updateBody(this._bodyForStatus("SCANNED",{qrUrl:s,timerHtml:this._formatTimer()})),this._bindBodyEvents();break}case"APPROVED":{this._clearTimers(),this._updateBody(this._bodyForStatus("APPROVED")),this.emit("qrauth:verified",{sessionId:e?.sessionId,user:e?.user,signature:e?.signature});break}case"DENIED":this._clearTimers(),this._updateBody(this._bodyForStatus("DENIED")),this._bindBodyEvents(),this.emit("qrauth:denied");break;case"EXPIRED":this._clearTimers(),this._status="EXPIRED",this._updateBody(this._bodyForStatus("EXPIRED")),this._bindBodyEvents(),this.emit("qrauth:expired");break}}_startCountdown(){this._timerInterval=setInterval(()=>{let t=this.shadow.getElementById("timer");if(!t||!this._expiresAt)return;let e=Math.max(0,Math.floor((this._expiresAt-Date.now())/1e3));t.textContent=e>0?this._formatTimer(e):"",e<=0&&(clearInterval(this._timerInterval),this._timerInterval=null,(this._status==="pending"||this._status==="SCANNED")&&this._handleStatusChange("EXPIRED",void 0))},1e3)}_formatTimer(t){let e=t??(this._expiresAt?Math.max(0,Math.floor((this._expiresAt-Date.now())/1e3)):0),i=Math.floor(e/60),s=e%60;return`Expires in ${i}:${s<10?"0":""}${s}`}_updateBody(t){let e=this.shadow.getElementById("body");e&&(e.innerHTML=t)}_clearTimers(){this._timerInterval&&(clearInterval(this._timerInterval),this._timerInterval=null),this._pollTimeout&&(clearTimeout(this._pollTimeout),this._pollTimeout=null)}};customElements.define("qrauth-2fa",m);return C($);})();
1177
1304
  //# sourceMappingURL=qrauth-components.js.map
package/dist/twofa.d.ts CHANGED
@@ -6,12 +6,23 @@ import { QRAuthElement } from './base.js';
6
6
  * to perform QR-based step-up verification. Always displays inline — no button/modal mode.
7
7
  *
8
8
  * Attributes:
9
- * tenant — Your QRAuth App client ID (required)
10
- * theme — "light" (default) | "dark"
11
- * base-url — API base URL (default: https://qrauth.io)
12
- * session-token — JWT from the primary authentication (required for context)
13
- * scopes — Space-separated scopes (default: "identity")
14
- * auto-start — Boolean attribute; if present, starts 2FA immediately on connect
9
+ * tenant — Your QRAuth App client ID (required)
10
+ * theme — "light" (default) | "dark"
11
+ * base-url — API base URL (default: https://qrauth.io)
12
+ * session-token — JWT from the primary authentication (required for context)
13
+ * scopes — Space-separated scopes (default: "identity")
14
+ * auto-start — Boolean attribute; if present, starts 2FA immediately on connect
15
+ * force-mode — "mobile" | "desktop" | "auto" (default "auto").
16
+ * Overrides the automatic mobile-like detection that
17
+ * swaps the QR-first body for a "Continue with
18
+ * QRAuth" CTA + QR expander on phones and tablets.
19
+ * Mirrors the 0.2.0 <qrauth-login> treatment.
20
+ * mobile-fallback-only — If present, disables the mobile-aware UI entirely
21
+ * and keeps the QR-first body on every device. Use
22
+ * this when the surrounding page already provides an
23
+ * alternative same-device verification path and the
24
+ * component is only there as the cross-device scan
25
+ * option.
15
26
  *
16
27
  * Events (bubbles + composed, cross shadow-DOM):
17
28
  * qrauth:verified — detail: { sessionId, user, signature }
@@ -26,6 +37,7 @@ import { QRAuthElement } from './base.js';
26
37
  export declare class QRAuthTwoFA extends QRAuthElement {
27
38
  static get observedAttributes(): string[];
28
39
  private _sessionId;
40
+ private _sessionToken;
29
41
  private _codeVerifier;
30
42
  private _status;
31
43
  private _expiresAt;
@@ -34,12 +46,35 @@ export declare class QRAuthTwoFA extends QRAuthElement {
34
46
  get sessionToken(): string | null;
35
47
  get scopes(): string;
36
48
  get autoStart(): boolean;
49
+ get forceMode(): 'mobile' | 'desktop' | 'auto';
50
+ get mobileFallbackOnly(): boolean;
37
51
  connectedCallback(): void;
38
52
  disconnectedCallback(): void;
39
53
  protected render(): void;
40
54
  private _bodyForStatus;
55
+ /**
56
+ * Shared QR frame markup used by both the desktop pending body and the
57
+ * mobile "Use another device" expander. Centralised so the two paths can't
58
+ * drift.
59
+ */
60
+ private _qrFrameHtml;
61
+ /**
62
+ * Mobile-aware pending body. Primary CTA opens the hosted approval page
63
+ * (/a/:token) in a new tab — a same-device path that short-circuits the
64
+ * cross-device QR scan that is a dead end on phones. The QR itself is
65
+ * demoted to a collapsed expander for the "I have a second device" case.
66
+ * Mirrors the 0.2.0 <qrauth-login> treatment.
67
+ */
68
+ private _mobilePendingBody;
41
69
  private _sharedStyles;
42
70
  private _bindBodyEvents;
71
+ /**
72
+ * Mobile same-device path: open the hosted approval page in a new tab.
73
+ * The existing polling loop in _beginPolling picks up SCANNED / APPROVED
74
+ * transitions from the server once the user completes the flow there, so
75
+ * qrauth:verified fires unchanged.
76
+ */
77
+ private _openApprovalPage;
43
78
  private _startAuth;
44
79
  private _retry;
45
80
  private _beginPolling;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qrauth/web-components",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "QRAuth Web Components — drop-in authentication elements",
5
5
  "type": "module",
6
6
  "main": "dist/qrauth-components.js",
@@ -14,11 +14,17 @@
14
14
  }
15
15
  },
16
16
  "files": [
17
- "dist/"
17
+ "dist/qrauth-components.js",
18
+ "dist/qrauth-components.esm.js",
19
+ "dist/*.d.ts",
20
+ "README.md",
21
+ "CHANGELOG.md"
18
22
  ],
19
23
  "scripts": {
20
24
  "build": "tsc && node scripts/bundle.js",
21
25
  "dev": "tsc --watch",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
22
28
  "prepublishOnly": "npm run build"
23
29
  },
24
30
  "keywords": [
@@ -41,10 +47,12 @@
41
47
  },
42
48
  "repository": {
43
49
  "type": "git",
44
- "url": "https://github.com/qrauth-io/qrauth"
50
+ "url": "git+https://github.com/qrauth-io/qrauth.git"
45
51
  },
46
52
  "devDependencies": {
47
53
  "esbuild": "^0.24.0",
48
- "typescript": "^5.7.0"
54
+ "happy-dom": "^15.11.7",
55
+ "typescript": "^5.7.0",
56
+ "vitest": "^2.1.9"
49
57
  }
50
58
  }