@authon/js 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -44,6 +44,7 @@ declare class Authon {
44
44
  private startOAuthFlow;
45
45
  private apiGet;
46
46
  private apiPost;
47
+ private parseApiError;
47
48
  }
48
49
 
49
50
  interface ProviderButtonConfig {
package/dist/index.d.ts CHANGED
@@ -44,6 +44,7 @@ declare class Authon {
44
44
  private startOAuthFlow;
45
45
  private apiGet;
46
46
  private apiPost;
47
+ private parseApiError;
47
48
  }
48
49
 
49
50
  interface ProviderButtonConfig {
package/dist/index.js CHANGED
@@ -97,6 +97,7 @@ var ModalRenderer = class {
97
97
  theme;
98
98
  branding;
99
99
  enabledProviders = [];
100
+ currentView = "signIn";
100
101
  onProviderClick;
101
102
  onEmailSubmit;
102
103
  onClose;
@@ -120,9 +121,9 @@ var ModalRenderer = class {
120
121
  }
121
122
  open(view = "signIn") {
122
123
  if (this.shadowRoot && this.hostElement) {
123
- this.shadowRoot.innerHTML = this.buildHTML(view);
124
- this.attachEvents(view);
124
+ this.switchView(view);
125
125
  } else {
126
+ this.currentView = view;
126
127
  this.render(view);
127
128
  }
128
129
  }
@@ -155,13 +156,13 @@ var ModalRenderer = class {
155
156
  showBanner(message, type = "error") {
156
157
  if (!this.shadowRoot) return;
157
158
  this.clearBanner();
158
- const container = this.shadowRoot.querySelector(".modal-container");
159
- if (!container) return;
159
+ const inner = this.shadowRoot.getElementById("modal-inner");
160
+ if (!inner) return;
160
161
  const banner = document.createElement("div");
161
162
  banner.id = "authon-banner";
162
163
  banner.className = type === "warning" ? "banner-warning" : "error-msg";
163
164
  banner.textContent = message;
164
- container.insertBefore(banner, container.firstChild);
165
+ inner.insertBefore(banner, inner.firstChild);
165
166
  }
166
167
  clearBanner() {
167
168
  if (!this.shadowRoot) return;
@@ -190,6 +191,24 @@ var ModalRenderer = class {
190
191
  if (!this.shadowRoot) return;
191
192
  this.shadowRoot.getElementById("authon-loading-overlay")?.remove();
192
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 ──
193
212
  render(view) {
194
213
  const host = document.createElement("div");
195
214
  host.setAttribute("data-authon-modal", "");
@@ -200,30 +219,48 @@ var ModalRenderer = class {
200
219
  this.containerElement.appendChild(host);
201
220
  }
202
221
  this.shadowRoot = host.attachShadow({ mode: "open" });
203
- this.shadowRoot.innerHTML = this.buildHTML(view);
204
- 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
+ `;
205
239
  }
206
- buildHTML(view) {
240
+ /** Inner content = everything inside the modal that changes per view */
241
+ buildInnerContent(view) {
207
242
  const b = this.branding;
208
243
  const isSignUp = view === "signUp";
209
244
  const title = isSignUp ? "Create your account" : "Welcome back";
210
245
  const subtitle = isSignUp ? "Already have an account?" : "Don't have an account?";
211
246
  const subtitleLink = isSignUp ? "Sign in" : "Sign up";
212
247
  const dark = this.isDark();
213
- const providerButtons = this.enabledProviders.filter((p) => !b.hiddenProviders?.includes(p)).map((p) => {
248
+ const showProviders = !isSignUp;
249
+ const providerButtons = showProviders ? this.enabledProviders.filter((p) => !b.hiddenProviders?.includes(p)).map((p) => {
214
250
  const config = getProviderButtonConfig(p);
215
251
  const isWhiteBg = config.bgColor === "#ffffff";
216
252
  const btnBg = dark && isWhiteBg ? "#f8fafc" : config.bgColor;
217
253
  const btnBorder = isWhiteBg ? dark ? "#475569" : "#e5e7eb" : config.bgColor;
218
254
  return `<button class="provider-btn" data-provider="${p}" style="background:${btnBg};color:${config.textColor};border:1px solid ${btnBorder}">
219
- <span class="provider-icon">${config.iconSvg}</span>
220
- <span>${config.label}</span>
221
- </button>`;
222
- }).join("");
223
- const divider = b.showDivider !== false && b.showEmailPassword !== false ? `<div class="divider"><span>or</span></div>` : "";
255
+ <span class="provider-icon">${config.iconSvg}</span>
256
+ <span>${config.label}</span>
257
+ </button>`;
258
+ }).join("") : "";
259
+ const divider = showProviders && b.showDivider !== false && b.showEmailPassword !== false ? `<div class="divider"><span>or</span></div>` : "";
224
260
  const emailForm = b.showEmailPassword !== false ? `<form class="email-form" id="email-form">
225
261
  <input type="email" placeholder="Email address" name="email" required class="input" autocomplete="email" />
226
262
  <input type="password" placeholder="Password" name="password" required class="input" autocomplete="${isSignUp ? "new-password" : "current-password"}" />
263
+ ${isSignUp ? '<p class="password-hint">Must contain uppercase, lowercase, and a number (min 8 chars)</p>' : ""}
227
264
  <button type="submit" class="submit-btn">${isSignUp ? "Sign up" : "Sign in"}</button>
228
265
  </form>` : "";
229
266
  const footer = b.termsUrl || b.privacyUrl ? `<div class="footer">
@@ -231,21 +268,22 @@ var ModalRenderer = class {
231
268
  ${b.termsUrl && b.privacyUrl ? " \xB7 " : ""}
232
269
  ${b.privacyUrl ? `<a href="${b.privacyUrl}" target="_blank">Privacy Policy</a>` : ""}
233
270
  </div>` : "";
234
- const popupWrapper = this.mode === "popup" ? `<div class="backdrop" id="backdrop"></div>` : "";
271
+ const titleHtml = isSignUp ? `<div class="title-row">
272
+ <button class="back-btn" id="back-btn" type="button" aria-label="Back to sign in">
273
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
274
+ </button>
275
+ <h2 class="title">${title}</h2>
276
+ </div>` : `<h2 class="title">${title}</h2>`;
235
277
  return `
236
- <style>${this.buildCSS()}</style>
237
- ${popupWrapper}
238
- <div class="modal-container" role="dialog" aria-modal="true">
239
- ${b.logoDataUrl ? `<img src="${b.logoDataUrl}" alt="Logo" class="logo" />` : ""}
240
- <h2 class="title">${title}</h2>
241
- ${b.brandName ? `<p class="brand-name">${b.brandName}</p>` : ""}
242
- <div class="providers">${providerButtons}</div>
243
- ${divider}
244
- ${emailForm}
245
- <p class="switch-view">${subtitle} <a href="#" id="switch-link">${subtitleLink}</a></p>
246
- ${footer}
247
- ${b.showSecuredBy !== false ? `<div class="secured-by">Secured by <span class="secured-brand">Authon</span></div>` : ""}
248
- </div>
278
+ ${b.logoDataUrl ? `<img src="${b.logoDataUrl}" alt="Logo" class="logo" />` : ""}
279
+ ${titleHtml}
280
+ ${b.brandName ? `<p class="brand-name">${b.brandName}</p>` : ""}
281
+ ${showProviders ? `<div class="providers">${providerButtons}</div>` : ""}
282
+ ${divider}
283
+ ${emailForm}
284
+ <p class="switch-view">${subtitle} <a href="#" id="switch-link">${subtitleLink}</a></p>
285
+ ${footer}
286
+ ${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>` : ""}
249
287
  `;
250
288
  }
251
289
  isDark() {
@@ -296,7 +334,20 @@ var ModalRenderer = class {
296
334
  position: ${this.mode === "popup" ? "fixed" : "relative"};
297
335
  ${this.mode === "popup" ? `box-shadow: 0 25px 50px -12px rgba(0,0,0,${dark ? "0.5" : "0.25"}); animation: slideIn 0.3s ease;` : ""}
298
336
  }
337
+ .modal-inner {
338
+ transition: opacity 0.14s ease, transform 0.14s ease;
339
+ }
299
340
  .logo { display: block; margin: 0 auto 16px; max-height: 48px; }
341
+ .title-row { display: flex; align-items: center; position: relative; margin-bottom: 8px; }
342
+ .title-row .title { flex: 1; margin-bottom: 0; }
343
+ .back-btn {
344
+ position: absolute; left: 0; top: 50%; transform: translateY(-50%);
345
+ background: none; border: none; color: var(--authon-muted);
346
+ cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center;
347
+ transition: color 0.15s, background 0.15s;
348
+ }
349
+ .back-btn:hover { color: var(--authon-text); background: var(--authon-divider); }
350
+ .password-hint { font-size: 11px; color: var(--authon-dim); margin: -4px 0 2px; }
300
351
  .title { text-align: center; font-size: 24px; font-weight: 700; margin-bottom: 8px; color: var(--authon-text); }
301
352
  .brand-name { text-align: center; font-size: 14px; color: var(--authon-muted); margin-bottom: 24px; }
302
353
  .providers { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
@@ -354,15 +405,15 @@ var ModalRenderer = class {
354
405
  .switch-view { text-align: center; margin-top: 16px; font-size: 13px; color: var(--authon-muted); }
355
406
  .switch-view a { color: var(--authon-primary-start); text-decoration: none; font-weight: 500; }
356
407
  .switch-view a:hover { text-decoration: underline; }
357
- .footer { text-align: center; margin-top: 16px; font-size: 12px; color: var(--authon-dim); }
408
+ .footer { text-align: center; margin-top: 12px; font-size: 12px; color: var(--authon-dim); }
358
409
  .footer a { color: var(--authon-dim); text-decoration: none; }
359
410
  .footer a:hover { text-decoration: underline; }
360
411
  .secured-by {
361
- text-align: center; margin-top: 20px; padding-top: 16px;
362
- border-top: 1px solid var(--authon-divider);
412
+ text-align: center; margin-top: 16px;
363
413
  font-size: 11px; color: var(--authon-dim);
364
414
  }
365
- .secured-brand { font-weight: 600; color: var(--authon-muted); }
415
+ .secured-link { font-weight: 600; color: var(--authon-muted); text-decoration: none; }
416
+ .secured-link:hover { text-decoration: underline; }
366
417
  /* Loading overlay */
367
418
  #authon-loading-overlay {
368
419
  position: absolute; inset: 0; z-index: 10;
@@ -401,7 +452,26 @@ var ModalRenderer = class {
401
452
  ${b.customCss || ""}
402
453
  `;
403
454
  }
404
- attachEvents(view) {
455
+ // ── Event binding ──
456
+ /** Attach events to shell elements (backdrop, ESC) — called once */
457
+ attachShellEvents() {
458
+ if (!this.shadowRoot) return;
459
+ const backdrop = this.shadowRoot.getElementById("backdrop");
460
+ if (backdrop) {
461
+ backdrop.addEventListener("click", () => this.onClose());
462
+ }
463
+ if (this.escHandler) {
464
+ document.removeEventListener("keydown", this.escHandler);
465
+ }
466
+ if (this.mode === "popup") {
467
+ this.escHandler = (e) => {
468
+ if (e.key === "Escape") this.onClose();
469
+ };
470
+ document.addEventListener("keydown", this.escHandler);
471
+ }
472
+ }
473
+ /** Attach events to inner content (buttons, form, switch link) — called on each view */
474
+ attachInnerEvents(view) {
405
475
  if (!this.shadowRoot) return;
406
476
  this.shadowRoot.querySelectorAll(".provider-btn").forEach((btn) => {
407
477
  btn.addEventListener("click", () => {
@@ -421,6 +491,12 @@ var ModalRenderer = class {
421
491
  );
422
492
  });
423
493
  }
494
+ const backBtn = this.shadowRoot.getElementById("back-btn");
495
+ if (backBtn) {
496
+ backBtn.addEventListener("click", () => {
497
+ this.open("signIn");
498
+ });
499
+ }
424
500
  const switchLink = this.shadowRoot.getElementById("switch-link");
425
501
  if (switchLink) {
426
502
  switchLink.addEventListener("click", (e) => {
@@ -428,20 +504,6 @@ var ModalRenderer = class {
428
504
  this.open(view === "signIn" ? "signUp" : "signIn");
429
505
  });
430
506
  }
431
- const backdrop = this.shadowRoot.getElementById("backdrop");
432
- if (backdrop) {
433
- backdrop.addEventListener("click", () => this.onClose());
434
- }
435
- if (this.escHandler) {
436
- document.removeEventListener("keydown", this.escHandler);
437
- this.escHandler = null;
438
- }
439
- if (this.mode === "popup") {
440
- this.escHandler = (e) => {
441
- if (e.key === "Escape") this.onClose();
442
- };
443
- document.addEventListener("keydown", this.escHandler);
444
- }
445
507
  }
446
508
  };
447
509
 
@@ -636,8 +698,7 @@ var Authon = class {
636
698
  const promise = isSignUp ? this.signUpWithEmail(email, password) : this.signInWithEmail(email, password);
637
699
  promise.then(() => this.modal?.close()).catch((err) => {
638
700
  const msg = err instanceof Error ? err.message : String(err);
639
- const friendlyMsg = msg.includes("401") ? "Invalid email or password" : msg.includes("409") ? "Email already in use" : msg.includes("400") ? "Please check your input" : msg || "Authentication failed";
640
- this.modal?.showError(friendlyMsg);
701
+ this.modal?.showError(msg || "Authentication failed");
641
702
  this.emit("error", err instanceof Error ? err : new Error(msg));
642
703
  });
643
704
  },
@@ -651,7 +712,7 @@ var Authon = class {
651
712
  async startOAuthFlow(provider) {
652
713
  try {
653
714
  const redirectUri = `${this.config.apiUrl}/v1/auth/oauth/redirect`;
654
- const { url } = await this.apiGet(
715
+ const { url, state } = await this.apiGet(
655
716
  `/v1/auth/oauth/${provider}/url?redirectUri=${encodeURIComponent(redirectUri)}`
656
717
  );
657
718
  this.modal?.showLoading();
@@ -673,59 +734,80 @@ var Authon = class {
673
734
  this.emit("error", new Error("Popup was blocked by the browser"));
674
735
  return;
675
736
  }
676
- let callbackReceived = false;
737
+ let resolved = false;
677
738
  let cleaned = false;
739
+ const resolve = (tokens) => {
740
+ if (resolved) return;
741
+ resolved = true;
742
+ cleanup();
743
+ try {
744
+ if (!popup.closed) popup.close();
745
+ } catch {
746
+ }
747
+ this.session.setSession(tokens);
748
+ this.modal?.close();
749
+ this.emit("signedIn", tokens.user);
750
+ };
751
+ const handleError = (msg) => {
752
+ if (resolved) return;
753
+ cleanup();
754
+ this.modal?.hideLoading();
755
+ this.modal?.showError(msg);
756
+ this.emit("error", new Error(msg));
757
+ };
678
758
  const cleanup = () => {
679
759
  if (cleaned) return;
680
760
  cleaned = true;
681
- window.removeEventListener("message", handler);
682
- if (pollTimer) clearInterval(pollTimer);
761
+ window.removeEventListener("message", messageHandler);
762
+ if (apiPollTimer) clearInterval(apiPollTimer);
763
+ if (closePollTimer) clearInterval(closePollTimer);
683
764
  if (maxTimer) clearTimeout(maxTimer);
684
765
  };
685
- const pollTimer = setInterval(() => {
686
- if (callbackReceived || cleaned) return;
766
+ const messageHandler = (e) => {
767
+ if (e.data?.type !== "authon-oauth-callback") return;
768
+ if (e.data.tokens) {
769
+ resolve(e.data.tokens);
770
+ }
771
+ };
772
+ window.addEventListener("message", messageHandler);
773
+ const apiPollTimer = setInterval(async () => {
774
+ if (resolved || cleaned) return;
775
+ try {
776
+ const result = await this.apiGet(
777
+ `/v1/auth/oauth/poll?state=${encodeURIComponent(state)}`
778
+ );
779
+ if (result.status === "completed" && result.accessToken) {
780
+ resolve({
781
+ accessToken: result.accessToken,
782
+ refreshToken: result.refreshToken,
783
+ expiresIn: result.expiresIn,
784
+ user: result.user
785
+ });
786
+ } else if (result.status === "error") {
787
+ handleError(result.message || "Authentication failed");
788
+ }
789
+ } catch {
790
+ }
791
+ }, 1500);
792
+ const closePollTimer = setInterval(() => {
793
+ if (resolved || cleaned) return;
687
794
  try {
688
795
  if (popup.closed) {
796
+ clearInterval(closePollTimer);
689
797
  setTimeout(() => {
690
- if (callbackReceived || cleaned) return;
798
+ if (resolved || cleaned) return;
691
799
  cleanup();
692
800
  this.modal?.hideLoading();
693
- }, 500);
694
- clearInterval(pollTimer);
801
+ }, 3e3);
695
802
  }
696
803
  } catch {
697
804
  }
698
805
  }, 500);
699
806
  const maxTimer = setTimeout(() => {
700
- if (callbackReceived || cleaned) return;
807
+ if (resolved || cleaned) return;
701
808
  cleanup();
702
809
  this.modal?.hideLoading();
703
810
  }, 18e4);
704
- const handler = async (e) => {
705
- if (e.data?.type !== "authon-oauth-callback") return;
706
- callbackReceived = true;
707
- cleanup();
708
- try {
709
- if (!popup.closed) popup.close();
710
- } catch {
711
- }
712
- try {
713
- const tokens = await this.apiPost("/v1/auth/oauth/callback", {
714
- code: e.data.code,
715
- state: e.data.state,
716
- provider
717
- });
718
- this.session.setSession(tokens);
719
- this.modal?.close();
720
- this.emit("signedIn", tokens.user);
721
- } catch (err) {
722
- this.modal?.hideLoading();
723
- const msg = err instanceof Error ? err.message : String(err);
724
- this.modal?.showError(msg.includes("500") ? "Authentication failed. Please try again." : msg);
725
- this.emit("error", err instanceof Error ? err : new Error(msg));
726
- }
727
- };
728
- window.addEventListener("message", handler);
729
811
  } catch (err) {
730
812
  this.modal?.hideLoading();
731
813
  this.emit("error", err instanceof Error ? err : new Error(String(err)));
@@ -736,7 +818,7 @@ var Authon = class {
736
818
  headers: { "x-api-key": this.publishableKey },
737
819
  credentials: "include"
738
820
  });
739
- if (!res.ok) throw new Error(`API ${path}: ${res.status}`);
821
+ if (!res.ok) throw new Error(await this.parseApiError(res, path));
740
822
  return res.json();
741
823
  }
742
824
  async apiPost(path, body) {
@@ -749,9 +831,22 @@ var Authon = class {
749
831
  credentials: "include",
750
832
  body: body ? JSON.stringify(body) : void 0
751
833
  });
752
- if (!res.ok) throw new Error(`API ${path}: ${res.status}`);
834
+ if (!res.ok) throw new Error(await this.parseApiError(res, path));
753
835
  return res.json();
754
836
  }
837
+ async parseApiError(res, path) {
838
+ try {
839
+ const body = await res.json();
840
+ if (Array.isArray(body.message) && body.message.length > 0) {
841
+ return body.message[0];
842
+ }
843
+ if (typeof body.message === "string" && body.message !== "Bad Request") {
844
+ return body.message;
845
+ }
846
+ } catch {
847
+ }
848
+ return `API ${path}: ${res.status}`;
849
+ }
755
850
  };
756
851
  // Annotate the CommonJS export names for ESM import in node:
757
852
  0 && (module.exports = {
package/dist/index.mjs CHANGED
@@ -70,6 +70,7 @@ var ModalRenderer = class {
70
70
  theme;
71
71
  branding;
72
72
  enabledProviders = [];
73
+ currentView = "signIn";
73
74
  onProviderClick;
74
75
  onEmailSubmit;
75
76
  onClose;
@@ -93,9 +94,9 @@ var ModalRenderer = class {
93
94
  }
94
95
  open(view = "signIn") {
95
96
  if (this.shadowRoot && this.hostElement) {
96
- this.shadowRoot.innerHTML = this.buildHTML(view);
97
- this.attachEvents(view);
97
+ this.switchView(view);
98
98
  } else {
99
+ this.currentView = view;
99
100
  this.render(view);
100
101
  }
101
102
  }
@@ -128,13 +129,13 @@ var ModalRenderer = class {
128
129
  showBanner(message, type = "error") {
129
130
  if (!this.shadowRoot) return;
130
131
  this.clearBanner();
131
- const container = this.shadowRoot.querySelector(".modal-container");
132
- if (!container) return;
132
+ const inner = this.shadowRoot.getElementById("modal-inner");
133
+ if (!inner) return;
133
134
  const banner = document.createElement("div");
134
135
  banner.id = "authon-banner";
135
136
  banner.className = type === "warning" ? "banner-warning" : "error-msg";
136
137
  banner.textContent = message;
137
- container.insertBefore(banner, container.firstChild);
138
+ inner.insertBefore(banner, inner.firstChild);
138
139
  }
139
140
  clearBanner() {
140
141
  if (!this.shadowRoot) return;
@@ -163,6 +164,24 @@ var ModalRenderer = class {
163
164
  if (!this.shadowRoot) return;
164
165
  this.shadowRoot.getElementById("authon-loading-overlay")?.remove();
165
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 ──
166
185
  render(view) {
167
186
  const host = document.createElement("div");
168
187
  host.setAttribute("data-authon-modal", "");
@@ -173,30 +192,48 @@ var ModalRenderer = class {
173
192
  this.containerElement.appendChild(host);
174
193
  }
175
194
  this.shadowRoot = host.attachShadow({ mode: "open" });
176
- this.shadowRoot.innerHTML = this.buildHTML(view);
177
- 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
+ `;
178
212
  }
179
- buildHTML(view) {
213
+ /** Inner content = everything inside the modal that changes per view */
214
+ buildInnerContent(view) {
180
215
  const b = this.branding;
181
216
  const isSignUp = view === "signUp";
182
217
  const title = isSignUp ? "Create your account" : "Welcome back";
183
218
  const subtitle = isSignUp ? "Already have an account?" : "Don't have an account?";
184
219
  const subtitleLink = isSignUp ? "Sign in" : "Sign up";
185
220
  const dark = this.isDark();
186
- const providerButtons = this.enabledProviders.filter((p) => !b.hiddenProviders?.includes(p)).map((p) => {
221
+ const showProviders = !isSignUp;
222
+ const providerButtons = showProviders ? this.enabledProviders.filter((p) => !b.hiddenProviders?.includes(p)).map((p) => {
187
223
  const config = getProviderButtonConfig(p);
188
224
  const isWhiteBg = config.bgColor === "#ffffff";
189
225
  const btnBg = dark && isWhiteBg ? "#f8fafc" : config.bgColor;
190
226
  const btnBorder = isWhiteBg ? dark ? "#475569" : "#e5e7eb" : config.bgColor;
191
227
  return `<button class="provider-btn" data-provider="${p}" style="background:${btnBg};color:${config.textColor};border:1px solid ${btnBorder}">
192
- <span class="provider-icon">${config.iconSvg}</span>
193
- <span>${config.label}</span>
194
- </button>`;
195
- }).join("");
196
- const divider = b.showDivider !== false && b.showEmailPassword !== false ? `<div class="divider"><span>or</span></div>` : "";
228
+ <span class="provider-icon">${config.iconSvg}</span>
229
+ <span>${config.label}</span>
230
+ </button>`;
231
+ }).join("") : "";
232
+ const divider = showProviders && b.showDivider !== false && b.showEmailPassword !== false ? `<div class="divider"><span>or</span></div>` : "";
197
233
  const emailForm = b.showEmailPassword !== false ? `<form class="email-form" id="email-form">
198
234
  <input type="email" placeholder="Email address" name="email" required class="input" autocomplete="email" />
199
235
  <input type="password" placeholder="Password" name="password" required class="input" autocomplete="${isSignUp ? "new-password" : "current-password"}" />
236
+ ${isSignUp ? '<p class="password-hint">Must contain uppercase, lowercase, and a number (min 8 chars)</p>' : ""}
200
237
  <button type="submit" class="submit-btn">${isSignUp ? "Sign up" : "Sign in"}</button>
201
238
  </form>` : "";
202
239
  const footer = b.termsUrl || b.privacyUrl ? `<div class="footer">
@@ -204,21 +241,22 @@ var ModalRenderer = class {
204
241
  ${b.termsUrl && b.privacyUrl ? " \xB7 " : ""}
205
242
  ${b.privacyUrl ? `<a href="${b.privacyUrl}" target="_blank">Privacy Policy</a>` : ""}
206
243
  </div>` : "";
207
- const popupWrapper = this.mode === "popup" ? `<div class="backdrop" id="backdrop"></div>` : "";
244
+ const titleHtml = isSignUp ? `<div class="title-row">
245
+ <button class="back-btn" id="back-btn" type="button" aria-label="Back to sign in">
246
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
247
+ </button>
248
+ <h2 class="title">${title}</h2>
249
+ </div>` : `<h2 class="title">${title}</h2>`;
208
250
  return `
209
- <style>${this.buildCSS()}</style>
210
- ${popupWrapper}
211
- <div class="modal-container" role="dialog" aria-modal="true">
212
- ${b.logoDataUrl ? `<img src="${b.logoDataUrl}" alt="Logo" class="logo" />` : ""}
213
- <h2 class="title">${title}</h2>
214
- ${b.brandName ? `<p class="brand-name">${b.brandName}</p>` : ""}
215
- <div class="providers">${providerButtons}</div>
216
- ${divider}
217
- ${emailForm}
218
- <p class="switch-view">${subtitle} <a href="#" id="switch-link">${subtitleLink}</a></p>
219
- ${footer}
220
- ${b.showSecuredBy !== false ? `<div class="secured-by">Secured by <span class="secured-brand">Authon</span></div>` : ""}
221
- </div>
251
+ ${b.logoDataUrl ? `<img src="${b.logoDataUrl}" alt="Logo" class="logo" />` : ""}
252
+ ${titleHtml}
253
+ ${b.brandName ? `<p class="brand-name">${b.brandName}</p>` : ""}
254
+ ${showProviders ? `<div class="providers">${providerButtons}</div>` : ""}
255
+ ${divider}
256
+ ${emailForm}
257
+ <p class="switch-view">${subtitle} <a href="#" id="switch-link">${subtitleLink}</a></p>
258
+ ${footer}
259
+ ${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>` : ""}
222
260
  `;
223
261
  }
224
262
  isDark() {
@@ -269,7 +307,20 @@ var ModalRenderer = class {
269
307
  position: ${this.mode === "popup" ? "fixed" : "relative"};
270
308
  ${this.mode === "popup" ? `box-shadow: 0 25px 50px -12px rgba(0,0,0,${dark ? "0.5" : "0.25"}); animation: slideIn 0.3s ease;` : ""}
271
309
  }
310
+ .modal-inner {
311
+ transition: opacity 0.14s ease, transform 0.14s ease;
312
+ }
272
313
  .logo { display: block; margin: 0 auto 16px; max-height: 48px; }
314
+ .title-row { display: flex; align-items: center; position: relative; margin-bottom: 8px; }
315
+ .title-row .title { flex: 1; margin-bottom: 0; }
316
+ .back-btn {
317
+ position: absolute; left: 0; top: 50%; transform: translateY(-50%);
318
+ background: none; border: none; color: var(--authon-muted);
319
+ cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center;
320
+ transition: color 0.15s, background 0.15s;
321
+ }
322
+ .back-btn:hover { color: var(--authon-text); background: var(--authon-divider); }
323
+ .password-hint { font-size: 11px; color: var(--authon-dim); margin: -4px 0 2px; }
273
324
  .title { text-align: center; font-size: 24px; font-weight: 700; margin-bottom: 8px; color: var(--authon-text); }
274
325
  .brand-name { text-align: center; font-size: 14px; color: var(--authon-muted); margin-bottom: 24px; }
275
326
  .providers { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
@@ -327,15 +378,15 @@ var ModalRenderer = class {
327
378
  .switch-view { text-align: center; margin-top: 16px; font-size: 13px; color: var(--authon-muted); }
328
379
  .switch-view a { color: var(--authon-primary-start); text-decoration: none; font-weight: 500; }
329
380
  .switch-view a:hover { text-decoration: underline; }
330
- .footer { text-align: center; margin-top: 16px; font-size: 12px; color: var(--authon-dim); }
381
+ .footer { text-align: center; margin-top: 12px; font-size: 12px; color: var(--authon-dim); }
331
382
  .footer a { color: var(--authon-dim); text-decoration: none; }
332
383
  .footer a:hover { text-decoration: underline; }
333
384
  .secured-by {
334
- text-align: center; margin-top: 20px; padding-top: 16px;
335
- border-top: 1px solid var(--authon-divider);
385
+ text-align: center; margin-top: 16px;
336
386
  font-size: 11px; color: var(--authon-dim);
337
387
  }
338
- .secured-brand { font-weight: 600; color: var(--authon-muted); }
388
+ .secured-link { font-weight: 600; color: var(--authon-muted); text-decoration: none; }
389
+ .secured-link:hover { text-decoration: underline; }
339
390
  /* Loading overlay */
340
391
  #authon-loading-overlay {
341
392
  position: absolute; inset: 0; z-index: 10;
@@ -374,7 +425,26 @@ var ModalRenderer = class {
374
425
  ${b.customCss || ""}
375
426
  `;
376
427
  }
377
- attachEvents(view) {
428
+ // ── Event binding ──
429
+ /** Attach events to shell elements (backdrop, ESC) — called once */
430
+ attachShellEvents() {
431
+ if (!this.shadowRoot) return;
432
+ const backdrop = this.shadowRoot.getElementById("backdrop");
433
+ if (backdrop) {
434
+ backdrop.addEventListener("click", () => this.onClose());
435
+ }
436
+ if (this.escHandler) {
437
+ document.removeEventListener("keydown", this.escHandler);
438
+ }
439
+ if (this.mode === "popup") {
440
+ this.escHandler = (e) => {
441
+ if (e.key === "Escape") this.onClose();
442
+ };
443
+ document.addEventListener("keydown", this.escHandler);
444
+ }
445
+ }
446
+ /** Attach events to inner content (buttons, form, switch link) — called on each view */
447
+ attachInnerEvents(view) {
378
448
  if (!this.shadowRoot) return;
379
449
  this.shadowRoot.querySelectorAll(".provider-btn").forEach((btn) => {
380
450
  btn.addEventListener("click", () => {
@@ -394,6 +464,12 @@ var ModalRenderer = class {
394
464
  );
395
465
  });
396
466
  }
467
+ const backBtn = this.shadowRoot.getElementById("back-btn");
468
+ if (backBtn) {
469
+ backBtn.addEventListener("click", () => {
470
+ this.open("signIn");
471
+ });
472
+ }
397
473
  const switchLink = this.shadowRoot.getElementById("switch-link");
398
474
  if (switchLink) {
399
475
  switchLink.addEventListener("click", (e) => {
@@ -401,20 +477,6 @@ var ModalRenderer = class {
401
477
  this.open(view === "signIn" ? "signUp" : "signIn");
402
478
  });
403
479
  }
404
- const backdrop = this.shadowRoot.getElementById("backdrop");
405
- if (backdrop) {
406
- backdrop.addEventListener("click", () => this.onClose());
407
- }
408
- if (this.escHandler) {
409
- document.removeEventListener("keydown", this.escHandler);
410
- this.escHandler = null;
411
- }
412
- if (this.mode === "popup") {
413
- this.escHandler = (e) => {
414
- if (e.key === "Escape") this.onClose();
415
- };
416
- document.addEventListener("keydown", this.escHandler);
417
- }
418
480
  }
419
481
  };
420
482
 
@@ -609,8 +671,7 @@ var Authon = class {
609
671
  const promise = isSignUp ? this.signUpWithEmail(email, password) : this.signInWithEmail(email, password);
610
672
  promise.then(() => this.modal?.close()).catch((err) => {
611
673
  const msg = err instanceof Error ? err.message : String(err);
612
- const friendlyMsg = msg.includes("401") ? "Invalid email or password" : msg.includes("409") ? "Email already in use" : msg.includes("400") ? "Please check your input" : msg || "Authentication failed";
613
- this.modal?.showError(friendlyMsg);
674
+ this.modal?.showError(msg || "Authentication failed");
614
675
  this.emit("error", err instanceof Error ? err : new Error(msg));
615
676
  });
616
677
  },
@@ -624,7 +685,7 @@ var Authon = class {
624
685
  async startOAuthFlow(provider) {
625
686
  try {
626
687
  const redirectUri = `${this.config.apiUrl}/v1/auth/oauth/redirect`;
627
- const { url } = await this.apiGet(
688
+ const { url, state } = await this.apiGet(
628
689
  `/v1/auth/oauth/${provider}/url?redirectUri=${encodeURIComponent(redirectUri)}`
629
690
  );
630
691
  this.modal?.showLoading();
@@ -646,59 +707,80 @@ var Authon = class {
646
707
  this.emit("error", new Error("Popup was blocked by the browser"));
647
708
  return;
648
709
  }
649
- let callbackReceived = false;
710
+ let resolved = false;
650
711
  let cleaned = false;
712
+ const resolve = (tokens) => {
713
+ if (resolved) return;
714
+ resolved = true;
715
+ cleanup();
716
+ try {
717
+ if (!popup.closed) popup.close();
718
+ } catch {
719
+ }
720
+ this.session.setSession(tokens);
721
+ this.modal?.close();
722
+ this.emit("signedIn", tokens.user);
723
+ };
724
+ const handleError = (msg) => {
725
+ if (resolved) return;
726
+ cleanup();
727
+ this.modal?.hideLoading();
728
+ this.modal?.showError(msg);
729
+ this.emit("error", new Error(msg));
730
+ };
651
731
  const cleanup = () => {
652
732
  if (cleaned) return;
653
733
  cleaned = true;
654
- window.removeEventListener("message", handler);
655
- if (pollTimer) clearInterval(pollTimer);
734
+ window.removeEventListener("message", messageHandler);
735
+ if (apiPollTimer) clearInterval(apiPollTimer);
736
+ if (closePollTimer) clearInterval(closePollTimer);
656
737
  if (maxTimer) clearTimeout(maxTimer);
657
738
  };
658
- const pollTimer = setInterval(() => {
659
- if (callbackReceived || cleaned) return;
739
+ const messageHandler = (e) => {
740
+ if (e.data?.type !== "authon-oauth-callback") return;
741
+ if (e.data.tokens) {
742
+ resolve(e.data.tokens);
743
+ }
744
+ };
745
+ window.addEventListener("message", messageHandler);
746
+ const apiPollTimer = setInterval(async () => {
747
+ if (resolved || cleaned) return;
748
+ try {
749
+ const result = await this.apiGet(
750
+ `/v1/auth/oauth/poll?state=${encodeURIComponent(state)}`
751
+ );
752
+ if (result.status === "completed" && result.accessToken) {
753
+ resolve({
754
+ accessToken: result.accessToken,
755
+ refreshToken: result.refreshToken,
756
+ expiresIn: result.expiresIn,
757
+ user: result.user
758
+ });
759
+ } else if (result.status === "error") {
760
+ handleError(result.message || "Authentication failed");
761
+ }
762
+ } catch {
763
+ }
764
+ }, 1500);
765
+ const closePollTimer = setInterval(() => {
766
+ if (resolved || cleaned) return;
660
767
  try {
661
768
  if (popup.closed) {
769
+ clearInterval(closePollTimer);
662
770
  setTimeout(() => {
663
- if (callbackReceived || cleaned) return;
771
+ if (resolved || cleaned) return;
664
772
  cleanup();
665
773
  this.modal?.hideLoading();
666
- }, 500);
667
- clearInterval(pollTimer);
774
+ }, 3e3);
668
775
  }
669
776
  } catch {
670
777
  }
671
778
  }, 500);
672
779
  const maxTimer = setTimeout(() => {
673
- if (callbackReceived || cleaned) return;
780
+ if (resolved || cleaned) return;
674
781
  cleanup();
675
782
  this.modal?.hideLoading();
676
783
  }, 18e4);
677
- const handler = async (e) => {
678
- if (e.data?.type !== "authon-oauth-callback") return;
679
- callbackReceived = true;
680
- cleanup();
681
- try {
682
- if (!popup.closed) popup.close();
683
- } catch {
684
- }
685
- try {
686
- const tokens = await this.apiPost("/v1/auth/oauth/callback", {
687
- code: e.data.code,
688
- state: e.data.state,
689
- provider
690
- });
691
- this.session.setSession(tokens);
692
- this.modal?.close();
693
- this.emit("signedIn", tokens.user);
694
- } catch (err) {
695
- this.modal?.hideLoading();
696
- const msg = err instanceof Error ? err.message : String(err);
697
- this.modal?.showError(msg.includes("500") ? "Authentication failed. Please try again." : msg);
698
- this.emit("error", err instanceof Error ? err : new Error(msg));
699
- }
700
- };
701
- window.addEventListener("message", handler);
702
784
  } catch (err) {
703
785
  this.modal?.hideLoading();
704
786
  this.emit("error", err instanceof Error ? err : new Error(String(err)));
@@ -709,7 +791,7 @@ var Authon = class {
709
791
  headers: { "x-api-key": this.publishableKey },
710
792
  credentials: "include"
711
793
  });
712
- if (!res.ok) throw new Error(`API ${path}: ${res.status}`);
794
+ if (!res.ok) throw new Error(await this.parseApiError(res, path));
713
795
  return res.json();
714
796
  }
715
797
  async apiPost(path, body) {
@@ -722,9 +804,22 @@ var Authon = class {
722
804
  credentials: "include",
723
805
  body: body ? JSON.stringify(body) : void 0
724
806
  });
725
- if (!res.ok) throw new Error(`API ${path}: ${res.status}`);
807
+ if (!res.ok) throw new Error(await this.parseApiError(res, path));
726
808
  return res.json();
727
809
  }
810
+ async parseApiError(res, path) {
811
+ try {
812
+ const body = await res.json();
813
+ if (Array.isArray(body.message) && body.message.length > 0) {
814
+ return body.message[0];
815
+ }
816
+ if (typeof body.message === "string" && body.message !== "Bad Request") {
817
+ return body.message;
818
+ }
819
+ } catch {
820
+ }
821
+ return `API ${path}: ${res.status}`;
822
+ }
728
823
  };
729
824
  export {
730
825
  Authon,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authon/js",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Authon core SDK — ShadowDOM login modal for any app",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",