@authhero/widget 0.9.0 → 0.11.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.
@@ -288,6 +288,139 @@ function applyCssVars(element, vars) {
288
288
  });
289
289
  }
290
290
 
291
+ /**
292
+ * Sanitize HTML to only allow safe formatting tags
293
+ *
294
+ * Allowed tags:
295
+ * - <br>, <br/> - Line breaks
296
+ * - <em>, <i> - Italic
297
+ * - <strong>, <b> - Bold
298
+ * - <u> - Underline
299
+ * - <span> - Generic inline container (for styling)
300
+ * - <a> - Links (href attribute only, with target="_blank" and rel="noopener")
301
+ *
302
+ * All other tags and attributes are stripped.
303
+ */
304
+ // Allowed tags and their allowed attributes
305
+ const ALLOWED_TAGS = {
306
+ br: [],
307
+ em: [],
308
+ i: [],
309
+ strong: [],
310
+ b: [],
311
+ u: [],
312
+ span: ["class"],
313
+ a: ["href", "class"],
314
+ };
315
+ /**
316
+ * Sanitize HTML string to only allow safe formatting tags
317
+ *
318
+ * @param html - The HTML string to sanitize
319
+ * @returns Sanitized HTML string safe for innerHTML
320
+ */
321
+ function sanitizeHtml(html) {
322
+ if (!html)
323
+ return "";
324
+ // If no < character present, return as-is (optimization)
325
+ // Must check for any < to prevent bypassing sanitization with malformed tags
326
+ // like "<img src=x onerror=..." which forgiving HTML parsers may still execute
327
+ if (!html.includes("<")) {
328
+ return html;
329
+ }
330
+ // Use a simple regex-based approach that's safe for our limited use case
331
+ // This avoids needing DOMParser which may not be available in all environments
332
+ let result = html;
333
+ // First, escape all HTML
334
+ result = result
335
+ .replace(/&/g, "&amp;")
336
+ .replace(/</g, "&lt;")
337
+ .replace(/>/g, "&gt;")
338
+ .replace(/"/g, "&quot;")
339
+ .replace(/'/g, "&#39;");
340
+ // Then selectively re-enable allowed tags
341
+ for (const [tag, allowedAttrs] of Object.entries(ALLOWED_TAGS)) {
342
+ // Self-closing tags (like <br> and <br/>)
343
+ if (tag === "br") {
344
+ result = result.replace(/&lt;br\s*\/?&gt;/gi, "<br>");
345
+ continue;
346
+ }
347
+ // Opening tags with optional attributes
348
+ const openingPattern = new RegExp(`&lt;${tag}((?:\\s+[a-z-]+(?:=&quot;[^&]*&quot;|=&#39;[^&]*&#39;)?)*)\\s*&gt;`, "gi");
349
+ result = result.replace(openingPattern, (_match, attrsStr) => {
350
+ // Parse and filter attributes
351
+ const filteredAttrs = [];
352
+ if (attrsStr) {
353
+ // Unescape the attributes string for parsing
354
+ const unescapedAttrs = attrsStr
355
+ .replace(/&quot;/g, '"')
356
+ .replace(/&#39;/g, "'")
357
+ .replace(/&amp;/g, "&")
358
+ .replace(/&lt;/g, "<")
359
+ .replace(/&gt;/g, ">");
360
+ // Extract attributes
361
+ const attrPattern = /([a-z-]+)=["']([^"']*)["']/gi;
362
+ let attrMatch;
363
+ while ((attrMatch = attrPattern.exec(unescapedAttrs)) !== null) {
364
+ const [, attrName, attrValue] = attrMatch;
365
+ if (attrName && allowedAttrs.includes(attrName.toLowerCase())) {
366
+ // For href, validate it's a safe URL
367
+ if (attrName.toLowerCase() === "href") {
368
+ if (isSafeUrl(attrValue || "")) {
369
+ filteredAttrs.push(`${attrName}="${escapeAttr(attrValue || "")}"`);
370
+ }
371
+ }
372
+ else {
373
+ filteredAttrs.push(`${attrName}="${escapeAttr(attrValue || "")}"`);
374
+ }
375
+ }
376
+ }
377
+ }
378
+ // For <a> tags, always add security attributes
379
+ if (tag === "a") {
380
+ filteredAttrs.push('target="_blank"');
381
+ filteredAttrs.push('rel="noopener noreferrer"');
382
+ }
383
+ const attrsOutput = filteredAttrs.length
384
+ ? " " + filteredAttrs.join(" ")
385
+ : "";
386
+ return `<${tag}${attrsOutput}>`;
387
+ });
388
+ // Closing tags
389
+ const closingPattern = new RegExp(`&lt;/${tag}&gt;`, "gi");
390
+ result = result.replace(closingPattern, `</${tag}>`);
391
+ }
392
+ return result;
393
+ }
394
+ /**
395
+ * Check if a URL is safe (http, https, or relative)
396
+ */
397
+ function isSafeUrl(url) {
398
+ if (!url)
399
+ return false;
400
+ // Allow relative URLs
401
+ if (url.startsWith("/") || url.startsWith("#") || url.startsWith("?")) {
402
+ return true;
403
+ }
404
+ // Allow http and https
405
+ try {
406
+ const parsed = new URL(url, "https://example.com");
407
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
408
+ }
409
+ catch {
410
+ return false;
411
+ }
412
+ }
413
+ /**
414
+ * Escape attribute value
415
+ */
416
+ function escapeAttr(value) {
417
+ return value
418
+ .replace(/&/g, "&amp;")
419
+ .replace(/"/g, "&quot;")
420
+ .replace(/</g, "&lt;")
421
+ .replace(/>/g, "&gt;");
422
+ }
423
+
291
424
  const authheroWidgetCss = () => `:host{display:block;font-family:var(--ah-font-family, 'ulp-font', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, sans-serif);font-size:var(--ah-font-size-base, 14px);line-height:var(--ah-line-height-base, 1.5);color:var(--ah-color-text, #1e212a);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.widget-container{max-width:var(--ah-widget-max-width, 400px);width:100%;margin:0 auto;background-color:var(--ah-color-bg, #ffffff);border-radius:var(--ah-widget-radius, 5px);box-shadow:var(--ah-widget-shadow, 0 4px 22px 0 rgba(0, 0, 0, 0.11));box-sizing:border-box}.widget-header{padding:var(--ah-header-padding, 40px 48px 24px)}.widget-body{padding:var(--ah-body-padding, 0 48px 40px)}.logo-wrapper{display:var(--ah-logo-display, flex);justify-content:var(--ah-logo-align, center);margin-bottom:8px}.logo{display:block;height:var(--ah-logo-height, 52px);max-width:100%;width:auto;object-fit:contain}.title{font-size:var(--ah-font-size-title, 24px);font-weight:var(--ah-font-weight-title, 700);text-align:var(--ah-title-align, center);margin:var(--ah-title-margin, 24px 0 8px);color:var(--ah-color-header, #1e212a);line-height:1.2}.description{font-size:var(--ah-font-size-description, 14px);text-align:var(--ah-title-align, center);margin:var(--ah-description-margin, 0 0 8px);color:var(--ah-color-text, #1e212a);line-height:1.5}.message{padding:12px 16px;border-radius:4px;margin-bottom:16px;font-size:14px;line-height:1.5}.message-error{background-color:var(--ah-color-error-bg, #ffeaea);color:var(--ah-color-error, #d03c38);border-left:3px solid var(--ah-color-error, #d03c38)}.message-success{background-color:var(--ah-color-success-bg, #e6f9f1);color:var(--ah-color-success, #13a769);border-left:3px solid var(--ah-color-success, #13a769)}form{display:flex;flex-direction:column}.form-content{display:flex;flex-direction:column}.social-section{display:flex;flex-direction:column;gap:8px;order:var(--ah-social-order, 2)}.fields-section{display:flex;flex-direction:column;order:var(--ah-fields-order, 0)}.divider{display:flex;align-items:center;text-align:center;margin:16px 0;order:var(--ah-divider-order, 1)}.divider::before,.divider::after{content:'';flex:1;border-bottom:1px solid var(--ah-color-border-muted, #c9cace)}.divider-text{padding:0 10px;font-size:12px;font-weight:400;color:var(--ah-color-text-muted, #65676e);text-transform:uppercase;letter-spacing:0}.links{display:flex;flex-direction:column;align-items:center;gap:8px;margin-top:16px}.link-wrapper{font-size:14px;color:var(--ah-color-text, #1e212a)}.link{color:var(--ah-color-link, #635dff);text-decoration:var(--ah-link-decoration, none);font-size:14px;font-weight:var(--ah-font-weight-link, 400);transition:color 150ms ease}.link:hover{text-decoration:underline}.link:focus-visible{outline:2px solid var(--ah-color-link, #635dff);outline-offset:2px;border-radius:2px}.loading-spinner{width:32px;height:32px;margin:24px auto;border:3px solid var(--ah-color-border-muted, #e0e1e3);border-top-color:var(--ah-color-primary, #635dff);border-radius:50%;animation:spin 0.8s linear infinite}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}.error-message{text-align:center;color:var(--ah-color-error, #d03c38);padding:16px;font-size:14px}@media (max-width: 480px){:host{display:block;width:100%;min-height:100vh;background-color:var(--ah-color-bg, #ffffff)}.widget-container{box-shadow:none;border-radius:0;max-width:none;width:100%;margin:0}.widget-header{padding:24px 16px 16px}.widget-body{padding:0 16px 24px}}`;
292
425
 
293
426
  const AuthheroWidget = class {
@@ -713,7 +846,12 @@ const AuthheroWidget = class {
713
846
  if (result.screenId) {
714
847
  this.screenId = result.screenId;
715
848
  }
849
+ // Persist state (especially for session storage mode)
716
850
  this.persistState();
851
+ // Update URL path if navigateUrl is provided (client-side navigation)
852
+ if (result.navigateUrl && this.shouldAutoNavigate) {
853
+ window.history.pushState({ screen: result.screenId, state: this.state }, "", result.navigateUrl);
854
+ }
717
855
  // Apply branding if included
718
856
  if (result.branding) {
719
857
  this._branding = result.branding;
@@ -886,7 +1024,7 @@ const AuthheroWidget = class {
886
1024
  const hasDivider = components.some((c) => this.isDividerComponent(c));
887
1025
  // Get logo URL from theme.widget (takes precedence) or branding
888
1026
  const logoUrl = this._theme?.widget?.logo_url || this._branding?.logo_url;
889
- return (h("div", { class: "widget-container", part: "container" }, h("header", { class: "widget-header", part: "header" }, logoUrl && (h("div", { class: "logo-wrapper", part: "logo-wrapper" }, h("img", { class: "logo", part: "logo", src: logoUrl, alt: "Logo" }))), this._screen.title && (h("h1", { class: "title", part: "title" }, this._screen.title)), this._screen.description && (h("p", { class: "description", part: "description" }, this._screen.description))), h("div", { class: "widget-body", part: "body" }, screenErrors.map((err) => (h("div", { class: "message message-error", part: "message message-error", key: err.id ?? err.text }, err.text))), screenSuccesses.map((msg) => (h("div", { class: "message message-success", part: "message message-success", key: msg.id ?? msg.text }, msg.text))), h("form", { onSubmit: this.handleSubmit, part: "form" }, h("div", { class: "form-content" }, socialComponents.length > 0 && (h("div", { class: "social-section", part: "social-section" }, socialComponents.map((component) => (h("authhero-node", { key: component.id, component: component, value: this.formData[component.id], onFieldChange: (e) => this.handleInputChange(e.detail.id, e.detail.value), onButtonClick: (e) => this.handleButtonClick(e.detail), disabled: this.loading }))))), socialComponents.length > 0 &&
1027
+ return (h("div", { class: "widget-container", part: "container" }, h("header", { class: "widget-header", part: "header" }, logoUrl && (h("div", { class: "logo-wrapper", part: "logo-wrapper" }, h("img", { class: "logo", part: "logo", src: logoUrl, alt: "Logo" }))), this._screen.title && (h("h1", { class: "title", part: "title", innerHTML: sanitizeHtml(this._screen.title) })), this._screen.description && (h("p", { class: "description", part: "description", innerHTML: sanitizeHtml(this._screen.description) }))), h("div", { class: "widget-body", part: "body" }, screenErrors.map((err) => (h("div", { class: "message message-error", part: "message message-error", key: err.id ?? err.text }, err.text))), screenSuccesses.map((msg) => (h("div", { class: "message message-success", part: "message message-success", key: msg.id ?? msg.text }, msg.text))), h("form", { onSubmit: this.handleSubmit, part: "form" }, h("div", { class: "form-content" }, socialComponents.length > 0 && (h("div", { class: "social-section", part: "social-section" }, socialComponents.map((component) => (h("authhero-node", { key: component.id, component: component, value: this.formData[component.id], onFieldChange: (e) => this.handleInputChange(e.detail.id, e.detail.value), onButtonClick: (e) => this.handleButtonClick(e.detail), disabled: this.loading }))))), socialComponents.length > 0 &&
890
1028
  fieldComponents.length > 0 &&
891
1029
  hasDivider && (h("div", { class: "divider", part: "divider" }, h("span", { class: "divider-text" }, "Or"))), h("div", { class: "fields-section", part: "fields-section" }, fieldComponents.map((component) => (h("authhero-node", { key: component.id, component: component, value: this.formData[component.id], onFieldChange: (e) => this.handleInputChange(e.detail.id, e.detail.value), onButtonClick: (e) => this.handleButtonClick(e.detail), disabled: this.loading })))))), this._screen.links && this._screen.links.length > 0 && (h("div", { class: "links", part: "links" }, this._screen.links.map((link) => (h("span", { class: "link-wrapper", part: "link-wrapper", key: link.id ?? link.href }, link.linkText ? (h("span", null, link.text, " ", h("a", { href: link.href, class: "link", part: "link", onClick: (e) => this.handleLinkClick(e, {
892
1030
  id: link.id,
@@ -35,6 +35,11 @@ export declare class AuthheroNode {
35
35
  }>;
36
36
  private handleInput;
37
37
  private handleCheckbox;
38
+ /**
39
+ * Sanitize a string for use in CSS class names and part tokens.
40
+ * Replaces spaces and special characters with hyphens, converts to lowercase.
41
+ */
42
+ private sanitizeForCssToken;
38
43
  private handleButtonClick;
39
44
  private togglePasswordVisibility;
40
45
  /**
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Sanitize HTML to only allow safe formatting tags
3
+ *
4
+ * Allowed tags:
5
+ * - <br>, <br/> - Line breaks
6
+ * - <em>, <i> - Italic
7
+ * - <strong>, <b> - Bold
8
+ * - <u> - Underline
9
+ * - <span> - Generic inline container (for styling)
10
+ * - <a> - Links (href attribute only, with target="_blank" and rel="noopener")
11
+ *
12
+ * All other tags and attributes are stripped.
13
+ */
14
+ /**
15
+ * Sanitize HTML string to only allow safe formatting tags
16
+ *
17
+ * @param html - The HTML string to sanitize
18
+ * @returns Sanitized HTML string safe for innerHTML
19
+ */
20
+ export declare function sanitizeHtml(html: string | undefined | null): string;
package/hydrate/index.js CHANGED
@@ -5015,6 +5015,17 @@ class AuthheroNode {
5015
5015
  value: target.checked ? "true" : "false",
5016
5016
  });
5017
5017
  };
5018
+ /**
5019
+ * Sanitize a string for use in CSS class names and part tokens.
5020
+ * Replaces spaces and special characters with hyphens, converts to lowercase.
5021
+ */
5022
+ sanitizeForCssToken(value) {
5023
+ return value
5024
+ .toLowerCase()
5025
+ .replace(/[^a-z0-9-]/g, "-") // Replace non-alphanumeric chars with hyphen
5026
+ .replace(/-+/g, "-") // Collapse multiple hyphens
5027
+ .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
5028
+ }
5018
5029
  handleButtonClick = (e, type, value) => {
5019
5030
  if (type !== "submit") {
5020
5031
  e.preventDefault();
@@ -5177,113 +5188,32 @@ class AuthheroNode {
5177
5188
  const providerDetails = component.config?.provider_details;
5178
5189
  // Create a map of provider details for quick lookup
5179
5190
  const detailsMap = new Map(providerDetails?.map((d) => [d.name, d]) ?? []);
5180
- // Known provider identifiers for icon matching
5181
- const knownProviders = [
5182
- "google-oauth2",
5183
- "google",
5184
- "facebook",
5185
- "apple",
5186
- "github",
5187
- "microsoft",
5188
- "windowslive",
5189
- "linkedin",
5190
- "vipps",
5191
- ];
5192
- // Find matching known provider from name or strategy
5193
- const findKnownProvider = (name, strategy) => {
5194
- const nameLower = name.toLowerCase();
5195
- const strategyLower = strategy?.toLowerCase();
5196
- // First check exact match on strategy
5197
- if (strategyLower && knownProviders.includes(strategyLower)) {
5198
- return strategyLower;
5199
- }
5200
- // Then check exact match on name
5201
- if (knownProviders.includes(nameLower)) {
5202
- return nameLower;
5203
- }
5204
- // Check if name contains a known provider (e.g., "Vipps Login" contains "vipps")
5205
- for (const known of knownProviders) {
5206
- if (nameLower.includes(known)) {
5207
- return known;
5208
- }
5209
- }
5210
- return null;
5211
- };
5212
- // Map provider IDs to display names
5213
- const getProviderDisplayName = (provider) => {
5214
- // First check provider_details
5191
+ // Get button text from provider_details (already contains the full button text from server)
5192
+ const getButtonText = (provider) => {
5215
5193
  const details = detailsMap.get(provider);
5216
5194
  if (details?.display_name) {
5217
5195
  return details.display_name;
5218
5196
  }
5219
- const displayNames = {
5220
- "google-oauth2": "Google",
5221
- facebook: "Facebook",
5222
- twitter: "Twitter",
5223
- github: "GitHub",
5224
- linkedin: "LinkedIn",
5225
- apple: "Apple",
5226
- microsoft: "Microsoft",
5227
- windowslive: "Microsoft",
5228
- amazon: "Amazon",
5229
- dropbox: "Dropbox",
5230
- bitbucket: "Bitbucket",
5231
- spotify: "Spotify",
5232
- slack: "Slack",
5233
- discord: "Discord",
5234
- twitch: "Twitch",
5235
- line: "LINE",
5236
- shopify: "Shopify",
5237
- paypal: "PayPal",
5238
- "paypal-sandbox": "PayPal",
5239
- box: "Box",
5240
- salesforce: "Salesforce",
5241
- "salesforce-sandbox": "Salesforce",
5242
- yahoo: "Yahoo",
5243
- auth0: "Auth0",
5244
- vipps: "Vipps",
5245
- };
5246
- return (displayNames[provider.toLowerCase()] ||
5247
- provider
5248
- .split("-")
5249
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
5250
- .join(" "));
5197
+ // Fallback: use provider name with basic formatting
5198
+ return provider
5199
+ .split("-")
5200
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
5201
+ .join(" ");
5251
5202
  };
5252
- // Get provider icon - either from provider_details or built-in SVG
5203
+ // Get provider icon from provider_details icon_url
5253
5204
  const getProviderIcon = (provider) => {
5254
- // First check if we have a custom icon URL from provider_details
5255
5205
  const details = detailsMap.get(provider);
5256
5206
  if (details?.icon_url) {
5257
5207
  return (hAsync("img", { class: "social-icon", src: details.icon_url, alt: details.display_name || provider }));
5258
5208
  }
5259
- // Try to find a known provider from name or strategy
5260
- const knownProvider = findKnownProvider(provider, details?.strategy);
5261
- const p = knownProvider || provider.toLowerCase();
5262
- if (p === "google-oauth2" || p === "google") {
5263
- return (hAsync("svg", { class: "social-icon", viewBox: "0 0 24 24", xmlns: "http://www.w3.org/2000/svg" }, hAsync("path", { d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z", fill: "#4285F4" }), hAsync("path", { d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z", fill: "#34A853" }), hAsync("path", { d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z", fill: "#FBBC05" }), hAsync("path", { d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z", fill: "#EA4335" })));
5264
- }
5265
- if (p === "facebook") {
5266
- return (hAsync("svg", { class: "social-icon", viewBox: "0 0 24 24", xmlns: "http://www.w3.org/2000/svg" }, hAsync("path", { d: "M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z", fill: "#1877F2" })));
5267
- }
5268
- if (p === "apple") {
5269
- return (hAsync("svg", { class: "social-icon", viewBox: "0 0 24 24", xmlns: "http://www.w3.org/2000/svg" }, hAsync("path", { d: "M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z", fill: "#000000" })));
5270
- }
5271
- if (p === "github") {
5272
- return (hAsync("svg", { class: "social-icon", viewBox: "0 0 24 24", xmlns: "http://www.w3.org/2000/svg" }, hAsync("path", { d: "M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z", fill: "#181717" })));
5273
- }
5274
- if (p === "microsoft" || p === "windowslive") {
5275
- return (hAsync("svg", { class: "social-icon", viewBox: "0 0 24 24", xmlns: "http://www.w3.org/2000/svg" }, hAsync("path", { d: "M0 0h11.377v11.372H0V0z", fill: "#f25022" }), hAsync("path", { d: "M12.623 0H24v11.372H12.623V0z", fill: "#7fba00" }), hAsync("path", { d: "M0 12.623h11.377V24H0v-11.377z", fill: "#00a4ef" }), hAsync("path", { d: "M12.623 12.623H24V24H12.623v-11.377z", fill: "#ffb900" })));
5276
- }
5277
- if (p === "linkedin") {
5278
- return (hAsync("svg", { class: "social-icon", viewBox: "0 0 24 24", xmlns: "http://www.w3.org/2000/svg" }, hAsync("path", { d: "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z", fill: "#0A66C2" })));
5279
- }
5280
- if (p === "vipps") {
5281
- return (hAsync("svg", { class: "social-icon", viewBox: "0 0 48 48", xmlns: "http://www.w3.org/2000/svg" }, hAsync("path", { fill: "#FF5B24", d: "M3.5,8h41c1.9,0,3.5,1.6,3.5,3.5v25c0,1.9-1.6,3.5-3.5,3.5h-41C1.6,40,0,38.4,0,36.5v-25C0,9.6,1.6,8,3.5,8z" }), hAsync("path", { fill: "#FFFFFF", d: "M27.9,20.3c1.4,0,2.6-1,2.6-2.5c0-1.5-1.2-2.5-2.6-2.5c-1.4,0-2.6,1-2.6,2.5C25.3,19.2,26.5,20.3,27.9,20.3z" }), hAsync("path", { fill: "#FFFFFF", d: "M31.2,24.4c-1.7,2.2-3.5,3.8-6.7,3.8c-3.2,0-5.8-2-7.7-4.8c-0.8-1.2-2-1.4-2.9-0.8c-0.8,0.6-1,1.8-0.3,2.9c2.7,4.1,6.5,6.6,10.9,6.6c4,0,7.2-2,9.6-5.2c0.9-1.2,0.9-2.5,0-3.1C33.3,22.9,32.1,23.2,31.2,24.4z" })));
5282
- }
5283
- // Default: generic globe icon for unknown providers
5284
- return (hAsync("svg", { class: "social-icon", viewBox: "0 0 24 24", xmlns: "http://www.w3.org/2000/svg" }, hAsync("circle", { cx: "12", cy: "12", r: "10", fill: "none", stroke: "#666", "stroke-width": "2" }), hAsync("path", { d: "M2 12h20M12 2c-2.5 2.5-4 5.5-4 10s1.5 7.5 4 10c2.5-2.5 4-5.5 4-10s-1.5-7.5-4-10z", fill: "none", stroke: "#666", "stroke-width": "2" })));
5209
+ // No icon provided - return null (button will just show text)
5210
+ return null;
5285
5211
  };
5286
- return (hAsync("div", { class: "social-buttons", part: "social-buttons" }, providers.map((provider) => (hAsync("button", { type: "button", class: "btn btn-secondary btn-social", part: "button button-secondary button-social", "data-provider": provider, disabled: this.disabled, onClick: (e) => this.handleButtonClick(e, "SOCIAL", provider), key: provider }, getProviderIcon(provider), hAsync("span", null, "Continue with ", getProviderDisplayName(provider)))))));
5212
+ return (hAsync("div", { class: "social-buttons", part: "social-buttons" }, providers.map((provider) => {
5213
+ const safeProvider = this.sanitizeForCssToken(provider);
5214
+ const icon = getProviderIcon(provider);
5215
+ return (hAsync("button", { type: "button", class: `btn btn-secondary btn-social btn-social-${safeProvider}${icon ? "" : " no-icon"}`, part: `button button-secondary button-social button-social-${safeProvider}`, "data-provider": provider, disabled: this.disabled, onClick: (e) => this.handleButtonClick(e, "SOCIAL", provider), key: provider }, icon, hAsync("span", { part: "button-social-text" }, getButtonText(provider))));
5216
+ })));
5287
5217
  }
5288
5218
  // ===========================================================================
5289
5219
  // Main Render
@@ -5661,6 +5591,139 @@ function applyCssVars(element, vars) {
5661
5591
  });
5662
5592
  }
5663
5593
 
5594
+ /**
5595
+ * Sanitize HTML to only allow safe formatting tags
5596
+ *
5597
+ * Allowed tags:
5598
+ * - <br>, <br/> - Line breaks
5599
+ * - <em>, <i> - Italic
5600
+ * - <strong>, <b> - Bold
5601
+ * - <u> - Underline
5602
+ * - <span> - Generic inline container (for styling)
5603
+ * - <a> - Links (href attribute only, with target="_blank" and rel="noopener")
5604
+ *
5605
+ * All other tags and attributes are stripped.
5606
+ */
5607
+ // Allowed tags and their allowed attributes
5608
+ const ALLOWED_TAGS = {
5609
+ br: [],
5610
+ em: [],
5611
+ i: [],
5612
+ strong: [],
5613
+ b: [],
5614
+ u: [],
5615
+ span: ["class"],
5616
+ a: ["href", "class"],
5617
+ };
5618
+ /**
5619
+ * Sanitize HTML string to only allow safe formatting tags
5620
+ *
5621
+ * @param html - The HTML string to sanitize
5622
+ * @returns Sanitized HTML string safe for innerHTML
5623
+ */
5624
+ function sanitizeHtml(html) {
5625
+ if (!html)
5626
+ return "";
5627
+ // If no < character present, return as-is (optimization)
5628
+ // Must check for any < to prevent bypassing sanitization with malformed tags
5629
+ // like "<img src=x onerror=..." which forgiving HTML parsers may still execute
5630
+ if (!html.includes("<")) {
5631
+ return html;
5632
+ }
5633
+ // Use a simple regex-based approach that's safe for our limited use case
5634
+ // This avoids needing DOMParser which may not be available in all environments
5635
+ let result = html;
5636
+ // First, escape all HTML
5637
+ result = result
5638
+ .replace(/&/g, "&amp;")
5639
+ .replace(/</g, "&lt;")
5640
+ .replace(/>/g, "&gt;")
5641
+ .replace(/"/g, "&quot;")
5642
+ .replace(/'/g, "&#39;");
5643
+ // Then selectively re-enable allowed tags
5644
+ for (const [tag, allowedAttrs] of Object.entries(ALLOWED_TAGS)) {
5645
+ // Self-closing tags (like <br> and <br/>)
5646
+ if (tag === "br") {
5647
+ result = result.replace(/&lt;br\s*\/?&gt;/gi, "<br>");
5648
+ continue;
5649
+ }
5650
+ // Opening tags with optional attributes
5651
+ const openingPattern = new RegExp(`&lt;${tag}((?:\\s+[a-z-]+(?:=&quot;[^&]*&quot;|=&#39;[^&]*&#39;)?)*)\\s*&gt;`, "gi");
5652
+ result = result.replace(openingPattern, (_match, attrsStr) => {
5653
+ // Parse and filter attributes
5654
+ const filteredAttrs = [];
5655
+ if (attrsStr) {
5656
+ // Unescape the attributes string for parsing
5657
+ const unescapedAttrs = attrsStr
5658
+ .replace(/&quot;/g, '"')
5659
+ .replace(/&#39;/g, "'")
5660
+ .replace(/&amp;/g, "&")
5661
+ .replace(/&lt;/g, "<")
5662
+ .replace(/&gt;/g, ">");
5663
+ // Extract attributes
5664
+ const attrPattern = /([a-z-]+)=["']([^"']*)["']/gi;
5665
+ let attrMatch;
5666
+ while ((attrMatch = attrPattern.exec(unescapedAttrs)) !== null) {
5667
+ const [, attrName, attrValue] = attrMatch;
5668
+ if (attrName && allowedAttrs.includes(attrName.toLowerCase())) {
5669
+ // For href, validate it's a safe URL
5670
+ if (attrName.toLowerCase() === "href") {
5671
+ if (isSafeUrl(attrValue || "")) {
5672
+ filteredAttrs.push(`${attrName}="${escapeAttr(attrValue || "")}"`);
5673
+ }
5674
+ }
5675
+ else {
5676
+ filteredAttrs.push(`${attrName}="${escapeAttr(attrValue || "")}"`);
5677
+ }
5678
+ }
5679
+ }
5680
+ }
5681
+ // For <a> tags, always add security attributes
5682
+ if (tag === "a") {
5683
+ filteredAttrs.push('target="_blank"');
5684
+ filteredAttrs.push('rel="noopener noreferrer"');
5685
+ }
5686
+ const attrsOutput = filteredAttrs.length
5687
+ ? " " + filteredAttrs.join(" ")
5688
+ : "";
5689
+ return `<${tag}${attrsOutput}>`;
5690
+ });
5691
+ // Closing tags
5692
+ const closingPattern = new RegExp(`&lt;/${tag}&gt;`, "gi");
5693
+ result = result.replace(closingPattern, `</${tag}>`);
5694
+ }
5695
+ return result;
5696
+ }
5697
+ /**
5698
+ * Check if a URL is safe (http, https, or relative)
5699
+ */
5700
+ function isSafeUrl(url) {
5701
+ if (!url)
5702
+ return false;
5703
+ // Allow relative URLs
5704
+ if (url.startsWith("/") || url.startsWith("#") || url.startsWith("?")) {
5705
+ return true;
5706
+ }
5707
+ // Allow http and https
5708
+ try {
5709
+ const parsed = new URL(url, "https://example.com");
5710
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
5711
+ }
5712
+ catch {
5713
+ return false;
5714
+ }
5715
+ }
5716
+ /**
5717
+ * Escape attribute value
5718
+ */
5719
+ function escapeAttr(value) {
5720
+ return value
5721
+ .replace(/&/g, "&amp;")
5722
+ .replace(/"/g, "&quot;")
5723
+ .replace(/</g, "&lt;")
5724
+ .replace(/>/g, "&gt;");
5725
+ }
5726
+
5664
5727
  const authheroWidgetCss = () => `:host{display:block;font-family:var(--ah-font-family, 'ulp-font', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, sans-serif);font-size:var(--ah-font-size-base, 14px);line-height:var(--ah-line-height-base, 1.5);color:var(--ah-color-text, #1e212a);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.widget-container{max-width:var(--ah-widget-max-width, 400px);width:100%;margin:0 auto;background-color:var(--ah-color-bg, #ffffff);border-radius:var(--ah-widget-radius, 5px);box-shadow:var(--ah-widget-shadow, 0 4px 22px 0 rgba(0, 0, 0, 0.11));box-sizing:border-box}.widget-header{padding:var(--ah-header-padding, 40px 48px 24px)}.widget-body{padding:var(--ah-body-padding, 0 48px 40px)}.logo-wrapper{display:var(--ah-logo-display, flex);justify-content:var(--ah-logo-align, center);margin-bottom:8px}.logo{display:block;height:var(--ah-logo-height, 52px);max-width:100%;width:auto;object-fit:contain}.title{font-size:var(--ah-font-size-title, 24px);font-weight:var(--ah-font-weight-title, 700);text-align:var(--ah-title-align, center);margin:var(--ah-title-margin, 24px 0 8px);color:var(--ah-color-header, #1e212a);line-height:1.2}.description{font-size:var(--ah-font-size-description, 14px);text-align:var(--ah-title-align, center);margin:var(--ah-description-margin, 0 0 8px);color:var(--ah-color-text, #1e212a);line-height:1.5}.message{padding:12px 16px;border-radius:4px;margin-bottom:16px;font-size:14px;line-height:1.5}.message-error{background-color:var(--ah-color-error-bg, #ffeaea);color:var(--ah-color-error, #d03c38);border-left:3px solid var(--ah-color-error, #d03c38)}.message-success{background-color:var(--ah-color-success-bg, #e6f9f1);color:var(--ah-color-success, #13a769);border-left:3px solid var(--ah-color-success, #13a769)}form{display:flex;flex-direction:column}.form-content{display:flex;flex-direction:column}.social-section{display:flex;flex-direction:column;gap:8px;order:var(--ah-social-order, 2)}.fields-section{display:flex;flex-direction:column;order:var(--ah-fields-order, 0)}.divider{display:flex;align-items:center;text-align:center;margin:16px 0;order:var(--ah-divider-order, 1)}.divider::before,.divider::after{content:'';flex:1;border-bottom:1px solid var(--ah-color-border-muted, #c9cace)}.divider-text{padding:0 10px;font-size:12px;font-weight:400;color:var(--ah-color-text-muted, #65676e);text-transform:uppercase;letter-spacing:0}.links{display:flex;flex-direction:column;align-items:center;gap:8px;margin-top:16px}.link-wrapper{font-size:14px;color:var(--ah-color-text, #1e212a)}.link{color:var(--ah-color-link, #635dff);text-decoration:var(--ah-link-decoration, none);font-size:14px;font-weight:var(--ah-font-weight-link, 400);transition:color 150ms ease}.link:hover{text-decoration:underline}.link:focus-visible{outline:2px solid var(--ah-color-link, #635dff);outline-offset:2px;border-radius:2px}.loading-spinner{width:32px;height:32px;margin:24px auto;border:3px solid var(--ah-color-border-muted, #e0e1e3);border-top-color:var(--ah-color-primary, #635dff);border-radius:50%;animation:spin 0.8s linear infinite}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}.error-message{text-align:center;color:var(--ah-color-error, #d03c38);padding:16px;font-size:14px}@media (max-width: 480px){:host{display:block;width:100%;min-height:100vh;background-color:var(--ah-color-bg, #ffffff)}.widget-container{box-shadow:none;border-radius:0;max-width:none;width:100%;margin:0}.widget-header{padding:24px 16px 16px}.widget-body{padding:0 16px 24px}}`;
5665
5728
 
5666
5729
  class AuthheroWidget {
@@ -6086,7 +6149,12 @@ class AuthheroWidget {
6086
6149
  if (result.screenId) {
6087
6150
  this.screenId = result.screenId;
6088
6151
  }
6152
+ // Persist state (especially for session storage mode)
6089
6153
  this.persistState();
6154
+ // Update URL path if navigateUrl is provided (client-side navigation)
6155
+ if (result.navigateUrl && this.shouldAutoNavigate) {
6156
+ window.history.pushState({ screen: result.screenId, state: this.state }, "", result.navigateUrl);
6157
+ }
6090
6158
  // Apply branding if included
6091
6159
  if (result.branding) {
6092
6160
  this._branding = result.branding;
@@ -6259,7 +6327,7 @@ class AuthheroWidget {
6259
6327
  const hasDivider = components.some((c) => this.isDividerComponent(c));
6260
6328
  // Get logo URL from theme.widget (takes precedence) or branding
6261
6329
  const logoUrl = this._theme?.widget?.logo_url || this._branding?.logo_url;
6262
- return (hAsync("div", { class: "widget-container", part: "container" }, hAsync("header", { class: "widget-header", part: "header" }, logoUrl && (hAsync("div", { class: "logo-wrapper", part: "logo-wrapper" }, hAsync("img", { class: "logo", part: "logo", src: logoUrl, alt: "Logo" }))), this._screen.title && (hAsync("h1", { class: "title", part: "title" }, this._screen.title)), this._screen.description && (hAsync("p", { class: "description", part: "description" }, this._screen.description))), hAsync("div", { class: "widget-body", part: "body" }, screenErrors.map((err) => (hAsync("div", { class: "message message-error", part: "message message-error", key: err.id ?? err.text }, err.text))), screenSuccesses.map((msg) => (hAsync("div", { class: "message message-success", part: "message message-success", key: msg.id ?? msg.text }, msg.text))), hAsync("form", { onSubmit: this.handleSubmit, part: "form" }, hAsync("div", { class: "form-content" }, socialComponents.length > 0 && (hAsync("div", { class: "social-section", part: "social-section" }, socialComponents.map((component) => (hAsync("authhero-node", { key: component.id, component: component, value: this.formData[component.id], onFieldChange: (e) => this.handleInputChange(e.detail.id, e.detail.value), onButtonClick: (e) => this.handleButtonClick(e.detail), disabled: this.loading }))))), socialComponents.length > 0 &&
6330
+ return (hAsync("div", { class: "widget-container", part: "container" }, hAsync("header", { class: "widget-header", part: "header" }, logoUrl && (hAsync("div", { class: "logo-wrapper", part: "logo-wrapper" }, hAsync("img", { class: "logo", part: "logo", src: logoUrl, alt: "Logo" }))), this._screen.title && (hAsync("h1", { class: "title", part: "title", innerHTML: sanitizeHtml(this._screen.title) })), this._screen.description && (hAsync("p", { class: "description", part: "description", innerHTML: sanitizeHtml(this._screen.description) }))), hAsync("div", { class: "widget-body", part: "body" }, screenErrors.map((err) => (hAsync("div", { class: "message message-error", part: "message message-error", key: err.id ?? err.text }, err.text))), screenSuccesses.map((msg) => (hAsync("div", { class: "message message-success", part: "message message-success", key: msg.id ?? msg.text }, msg.text))), hAsync("form", { onSubmit: this.handleSubmit, part: "form" }, hAsync("div", { class: "form-content" }, socialComponents.length > 0 && (hAsync("div", { class: "social-section", part: "social-section" }, socialComponents.map((component) => (hAsync("authhero-node", { key: component.id, component: component, value: this.formData[component.id], onFieldChange: (e) => this.handleInputChange(e.detail.id, e.detail.value), onButtonClick: (e) => this.handleButtonClick(e.detail), disabled: this.loading }))))), socialComponents.length > 0 &&
6263
6331
  fieldComponents.length > 0 &&
6264
6332
  hasDivider && (hAsync("div", { class: "divider", part: "divider" }, hAsync("span", { class: "divider-text" }, "Or"))), hAsync("div", { class: "fields-section", part: "fields-section" }, fieldComponents.map((component) => (hAsync("authhero-node", { key: component.id, component: component, value: this.formData[component.id], onFieldChange: (e) => this.handleInputChange(e.detail.id, e.detail.value), onButtonClick: (e) => this.handleButtonClick(e.detail), disabled: this.loading })))))), this._screen.links && this._screen.links.length > 0 && (hAsync("div", { class: "links", part: "links" }, this._screen.links.map((link) => (hAsync("span", { class: "link-wrapper", part: "link-wrapper", key: link.id ?? link.href }, link.linkText ? (hAsync("span", null, link.text, " ", hAsync("a", { href: link.href, class: "link", part: "link", onClick: (e) => this.handleLinkClick(e, {
6265
6333
  id: link.id,