@authon/js 0.1.10 → 0.1.12

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.
Files changed (3) hide show
  1. package/dist/index.js +182 -77
  2. package/dist/index.mjs +182 -77
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -97,9 +97,11 @@ var ModalRenderer = class {
97
97
  theme;
98
98
  branding;
99
99
  enabledProviders = [];
100
+ currentView = "signIn";
100
101
  onProviderClick;
101
102
  onEmailSubmit;
102
103
  onClose;
104
+ escHandler = null;
103
105
  constructor(options) {
104
106
  this.mode = options.mode;
105
107
  this.theme = options.theme || "auto";
@@ -118,10 +120,18 @@ var ModalRenderer = class {
118
120
  this.branding = { ...DEFAULT_BRANDING, ...branding };
119
121
  }
120
122
  open(view = "signIn") {
121
- this.close();
122
- this.render(view);
123
+ if (this.shadowRoot && this.hostElement) {
124
+ this.switchView(view);
125
+ } else {
126
+ this.currentView = view;
127
+ this.render(view);
128
+ }
123
129
  }
124
130
  close() {
131
+ if (this.escHandler) {
132
+ document.removeEventListener("keydown", this.escHandler);
133
+ this.escHandler = null;
134
+ }
125
135
  if (this.hostElement) {
126
136
  this.hostElement.remove();
127
137
  this.hostElement = null;
@@ -143,6 +153,21 @@ var ModalRenderer = class {
143
153
  errorEl.appendChild(errDiv);
144
154
  }
145
155
  }
156
+ showBanner(message, type = "error") {
157
+ if (!this.shadowRoot) return;
158
+ this.clearBanner();
159
+ const inner = this.shadowRoot.getElementById("modal-inner");
160
+ if (!inner) return;
161
+ const banner = document.createElement("div");
162
+ banner.id = "authon-banner";
163
+ banner.className = type === "warning" ? "banner-warning" : "error-msg";
164
+ banner.textContent = message;
165
+ inner.insertBefore(banner, inner.firstChild);
166
+ }
167
+ clearBanner() {
168
+ if (!this.shadowRoot) return;
169
+ this.shadowRoot.getElementById("authon-banner")?.remove();
170
+ }
146
171
  clearError() {
147
172
  if (!this.shadowRoot) return;
148
173
  this.shadowRoot.getElementById("authon-error-msg")?.remove();
@@ -166,6 +191,24 @@ var ModalRenderer = class {
166
191
  if (!this.shadowRoot) return;
167
192
  this.shadowRoot.getElementById("authon-loading-overlay")?.remove();
168
193
  }
194
+ // ── Smooth view switch (no flicker) ──
195
+ switchView(view) {
196
+ if (!this.shadowRoot || view === this.currentView) return;
197
+ this.currentView = view;
198
+ const isSignUp = view === "signUp";
199
+ const inner = this.shadowRoot.getElementById("modal-inner");
200
+ if (!inner) return;
201
+ inner.style.opacity = "0";
202
+ inner.style.transform = "translateY(-4px)";
203
+ setTimeout(() => {
204
+ inner.innerHTML = this.buildInnerContent(view);
205
+ this.attachInnerEvents(view);
206
+ void inner.offsetHeight;
207
+ inner.style.opacity = "1";
208
+ inner.style.transform = "translateY(0)";
209
+ }, 140);
210
+ }
211
+ // ── Render ──
169
212
  render(view) {
170
213
  const host = document.createElement("div");
171
214
  host.setAttribute("data-authon-modal", "");
@@ -176,10 +219,26 @@ var ModalRenderer = class {
176
219
  this.containerElement.appendChild(host);
177
220
  }
178
221
  this.shadowRoot = host.attachShadow({ mode: "open" });
179
- this.shadowRoot.innerHTML = this.buildHTML(view);
180
- this.attachEvents(view);
222
+ this.shadowRoot.innerHTML = this.buildShell(view);
223
+ this.attachInnerEvents(view);
224
+ this.attachShellEvents();
225
+ }
226
+ // ── HTML builders ──
227
+ /** Shell = style + backdrop + modal-container (stable across view switches) */
228
+ buildShell(view) {
229
+ const popupWrapper = this.mode === "popup" ? `<div class="backdrop" id="backdrop"></div>` : "";
230
+ return `
231
+ <style>${this.buildCSS()}</style>
232
+ ${popupWrapper}
233
+ <div class="modal-container" role="dialog" aria-modal="true">
234
+ <div id="modal-inner" class="modal-inner">
235
+ ${this.buildInnerContent(view)}
236
+ </div>
237
+ </div>
238
+ `;
181
239
  }
182
- buildHTML(view) {
240
+ /** Inner content = everything inside the modal that changes per view */
241
+ buildInnerContent(view) {
183
242
  const b = this.branding;
184
243
  const isSignUp = view === "signUp";
185
244
  const title = isSignUp ? "Create your account" : "Welcome back";
@@ -198,8 +257,8 @@ var ModalRenderer = class {
198
257
  }).join("");
199
258
  const divider = b.showDivider !== false && b.showEmailPassword !== false ? `<div class="divider"><span>or</span></div>` : "";
200
259
  const emailForm = b.showEmailPassword !== false ? `<form class="email-form" id="email-form">
201
- <input type="email" placeholder="Email address" name="email" required class="input" />
202
- <input type="password" placeholder="Password" name="password" required class="input" />
260
+ <input type="email" placeholder="Email address" name="email" required class="input" autocomplete="email" />
261
+ <input type="password" placeholder="Password" name="password" required class="input" autocomplete="${isSignUp ? "new-password" : "current-password"}" />
203
262
  <button type="submit" class="submit-btn">${isSignUp ? "Sign up" : "Sign in"}</button>
204
263
  </form>` : "";
205
264
  const footer = b.termsUrl || b.privacyUrl ? `<div class="footer">
@@ -207,21 +266,16 @@ var ModalRenderer = class {
207
266
  ${b.termsUrl && b.privacyUrl ? " \xB7 " : ""}
208
267
  ${b.privacyUrl ? `<a href="${b.privacyUrl}" target="_blank">Privacy Policy</a>` : ""}
209
268
  </div>` : "";
210
- const popupWrapper = this.mode === "popup" ? `<div class="backdrop" id="backdrop"></div>` : "";
211
269
  return `
212
- <style>${this.buildCSS()}</style>
213
- ${popupWrapper}
214
- <div class="modal-container" role="dialog" aria-modal="true">
215
- ${b.logoDataUrl ? `<img src="${b.logoDataUrl}" alt="Logo" class="logo" />` : ""}
216
- <h2 class="title">${title}</h2>
217
- ${b.brandName ? `<p class="brand-name">${b.brandName}</p>` : ""}
218
- <div class="providers">${providerButtons}</div>
219
- ${divider}
220
- ${emailForm}
221
- <p class="switch-view">${subtitle} <a href="#" id="switch-link">${subtitleLink}</a></p>
222
- ${footer}
223
- ${b.showSecuredBy !== false ? `<div class="secured-by">Secured by <span class="secured-brand">Authon</span></div>` : ""}
224
- </div>
270
+ ${b.logoDataUrl ? `<img src="${b.logoDataUrl}" alt="Logo" class="logo" />` : ""}
271
+ <h2 class="title">${title}</h2>
272
+ ${b.brandName ? `<p class="brand-name">${b.brandName}</p>` : ""}
273
+ <div class="providers">${providerButtons}</div>
274
+ ${divider}
275
+ ${emailForm}
276
+ <p class="switch-view">${subtitle} <a href="#" id="switch-link">${subtitleLink}</a></p>
277
+ ${footer}
278
+ ${b.showSecuredBy !== false ? `<div class="secured-by">Secured by <a href="https://authon.dev" target="_blank" rel="noopener noreferrer" class="secured-link">Authon</a></div>` : ""}
225
279
  `;
226
280
  }
227
281
  isDark() {
@@ -272,6 +326,9 @@ var ModalRenderer = class {
272
326
  position: ${this.mode === "popup" ? "fixed" : "relative"};
273
327
  ${this.mode === "popup" ? `box-shadow: 0 25px 50px -12px rgba(0,0,0,${dark ? "0.5" : "0.25"}); animation: slideIn 0.3s ease;` : ""}
274
328
  }
329
+ .modal-inner {
330
+ transition: opacity 0.14s ease, transform 0.14s ease;
331
+ }
275
332
  .logo { display: block; margin: 0 auto 16px; max-height: 48px; }
276
333
  .title { text-align: center; font-size: 24px; font-weight: 700; margin-bottom: 8px; color: var(--authon-text); }
277
334
  .brand-name { text-align: center; font-size: 14px; color: var(--authon-muted); margin-bottom: 24px; }
@@ -320,18 +377,25 @@ var ModalRenderer = class {
320
377
  font-size: 13px; color: #ef4444; text-align: center;
321
378
  animation: fadeIn 0.15s ease;
322
379
  }
380
+ .banner-warning {
381
+ margin-bottom: 16px; padding: 10px 14px;
382
+ background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3);
383
+ border-radius: calc(var(--authon-radius) * 0.33);
384
+ font-size: 13px; color: #f59e0b; text-align: center;
385
+ animation: fadeIn 0.15s ease;
386
+ }
323
387
  .switch-view { text-align: center; margin-top: 16px; font-size: 13px; color: var(--authon-muted); }
324
388
  .switch-view a { color: var(--authon-primary-start); text-decoration: none; font-weight: 500; }
325
389
  .switch-view a:hover { text-decoration: underline; }
326
- .footer { text-align: center; margin-top: 16px; font-size: 12px; color: var(--authon-dim); }
390
+ .footer { text-align: center; margin-top: 12px; font-size: 12px; color: var(--authon-dim); }
327
391
  .footer a { color: var(--authon-dim); text-decoration: none; }
328
392
  .footer a:hover { text-decoration: underline; }
329
393
  .secured-by {
330
- text-align: center; margin-top: 20px; padding-top: 16px;
331
- border-top: 1px solid var(--authon-divider);
394
+ text-align: center; margin-top: 16px;
332
395
  font-size: 11px; color: var(--authon-dim);
333
396
  }
334
- .secured-brand { font-weight: 600; color: var(--authon-muted); }
397
+ .secured-link { font-weight: 600; color: var(--authon-muted); text-decoration: none; }
398
+ .secured-link:hover { text-decoration: underline; }
335
399
  /* Loading overlay */
336
400
  #authon-loading-overlay {
337
401
  position: absolute; inset: 0; z-index: 10;
@@ -370,7 +434,26 @@ var ModalRenderer = class {
370
434
  ${b.customCss || ""}
371
435
  `;
372
436
  }
373
- attachEvents(view) {
437
+ // ── Event binding ──
438
+ /** Attach events to shell elements (backdrop, ESC) — called once */
439
+ attachShellEvents() {
440
+ if (!this.shadowRoot) return;
441
+ const backdrop = this.shadowRoot.getElementById("backdrop");
442
+ if (backdrop) {
443
+ backdrop.addEventListener("click", () => this.onClose());
444
+ }
445
+ if (this.escHandler) {
446
+ document.removeEventListener("keydown", this.escHandler);
447
+ }
448
+ if (this.mode === "popup") {
449
+ this.escHandler = (e) => {
450
+ if (e.key === "Escape") this.onClose();
451
+ };
452
+ document.addEventListener("keydown", this.escHandler);
453
+ }
454
+ }
455
+ /** Attach events to inner content (buttons, form, switch link) — called on each view */
456
+ attachInnerEvents(view) {
374
457
  if (!this.shadowRoot) return;
375
458
  this.shadowRoot.querySelectorAll(".provider-btn").forEach((btn) => {
376
459
  btn.addEventListener("click", () => {
@@ -397,19 +480,6 @@ var ModalRenderer = class {
397
480
  this.open(view === "signIn" ? "signUp" : "signIn");
398
481
  });
399
482
  }
400
- const backdrop = this.shadowRoot.getElementById("backdrop");
401
- if (backdrop) {
402
- backdrop.addEventListener("click", () => this.onClose());
403
- }
404
- if (this.mode === "popup") {
405
- const handler = (e) => {
406
- if (e.key === "Escape") {
407
- this.onClose();
408
- document.removeEventListener("keydown", handler);
409
- }
410
- };
411
- document.addEventListener("keydown", handler);
412
- }
413
483
  }
414
484
  };
415
485
 
@@ -619,7 +689,7 @@ var Authon = class {
619
689
  async startOAuthFlow(provider) {
620
690
  try {
621
691
  const redirectUri = `${this.config.apiUrl}/v1/auth/oauth/redirect`;
622
- const { url } = await this.apiGet(
692
+ const { url, state } = await this.apiGet(
623
693
  `/v1/auth/oauth/${provider}/url?redirectUri=${encodeURIComponent(redirectUri)}`
624
694
  );
625
695
  this.modal?.showLoading();
@@ -632,54 +702,89 @@ var Authon = class {
632
702
  "authon-oauth",
633
703
  `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no`
634
704
  );
635
- let callbackReceived = false;
705
+ if (!popup || popup.closed) {
706
+ this.modal?.hideLoading();
707
+ this.modal?.showBanner(
708
+ "Pop-up blocked. Please allow pop-ups for this site and try again.",
709
+ "warning"
710
+ );
711
+ this.emit("error", new Error("Popup was blocked by the browser"));
712
+ return;
713
+ }
714
+ let resolved = false;
636
715
  let cleaned = false;
716
+ const resolve = (tokens) => {
717
+ if (resolved) return;
718
+ resolved = true;
719
+ cleanup();
720
+ try {
721
+ if (!popup.closed) popup.close();
722
+ } catch {
723
+ }
724
+ this.session.setSession(tokens);
725
+ this.modal?.close();
726
+ this.emit("signedIn", tokens.user);
727
+ };
728
+ const handleError = (msg) => {
729
+ if (resolved) return;
730
+ cleanup();
731
+ this.modal?.hideLoading();
732
+ this.modal?.showError(msg);
733
+ this.emit("error", new Error(msg));
734
+ };
637
735
  const cleanup = () => {
638
736
  if (cleaned) return;
639
737
  cleaned = true;
640
- window.removeEventListener("message", handler);
641
- window.removeEventListener("focus", focusHandler);
738
+ window.removeEventListener("message", messageHandler);
739
+ if (apiPollTimer) clearInterval(apiPollTimer);
740
+ if (closePollTimer) clearInterval(closePollTimer);
642
741
  if (maxTimer) clearTimeout(maxTimer);
643
742
  };
644
- const focusHandler = () => {
645
- if (callbackReceived || cleaned) return;
646
- setTimeout(() => {
647
- if (callbackReceived || cleaned) return;
648
- cleanup();
649
- this.modal?.hideLoading();
650
- }, 1500);
743
+ const messageHandler = (e) => {
744
+ if (e.data?.type !== "authon-oauth-callback") return;
745
+ if (e.data.tokens) {
746
+ resolve(e.data.tokens);
747
+ }
651
748
  };
652
- window.addEventListener("focus", focusHandler);
749
+ window.addEventListener("message", messageHandler);
750
+ const apiPollTimer = setInterval(async () => {
751
+ if (resolved || cleaned) return;
752
+ try {
753
+ const result = await this.apiGet(
754
+ `/v1/auth/oauth/poll?state=${encodeURIComponent(state)}`
755
+ );
756
+ if (result.status === "completed" && result.accessToken) {
757
+ resolve({
758
+ accessToken: result.accessToken,
759
+ refreshToken: result.refreshToken,
760
+ expiresIn: result.expiresIn,
761
+ user: result.user
762
+ });
763
+ } else if (result.status === "error") {
764
+ handleError(result.message || "Authentication failed");
765
+ }
766
+ } catch {
767
+ }
768
+ }, 1500);
769
+ const closePollTimer = setInterval(() => {
770
+ if (resolved || cleaned) return;
771
+ try {
772
+ if (popup.closed) {
773
+ clearInterval(closePollTimer);
774
+ setTimeout(() => {
775
+ if (resolved || cleaned) return;
776
+ cleanup();
777
+ this.modal?.hideLoading();
778
+ }, 3e3);
779
+ }
780
+ } catch {
781
+ }
782
+ }, 500);
653
783
  const maxTimer = setTimeout(() => {
654
- if (callbackReceived || cleaned) return;
784
+ if (resolved || cleaned) return;
655
785
  cleanup();
656
786
  this.modal?.hideLoading();
657
787
  }, 18e4);
658
- if (!popup) {
659
- cleanup();
660
- this.modal?.hideLoading();
661
- this.emit("error", new Error("Popup was blocked by the browser"));
662
- return;
663
- }
664
- const handler = async (e) => {
665
- if (e.data?.type !== "authon-oauth-callback") return;
666
- callbackReceived = true;
667
- cleanup();
668
- try {
669
- const tokens = await this.apiPost("/v1/auth/oauth/callback", {
670
- code: e.data.code,
671
- state: e.data.state,
672
- provider
673
- });
674
- this.session.setSession(tokens);
675
- this.modal?.close();
676
- this.emit("signedIn", tokens.user);
677
- } catch (err) {
678
- this.modal?.hideLoading();
679
- this.emit("error", err instanceof Error ? err : new Error(String(err)));
680
- }
681
- };
682
- window.addEventListener("message", handler);
683
788
  } catch (err) {
684
789
  this.modal?.hideLoading();
685
790
  this.emit("error", err instanceof Error ? err : new Error(String(err)));
package/dist/index.mjs CHANGED
@@ -70,9 +70,11 @@ var ModalRenderer = class {
70
70
  theme;
71
71
  branding;
72
72
  enabledProviders = [];
73
+ currentView = "signIn";
73
74
  onProviderClick;
74
75
  onEmailSubmit;
75
76
  onClose;
77
+ escHandler = null;
76
78
  constructor(options) {
77
79
  this.mode = options.mode;
78
80
  this.theme = options.theme || "auto";
@@ -91,10 +93,18 @@ var ModalRenderer = class {
91
93
  this.branding = { ...DEFAULT_BRANDING, ...branding };
92
94
  }
93
95
  open(view = "signIn") {
94
- this.close();
95
- this.render(view);
96
+ if (this.shadowRoot && this.hostElement) {
97
+ this.switchView(view);
98
+ } else {
99
+ this.currentView = view;
100
+ this.render(view);
101
+ }
96
102
  }
97
103
  close() {
104
+ if (this.escHandler) {
105
+ document.removeEventListener("keydown", this.escHandler);
106
+ this.escHandler = null;
107
+ }
98
108
  if (this.hostElement) {
99
109
  this.hostElement.remove();
100
110
  this.hostElement = null;
@@ -116,6 +126,21 @@ var ModalRenderer = class {
116
126
  errorEl.appendChild(errDiv);
117
127
  }
118
128
  }
129
+ showBanner(message, type = "error") {
130
+ if (!this.shadowRoot) return;
131
+ this.clearBanner();
132
+ const inner = this.shadowRoot.getElementById("modal-inner");
133
+ if (!inner) return;
134
+ const banner = document.createElement("div");
135
+ banner.id = "authon-banner";
136
+ banner.className = type === "warning" ? "banner-warning" : "error-msg";
137
+ banner.textContent = message;
138
+ inner.insertBefore(banner, inner.firstChild);
139
+ }
140
+ clearBanner() {
141
+ if (!this.shadowRoot) return;
142
+ this.shadowRoot.getElementById("authon-banner")?.remove();
143
+ }
119
144
  clearError() {
120
145
  if (!this.shadowRoot) return;
121
146
  this.shadowRoot.getElementById("authon-error-msg")?.remove();
@@ -139,6 +164,24 @@ var ModalRenderer = class {
139
164
  if (!this.shadowRoot) return;
140
165
  this.shadowRoot.getElementById("authon-loading-overlay")?.remove();
141
166
  }
167
+ // ── Smooth view switch (no flicker) ──
168
+ switchView(view) {
169
+ if (!this.shadowRoot || view === this.currentView) return;
170
+ this.currentView = view;
171
+ const isSignUp = view === "signUp";
172
+ const inner = this.shadowRoot.getElementById("modal-inner");
173
+ if (!inner) return;
174
+ inner.style.opacity = "0";
175
+ inner.style.transform = "translateY(-4px)";
176
+ setTimeout(() => {
177
+ inner.innerHTML = this.buildInnerContent(view);
178
+ this.attachInnerEvents(view);
179
+ void inner.offsetHeight;
180
+ inner.style.opacity = "1";
181
+ inner.style.transform = "translateY(0)";
182
+ }, 140);
183
+ }
184
+ // ── Render ──
142
185
  render(view) {
143
186
  const host = document.createElement("div");
144
187
  host.setAttribute("data-authon-modal", "");
@@ -149,10 +192,26 @@ var ModalRenderer = class {
149
192
  this.containerElement.appendChild(host);
150
193
  }
151
194
  this.shadowRoot = host.attachShadow({ mode: "open" });
152
- this.shadowRoot.innerHTML = this.buildHTML(view);
153
- this.attachEvents(view);
195
+ this.shadowRoot.innerHTML = this.buildShell(view);
196
+ this.attachInnerEvents(view);
197
+ this.attachShellEvents();
198
+ }
199
+ // ── HTML builders ──
200
+ /** Shell = style + backdrop + modal-container (stable across view switches) */
201
+ buildShell(view) {
202
+ const popupWrapper = this.mode === "popup" ? `<div class="backdrop" id="backdrop"></div>` : "";
203
+ return `
204
+ <style>${this.buildCSS()}</style>
205
+ ${popupWrapper}
206
+ <div class="modal-container" role="dialog" aria-modal="true">
207
+ <div id="modal-inner" class="modal-inner">
208
+ ${this.buildInnerContent(view)}
209
+ </div>
210
+ </div>
211
+ `;
154
212
  }
155
- buildHTML(view) {
213
+ /** Inner content = everything inside the modal that changes per view */
214
+ buildInnerContent(view) {
156
215
  const b = this.branding;
157
216
  const isSignUp = view === "signUp";
158
217
  const title = isSignUp ? "Create your account" : "Welcome back";
@@ -171,8 +230,8 @@ var ModalRenderer = class {
171
230
  }).join("");
172
231
  const divider = b.showDivider !== false && b.showEmailPassword !== false ? `<div class="divider"><span>or</span></div>` : "";
173
232
  const emailForm = b.showEmailPassword !== false ? `<form class="email-form" id="email-form">
174
- <input type="email" placeholder="Email address" name="email" required class="input" />
175
- <input type="password" placeholder="Password" name="password" required class="input" />
233
+ <input type="email" placeholder="Email address" name="email" required class="input" autocomplete="email" />
234
+ <input type="password" placeholder="Password" name="password" required class="input" autocomplete="${isSignUp ? "new-password" : "current-password"}" />
176
235
  <button type="submit" class="submit-btn">${isSignUp ? "Sign up" : "Sign in"}</button>
177
236
  </form>` : "";
178
237
  const footer = b.termsUrl || b.privacyUrl ? `<div class="footer">
@@ -180,21 +239,16 @@ var ModalRenderer = class {
180
239
  ${b.termsUrl && b.privacyUrl ? " \xB7 " : ""}
181
240
  ${b.privacyUrl ? `<a href="${b.privacyUrl}" target="_blank">Privacy Policy</a>` : ""}
182
241
  </div>` : "";
183
- const popupWrapper = this.mode === "popup" ? `<div class="backdrop" id="backdrop"></div>` : "";
184
242
  return `
185
- <style>${this.buildCSS()}</style>
186
- ${popupWrapper}
187
- <div class="modal-container" role="dialog" aria-modal="true">
188
- ${b.logoDataUrl ? `<img src="${b.logoDataUrl}" alt="Logo" class="logo" />` : ""}
189
- <h2 class="title">${title}</h2>
190
- ${b.brandName ? `<p class="brand-name">${b.brandName}</p>` : ""}
191
- <div class="providers">${providerButtons}</div>
192
- ${divider}
193
- ${emailForm}
194
- <p class="switch-view">${subtitle} <a href="#" id="switch-link">${subtitleLink}</a></p>
195
- ${footer}
196
- ${b.showSecuredBy !== false ? `<div class="secured-by">Secured by <span class="secured-brand">Authon</span></div>` : ""}
197
- </div>
243
+ ${b.logoDataUrl ? `<img src="${b.logoDataUrl}" alt="Logo" class="logo" />` : ""}
244
+ <h2 class="title">${title}</h2>
245
+ ${b.brandName ? `<p class="brand-name">${b.brandName}</p>` : ""}
246
+ <div class="providers">${providerButtons}</div>
247
+ ${divider}
248
+ ${emailForm}
249
+ <p class="switch-view">${subtitle} <a href="#" id="switch-link">${subtitleLink}</a></p>
250
+ ${footer}
251
+ ${b.showSecuredBy !== false ? `<div class="secured-by">Secured by <a href="https://authon.dev" target="_blank" rel="noopener noreferrer" class="secured-link">Authon</a></div>` : ""}
198
252
  `;
199
253
  }
200
254
  isDark() {
@@ -245,6 +299,9 @@ var ModalRenderer = class {
245
299
  position: ${this.mode === "popup" ? "fixed" : "relative"};
246
300
  ${this.mode === "popup" ? `box-shadow: 0 25px 50px -12px rgba(0,0,0,${dark ? "0.5" : "0.25"}); animation: slideIn 0.3s ease;` : ""}
247
301
  }
302
+ .modal-inner {
303
+ transition: opacity 0.14s ease, transform 0.14s ease;
304
+ }
248
305
  .logo { display: block; margin: 0 auto 16px; max-height: 48px; }
249
306
  .title { text-align: center; font-size: 24px; font-weight: 700; margin-bottom: 8px; color: var(--authon-text); }
250
307
  .brand-name { text-align: center; font-size: 14px; color: var(--authon-muted); margin-bottom: 24px; }
@@ -293,18 +350,25 @@ var ModalRenderer = class {
293
350
  font-size: 13px; color: #ef4444; text-align: center;
294
351
  animation: fadeIn 0.15s ease;
295
352
  }
353
+ .banner-warning {
354
+ margin-bottom: 16px; padding: 10px 14px;
355
+ background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3);
356
+ border-radius: calc(var(--authon-radius) * 0.33);
357
+ font-size: 13px; color: #f59e0b; text-align: center;
358
+ animation: fadeIn 0.15s ease;
359
+ }
296
360
  .switch-view { text-align: center; margin-top: 16px; font-size: 13px; color: var(--authon-muted); }
297
361
  .switch-view a { color: var(--authon-primary-start); text-decoration: none; font-weight: 500; }
298
362
  .switch-view a:hover { text-decoration: underline; }
299
- .footer { text-align: center; margin-top: 16px; font-size: 12px; color: var(--authon-dim); }
363
+ .footer { text-align: center; margin-top: 12px; font-size: 12px; color: var(--authon-dim); }
300
364
  .footer a { color: var(--authon-dim); text-decoration: none; }
301
365
  .footer a:hover { text-decoration: underline; }
302
366
  .secured-by {
303
- text-align: center; margin-top: 20px; padding-top: 16px;
304
- border-top: 1px solid var(--authon-divider);
367
+ text-align: center; margin-top: 16px;
305
368
  font-size: 11px; color: var(--authon-dim);
306
369
  }
307
- .secured-brand { font-weight: 600; color: var(--authon-muted); }
370
+ .secured-link { font-weight: 600; color: var(--authon-muted); text-decoration: none; }
371
+ .secured-link:hover { text-decoration: underline; }
308
372
  /* Loading overlay */
309
373
  #authon-loading-overlay {
310
374
  position: absolute; inset: 0; z-index: 10;
@@ -343,7 +407,26 @@ var ModalRenderer = class {
343
407
  ${b.customCss || ""}
344
408
  `;
345
409
  }
346
- attachEvents(view) {
410
+ // ── Event binding ──
411
+ /** Attach events to shell elements (backdrop, ESC) — called once */
412
+ attachShellEvents() {
413
+ if (!this.shadowRoot) return;
414
+ const backdrop = this.shadowRoot.getElementById("backdrop");
415
+ if (backdrop) {
416
+ backdrop.addEventListener("click", () => this.onClose());
417
+ }
418
+ if (this.escHandler) {
419
+ document.removeEventListener("keydown", this.escHandler);
420
+ }
421
+ if (this.mode === "popup") {
422
+ this.escHandler = (e) => {
423
+ if (e.key === "Escape") this.onClose();
424
+ };
425
+ document.addEventListener("keydown", this.escHandler);
426
+ }
427
+ }
428
+ /** Attach events to inner content (buttons, form, switch link) — called on each view */
429
+ attachInnerEvents(view) {
347
430
  if (!this.shadowRoot) return;
348
431
  this.shadowRoot.querySelectorAll(".provider-btn").forEach((btn) => {
349
432
  btn.addEventListener("click", () => {
@@ -370,19 +453,6 @@ var ModalRenderer = class {
370
453
  this.open(view === "signIn" ? "signUp" : "signIn");
371
454
  });
372
455
  }
373
- const backdrop = this.shadowRoot.getElementById("backdrop");
374
- if (backdrop) {
375
- backdrop.addEventListener("click", () => this.onClose());
376
- }
377
- if (this.mode === "popup") {
378
- const handler = (e) => {
379
- if (e.key === "Escape") {
380
- this.onClose();
381
- document.removeEventListener("keydown", handler);
382
- }
383
- };
384
- document.addEventListener("keydown", handler);
385
- }
386
456
  }
387
457
  };
388
458
 
@@ -592,7 +662,7 @@ var Authon = class {
592
662
  async startOAuthFlow(provider) {
593
663
  try {
594
664
  const redirectUri = `${this.config.apiUrl}/v1/auth/oauth/redirect`;
595
- const { url } = await this.apiGet(
665
+ const { url, state } = await this.apiGet(
596
666
  `/v1/auth/oauth/${provider}/url?redirectUri=${encodeURIComponent(redirectUri)}`
597
667
  );
598
668
  this.modal?.showLoading();
@@ -605,54 +675,89 @@ var Authon = class {
605
675
  "authon-oauth",
606
676
  `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no`
607
677
  );
608
- let callbackReceived = false;
678
+ if (!popup || popup.closed) {
679
+ this.modal?.hideLoading();
680
+ this.modal?.showBanner(
681
+ "Pop-up blocked. Please allow pop-ups for this site and try again.",
682
+ "warning"
683
+ );
684
+ this.emit("error", new Error("Popup was blocked by the browser"));
685
+ return;
686
+ }
687
+ let resolved = false;
609
688
  let cleaned = false;
689
+ const resolve = (tokens) => {
690
+ if (resolved) return;
691
+ resolved = true;
692
+ cleanup();
693
+ try {
694
+ if (!popup.closed) popup.close();
695
+ } catch {
696
+ }
697
+ this.session.setSession(tokens);
698
+ this.modal?.close();
699
+ this.emit("signedIn", tokens.user);
700
+ };
701
+ const handleError = (msg) => {
702
+ if (resolved) return;
703
+ cleanup();
704
+ this.modal?.hideLoading();
705
+ this.modal?.showError(msg);
706
+ this.emit("error", new Error(msg));
707
+ };
610
708
  const cleanup = () => {
611
709
  if (cleaned) return;
612
710
  cleaned = true;
613
- window.removeEventListener("message", handler);
614
- window.removeEventListener("focus", focusHandler);
711
+ window.removeEventListener("message", messageHandler);
712
+ if (apiPollTimer) clearInterval(apiPollTimer);
713
+ if (closePollTimer) clearInterval(closePollTimer);
615
714
  if (maxTimer) clearTimeout(maxTimer);
616
715
  };
617
- const focusHandler = () => {
618
- if (callbackReceived || cleaned) return;
619
- setTimeout(() => {
620
- if (callbackReceived || cleaned) return;
621
- cleanup();
622
- this.modal?.hideLoading();
623
- }, 1500);
716
+ const messageHandler = (e) => {
717
+ if (e.data?.type !== "authon-oauth-callback") return;
718
+ if (e.data.tokens) {
719
+ resolve(e.data.tokens);
720
+ }
624
721
  };
625
- window.addEventListener("focus", focusHandler);
722
+ window.addEventListener("message", messageHandler);
723
+ const apiPollTimer = setInterval(async () => {
724
+ if (resolved || cleaned) return;
725
+ try {
726
+ const result = await this.apiGet(
727
+ `/v1/auth/oauth/poll?state=${encodeURIComponent(state)}`
728
+ );
729
+ if (result.status === "completed" && result.accessToken) {
730
+ resolve({
731
+ accessToken: result.accessToken,
732
+ refreshToken: result.refreshToken,
733
+ expiresIn: result.expiresIn,
734
+ user: result.user
735
+ });
736
+ } else if (result.status === "error") {
737
+ handleError(result.message || "Authentication failed");
738
+ }
739
+ } catch {
740
+ }
741
+ }, 1500);
742
+ const closePollTimer = setInterval(() => {
743
+ if (resolved || cleaned) return;
744
+ try {
745
+ if (popup.closed) {
746
+ clearInterval(closePollTimer);
747
+ setTimeout(() => {
748
+ if (resolved || cleaned) return;
749
+ cleanup();
750
+ this.modal?.hideLoading();
751
+ }, 3e3);
752
+ }
753
+ } catch {
754
+ }
755
+ }, 500);
626
756
  const maxTimer = setTimeout(() => {
627
- if (callbackReceived || cleaned) return;
757
+ if (resolved || cleaned) return;
628
758
  cleanup();
629
759
  this.modal?.hideLoading();
630
760
  }, 18e4);
631
- if (!popup) {
632
- cleanup();
633
- this.modal?.hideLoading();
634
- this.emit("error", new Error("Popup was blocked by the browser"));
635
- return;
636
- }
637
- const handler = async (e) => {
638
- if (e.data?.type !== "authon-oauth-callback") return;
639
- callbackReceived = true;
640
- cleanup();
641
- try {
642
- const tokens = await this.apiPost("/v1/auth/oauth/callback", {
643
- code: e.data.code,
644
- state: e.data.state,
645
- provider
646
- });
647
- this.session.setSession(tokens);
648
- this.modal?.close();
649
- this.emit("signedIn", tokens.user);
650
- } catch (err) {
651
- this.modal?.hideLoading();
652
- this.emit("error", err instanceof Error ? err : new Error(String(err)));
653
- }
654
- };
655
- window.addEventListener("message", handler);
656
761
  } catch (err) {
657
762
  this.modal?.hideLoading();
658
763
  this.emit("error", err instanceof Error ? err : new Error(String(err)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authon/js",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Authon core SDK — ShadowDOM login modal for any app",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",