@authhero/widget 0.8.6 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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();
@@ -5174,8 +5185,48 @@ class AuthheroNode {
5174
5185
  }
5175
5186
  renderSocialField(component) {
5176
5187
  const providers = component.config?.providers ?? [];
5188
+ const providerDetails = component.config?.provider_details;
5189
+ // Create a map of provider details for quick lookup
5190
+ const detailsMap = new Map(providerDetails?.map((d) => [d.name, d]) ?? []);
5191
+ // Known provider identifiers for icon matching
5192
+ const knownProviders = [
5193
+ "google-oauth2",
5194
+ "google",
5195
+ "facebook",
5196
+ "apple",
5197
+ "github",
5198
+ "microsoft",
5199
+ "windowslive",
5200
+ "linkedin",
5201
+ "vipps",
5202
+ ];
5203
+ // Find matching known provider from name or strategy
5204
+ const findKnownProvider = (name, strategy) => {
5205
+ const nameLower = name.toLowerCase();
5206
+ const strategyLower = strategy?.toLowerCase();
5207
+ // First check exact match on strategy
5208
+ if (strategyLower && knownProviders.includes(strategyLower)) {
5209
+ return strategyLower;
5210
+ }
5211
+ // Then check exact match on name
5212
+ if (knownProviders.includes(nameLower)) {
5213
+ return nameLower;
5214
+ }
5215
+ // Check if name contains a known provider (e.g., "Vipps Login" contains "vipps")
5216
+ for (const known of knownProviders) {
5217
+ if (nameLower.includes(known)) {
5218
+ return known;
5219
+ }
5220
+ }
5221
+ return null;
5222
+ };
5177
5223
  // Map provider IDs to display names
5178
5224
  const getProviderDisplayName = (provider) => {
5225
+ // First check provider_details
5226
+ const details = detailsMap.get(provider);
5227
+ if (details?.display_name) {
5228
+ return details.display_name;
5229
+ }
5179
5230
  const displayNames = {
5180
5231
  "google-oauth2": "Google",
5181
5232
  facebook: "Facebook",
@@ -5201,6 +5252,7 @@ class AuthheroNode {
5201
5252
  "salesforce-sandbox": "Salesforce",
5202
5253
  yahoo: "Yahoo",
5203
5254
  auth0: "Auth0",
5255
+ vipps: "Vipps",
5204
5256
  };
5205
5257
  return (displayNames[provider.toLowerCase()] ||
5206
5258
  provider
@@ -5208,9 +5260,16 @@ class AuthheroNode {
5208
5260
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
5209
5261
  .join(" "));
5210
5262
  };
5211
- // Get provider icon SVG
5263
+ // Get provider icon - either from provider_details or built-in SVG
5212
5264
  const getProviderIcon = (provider) => {
5213
- const p = provider.toLowerCase();
5265
+ // First check if we have a custom icon URL from provider_details
5266
+ const details = detailsMap.get(provider);
5267
+ if (details?.icon_url) {
5268
+ return (hAsync("img", { class: "social-icon", src: details.icon_url, alt: details.display_name || provider }));
5269
+ }
5270
+ // Try to find a known provider from name or strategy
5271
+ const knownProvider = findKnownProvider(provider, details?.strategy);
5272
+ const p = knownProvider || provider.toLowerCase();
5214
5273
  if (p === "google-oauth2" || p === "google") {
5215
5274
  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" })));
5216
5275
  }
@@ -5229,10 +5288,16 @@ class AuthheroNode {
5229
5288
  if (p === "linkedin") {
5230
5289
  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" })));
5231
5290
  }
5232
- // Default: no icon
5233
- return null;
5291
+ if (p === "vipps") {
5292
+ 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" })));
5293
+ }
5294
+ // Default: generic globe icon for unknown providers
5295
+ 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" })));
5234
5296
  };
5235
- 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)))))));
5297
+ return (hAsync("div", { class: "social-buttons", part: "social-buttons" }, providers.map((provider) => {
5298
+ const safeProvider = this.sanitizeForCssToken(provider);
5299
+ return (hAsync("button", { type: "button", class: `btn btn-secondary btn-social btn-social-${safeProvider}`, part: `button button-secondary button-social button-social-${safeProvider}`, "data-provider": provider, disabled: this.disabled, onClick: (e) => this.handleButtonClick(e, "SOCIAL", provider), key: provider }, getProviderIcon(provider), hAsync("span", { part: "button-social-text" }, `Continue with ${getProviderDisplayName(provider)}`)));
5300
+ })));
5236
5301
  }
5237
5302
  // ===========================================================================
5238
5303
  // Main Render
@@ -5610,6 +5675,139 @@ function applyCssVars(element, vars) {
5610
5675
  });
5611
5676
  }
5612
5677
 
5678
+ /**
5679
+ * Sanitize HTML to only allow safe formatting tags
5680
+ *
5681
+ * Allowed tags:
5682
+ * - <br>, <br/> - Line breaks
5683
+ * - <em>, <i> - Italic
5684
+ * - <strong>, <b> - Bold
5685
+ * - <u> - Underline
5686
+ * - <span> - Generic inline container (for styling)
5687
+ * - <a> - Links (href attribute only, with target="_blank" and rel="noopener")
5688
+ *
5689
+ * All other tags and attributes are stripped.
5690
+ */
5691
+ // Allowed tags and their allowed attributes
5692
+ const ALLOWED_TAGS = {
5693
+ br: [],
5694
+ em: [],
5695
+ i: [],
5696
+ strong: [],
5697
+ b: [],
5698
+ u: [],
5699
+ span: ["class"],
5700
+ a: ["href", "class"],
5701
+ };
5702
+ /**
5703
+ * Sanitize HTML string to only allow safe formatting tags
5704
+ *
5705
+ * @param html - The HTML string to sanitize
5706
+ * @returns Sanitized HTML string safe for innerHTML
5707
+ */
5708
+ function sanitizeHtml(html) {
5709
+ if (!html)
5710
+ return "";
5711
+ // If no < character present, return as-is (optimization)
5712
+ // Must check for any < to prevent bypassing sanitization with malformed tags
5713
+ // like "<img src=x onerror=..." which forgiving HTML parsers may still execute
5714
+ if (!html.includes("<")) {
5715
+ return html;
5716
+ }
5717
+ // Use a simple regex-based approach that's safe for our limited use case
5718
+ // This avoids needing DOMParser which may not be available in all environments
5719
+ let result = html;
5720
+ // First, escape all HTML
5721
+ result = result
5722
+ .replace(/&/g, "&amp;")
5723
+ .replace(/</g, "&lt;")
5724
+ .replace(/>/g, "&gt;")
5725
+ .replace(/"/g, "&quot;")
5726
+ .replace(/'/g, "&#39;");
5727
+ // Then selectively re-enable allowed tags
5728
+ for (const [tag, allowedAttrs] of Object.entries(ALLOWED_TAGS)) {
5729
+ // Self-closing tags (like <br> and <br/>)
5730
+ if (tag === "br") {
5731
+ result = result.replace(/&lt;br\s*\/?&gt;/gi, "<br>");
5732
+ continue;
5733
+ }
5734
+ // Opening tags with optional attributes
5735
+ const openingPattern = new RegExp(`&lt;${tag}((?:\\s+[a-z-]+(?:=&quot;[^&]*&quot;|=&#39;[^&]*&#39;)?)*)\\s*&gt;`, "gi");
5736
+ result = result.replace(openingPattern, (_match, attrsStr) => {
5737
+ // Parse and filter attributes
5738
+ const filteredAttrs = [];
5739
+ if (attrsStr) {
5740
+ // Unescape the attributes string for parsing
5741
+ const unescapedAttrs = attrsStr
5742
+ .replace(/&quot;/g, '"')
5743
+ .replace(/&#39;/g, "'")
5744
+ .replace(/&amp;/g, "&")
5745
+ .replace(/&lt;/g, "<")
5746
+ .replace(/&gt;/g, ">");
5747
+ // Extract attributes
5748
+ const attrPattern = /([a-z-]+)=["']([^"']*)["']/gi;
5749
+ let attrMatch;
5750
+ while ((attrMatch = attrPattern.exec(unescapedAttrs)) !== null) {
5751
+ const [, attrName, attrValue] = attrMatch;
5752
+ if (attrName && allowedAttrs.includes(attrName.toLowerCase())) {
5753
+ // For href, validate it's a safe URL
5754
+ if (attrName.toLowerCase() === "href") {
5755
+ if (isSafeUrl(attrValue || "")) {
5756
+ filteredAttrs.push(`${attrName}="${escapeAttr(attrValue || "")}"`);
5757
+ }
5758
+ }
5759
+ else {
5760
+ filteredAttrs.push(`${attrName}="${escapeAttr(attrValue || "")}"`);
5761
+ }
5762
+ }
5763
+ }
5764
+ }
5765
+ // For <a> tags, always add security attributes
5766
+ if (tag === "a") {
5767
+ filteredAttrs.push('target="_blank"');
5768
+ filteredAttrs.push('rel="noopener noreferrer"');
5769
+ }
5770
+ const attrsOutput = filteredAttrs.length
5771
+ ? " " + filteredAttrs.join(" ")
5772
+ : "";
5773
+ return `<${tag}${attrsOutput}>`;
5774
+ });
5775
+ // Closing tags
5776
+ const closingPattern = new RegExp(`&lt;/${tag}&gt;`, "gi");
5777
+ result = result.replace(closingPattern, `</${tag}>`);
5778
+ }
5779
+ return result;
5780
+ }
5781
+ /**
5782
+ * Check if a URL is safe (http, https, or relative)
5783
+ */
5784
+ function isSafeUrl(url) {
5785
+ if (!url)
5786
+ return false;
5787
+ // Allow relative URLs
5788
+ if (url.startsWith("/") || url.startsWith("#") || url.startsWith("?")) {
5789
+ return true;
5790
+ }
5791
+ // Allow http and https
5792
+ try {
5793
+ const parsed = new URL(url, "https://example.com");
5794
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
5795
+ }
5796
+ catch {
5797
+ return false;
5798
+ }
5799
+ }
5800
+ /**
5801
+ * Escape attribute value
5802
+ */
5803
+ function escapeAttr(value) {
5804
+ return value
5805
+ .replace(/&/g, "&amp;")
5806
+ .replace(/"/g, "&quot;")
5807
+ .replace(/</g, "&lt;")
5808
+ .replace(/>/g, "&gt;");
5809
+ }
5810
+
5613
5811
  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}}`;
5614
5812
 
5615
5813
  class AuthheroWidget {
@@ -6208,7 +6406,7 @@ class AuthheroWidget {
6208
6406
  const hasDivider = components.some((c) => this.isDividerComponent(c));
6209
6407
  // Get logo URL from theme.widget (takes precedence) or branding
6210
6408
  const logoUrl = this._theme?.widget?.logo_url || this._branding?.logo_url;
6211
- 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 &&
6409
+ 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 &&
6212
6410
  fieldComponents.length > 0 &&
6213
6411
  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, {
6214
6412
  id: link.id,
package/hydrate/index.mjs CHANGED
@@ -5013,6 +5013,17 @@ class AuthheroNode {
5013
5013
  value: target.checked ? "true" : "false",
5014
5014
  });
5015
5015
  };
5016
+ /**
5017
+ * Sanitize a string for use in CSS class names and part tokens.
5018
+ * Replaces spaces and special characters with hyphens, converts to lowercase.
5019
+ */
5020
+ sanitizeForCssToken(value) {
5021
+ return value
5022
+ .toLowerCase()
5023
+ .replace(/[^a-z0-9-]/g, "-") // Replace non-alphanumeric chars with hyphen
5024
+ .replace(/-+/g, "-") // Collapse multiple hyphens
5025
+ .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
5026
+ }
5016
5027
  handleButtonClick = (e, type, value) => {
5017
5028
  if (type !== "submit") {
5018
5029
  e.preventDefault();
@@ -5172,8 +5183,48 @@ class AuthheroNode {
5172
5183
  }
5173
5184
  renderSocialField(component) {
5174
5185
  const providers = component.config?.providers ?? [];
5186
+ const providerDetails = component.config?.provider_details;
5187
+ // Create a map of provider details for quick lookup
5188
+ const detailsMap = new Map(providerDetails?.map((d) => [d.name, d]) ?? []);
5189
+ // Known provider identifiers for icon matching
5190
+ const knownProviders = [
5191
+ "google-oauth2",
5192
+ "google",
5193
+ "facebook",
5194
+ "apple",
5195
+ "github",
5196
+ "microsoft",
5197
+ "windowslive",
5198
+ "linkedin",
5199
+ "vipps",
5200
+ ];
5201
+ // Find matching known provider from name or strategy
5202
+ const findKnownProvider = (name, strategy) => {
5203
+ const nameLower = name.toLowerCase();
5204
+ const strategyLower = strategy?.toLowerCase();
5205
+ // First check exact match on strategy
5206
+ if (strategyLower && knownProviders.includes(strategyLower)) {
5207
+ return strategyLower;
5208
+ }
5209
+ // Then check exact match on name
5210
+ if (knownProviders.includes(nameLower)) {
5211
+ return nameLower;
5212
+ }
5213
+ // Check if name contains a known provider (e.g., "Vipps Login" contains "vipps")
5214
+ for (const known of knownProviders) {
5215
+ if (nameLower.includes(known)) {
5216
+ return known;
5217
+ }
5218
+ }
5219
+ return null;
5220
+ };
5175
5221
  // Map provider IDs to display names
5176
5222
  const getProviderDisplayName = (provider) => {
5223
+ // First check provider_details
5224
+ const details = detailsMap.get(provider);
5225
+ if (details?.display_name) {
5226
+ return details.display_name;
5227
+ }
5177
5228
  const displayNames = {
5178
5229
  "google-oauth2": "Google",
5179
5230
  facebook: "Facebook",
@@ -5199,6 +5250,7 @@ class AuthheroNode {
5199
5250
  "salesforce-sandbox": "Salesforce",
5200
5251
  yahoo: "Yahoo",
5201
5252
  auth0: "Auth0",
5253
+ vipps: "Vipps",
5202
5254
  };
5203
5255
  return (displayNames[provider.toLowerCase()] ||
5204
5256
  provider
@@ -5206,9 +5258,16 @@ class AuthheroNode {
5206
5258
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
5207
5259
  .join(" "));
5208
5260
  };
5209
- // Get provider icon SVG
5261
+ // Get provider icon - either from provider_details or built-in SVG
5210
5262
  const getProviderIcon = (provider) => {
5211
- const p = provider.toLowerCase();
5263
+ // First check if we have a custom icon URL from provider_details
5264
+ const details = detailsMap.get(provider);
5265
+ if (details?.icon_url) {
5266
+ return (hAsync("img", { class: "social-icon", src: details.icon_url, alt: details.display_name || provider }));
5267
+ }
5268
+ // Try to find a known provider from name or strategy
5269
+ const knownProvider = findKnownProvider(provider, details?.strategy);
5270
+ const p = knownProvider || provider.toLowerCase();
5212
5271
  if (p === "google-oauth2" || p === "google") {
5213
5272
  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" })));
5214
5273
  }
@@ -5227,10 +5286,16 @@ class AuthheroNode {
5227
5286
  if (p === "linkedin") {
5228
5287
  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" })));
5229
5288
  }
5230
- // Default: no icon
5231
- return null;
5289
+ if (p === "vipps") {
5290
+ 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" })));
5291
+ }
5292
+ // Default: generic globe icon for unknown providers
5293
+ 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" })));
5232
5294
  };
5233
- 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)))))));
5295
+ return (hAsync("div", { class: "social-buttons", part: "social-buttons" }, providers.map((provider) => {
5296
+ const safeProvider = this.sanitizeForCssToken(provider);
5297
+ return (hAsync("button", { type: "button", class: `btn btn-secondary btn-social btn-social-${safeProvider}`, part: `button button-secondary button-social button-social-${safeProvider}`, "data-provider": provider, disabled: this.disabled, onClick: (e) => this.handleButtonClick(e, "SOCIAL", provider), key: provider }, getProviderIcon(provider), hAsync("span", { part: "button-social-text" }, `Continue with ${getProviderDisplayName(provider)}`)));
5298
+ })));
5234
5299
  }
5235
5300
  // ===========================================================================
5236
5301
  // Main Render
@@ -5608,6 +5673,139 @@ function applyCssVars(element, vars) {
5608
5673
  });
5609
5674
  }
5610
5675
 
5676
+ /**
5677
+ * Sanitize HTML to only allow safe formatting tags
5678
+ *
5679
+ * Allowed tags:
5680
+ * - <br>, <br/> - Line breaks
5681
+ * - <em>, <i> - Italic
5682
+ * - <strong>, <b> - Bold
5683
+ * - <u> - Underline
5684
+ * - <span> - Generic inline container (for styling)
5685
+ * - <a> - Links (href attribute only, with target="_blank" and rel="noopener")
5686
+ *
5687
+ * All other tags and attributes are stripped.
5688
+ */
5689
+ // Allowed tags and their allowed attributes
5690
+ const ALLOWED_TAGS = {
5691
+ br: [],
5692
+ em: [],
5693
+ i: [],
5694
+ strong: [],
5695
+ b: [],
5696
+ u: [],
5697
+ span: ["class"],
5698
+ a: ["href", "class"],
5699
+ };
5700
+ /**
5701
+ * Sanitize HTML string to only allow safe formatting tags
5702
+ *
5703
+ * @param html - The HTML string to sanitize
5704
+ * @returns Sanitized HTML string safe for innerHTML
5705
+ */
5706
+ function sanitizeHtml(html) {
5707
+ if (!html)
5708
+ return "";
5709
+ // If no < character present, return as-is (optimization)
5710
+ // Must check for any < to prevent bypassing sanitization with malformed tags
5711
+ // like "<img src=x onerror=..." which forgiving HTML parsers may still execute
5712
+ if (!html.includes("<")) {
5713
+ return html;
5714
+ }
5715
+ // Use a simple regex-based approach that's safe for our limited use case
5716
+ // This avoids needing DOMParser which may not be available in all environments
5717
+ let result = html;
5718
+ // First, escape all HTML
5719
+ result = result
5720
+ .replace(/&/g, "&amp;")
5721
+ .replace(/</g, "&lt;")
5722
+ .replace(/>/g, "&gt;")
5723
+ .replace(/"/g, "&quot;")
5724
+ .replace(/'/g, "&#39;");
5725
+ // Then selectively re-enable allowed tags
5726
+ for (const [tag, allowedAttrs] of Object.entries(ALLOWED_TAGS)) {
5727
+ // Self-closing tags (like <br> and <br/>)
5728
+ if (tag === "br") {
5729
+ result = result.replace(/&lt;br\s*\/?&gt;/gi, "<br>");
5730
+ continue;
5731
+ }
5732
+ // Opening tags with optional attributes
5733
+ const openingPattern = new RegExp(`&lt;${tag}((?:\\s+[a-z-]+(?:=&quot;[^&]*&quot;|=&#39;[^&]*&#39;)?)*)\\s*&gt;`, "gi");
5734
+ result = result.replace(openingPattern, (_match, attrsStr) => {
5735
+ // Parse and filter attributes
5736
+ const filteredAttrs = [];
5737
+ if (attrsStr) {
5738
+ // Unescape the attributes string for parsing
5739
+ const unescapedAttrs = attrsStr
5740
+ .replace(/&quot;/g, '"')
5741
+ .replace(/&#39;/g, "'")
5742
+ .replace(/&amp;/g, "&")
5743
+ .replace(/&lt;/g, "<")
5744
+ .replace(/&gt;/g, ">");
5745
+ // Extract attributes
5746
+ const attrPattern = /([a-z-]+)=["']([^"']*)["']/gi;
5747
+ let attrMatch;
5748
+ while ((attrMatch = attrPattern.exec(unescapedAttrs)) !== null) {
5749
+ const [, attrName, attrValue] = attrMatch;
5750
+ if (attrName && allowedAttrs.includes(attrName.toLowerCase())) {
5751
+ // For href, validate it's a safe URL
5752
+ if (attrName.toLowerCase() === "href") {
5753
+ if (isSafeUrl(attrValue || "")) {
5754
+ filteredAttrs.push(`${attrName}="${escapeAttr(attrValue || "")}"`);
5755
+ }
5756
+ }
5757
+ else {
5758
+ filteredAttrs.push(`${attrName}="${escapeAttr(attrValue || "")}"`);
5759
+ }
5760
+ }
5761
+ }
5762
+ }
5763
+ // For <a> tags, always add security attributes
5764
+ if (tag === "a") {
5765
+ filteredAttrs.push('target="_blank"');
5766
+ filteredAttrs.push('rel="noopener noreferrer"');
5767
+ }
5768
+ const attrsOutput = filteredAttrs.length
5769
+ ? " " + filteredAttrs.join(" ")
5770
+ : "";
5771
+ return `<${tag}${attrsOutput}>`;
5772
+ });
5773
+ // Closing tags
5774
+ const closingPattern = new RegExp(`&lt;/${tag}&gt;`, "gi");
5775
+ result = result.replace(closingPattern, `</${tag}>`);
5776
+ }
5777
+ return result;
5778
+ }
5779
+ /**
5780
+ * Check if a URL is safe (http, https, or relative)
5781
+ */
5782
+ function isSafeUrl(url) {
5783
+ if (!url)
5784
+ return false;
5785
+ // Allow relative URLs
5786
+ if (url.startsWith("/") || url.startsWith("#") || url.startsWith("?")) {
5787
+ return true;
5788
+ }
5789
+ // Allow http and https
5790
+ try {
5791
+ const parsed = new URL(url, "https://example.com");
5792
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
5793
+ }
5794
+ catch {
5795
+ return false;
5796
+ }
5797
+ }
5798
+ /**
5799
+ * Escape attribute value
5800
+ */
5801
+ function escapeAttr(value) {
5802
+ return value
5803
+ .replace(/&/g, "&amp;")
5804
+ .replace(/"/g, "&quot;")
5805
+ .replace(/</g, "&lt;")
5806
+ .replace(/>/g, "&gt;");
5807
+ }
5808
+
5611
5809
  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}}`;
5612
5810
 
5613
5811
  class AuthheroWidget {
@@ -6206,7 +6404,7 @@ class AuthheroWidget {
6206
6404
  const hasDivider = components.some((c) => this.isDividerComponent(c));
6207
6405
  // Get logo URL from theme.widget (takes precedence) or branding
6208
6406
  const logoUrl = this._theme?.widget?.logo_url || this._branding?.logo_url;
6209
- 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 &&
6407
+ 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 &&
6210
6408
  fieldComponents.length > 0 &&
6211
6409
  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, {
6212
6410
  id: link.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authhero/widget",
3
- "version": "0.8.6",
3
+ "version": "0.10.0",
4
4
  "description": "Server-Driven UI widget for AuthHero authentication flows",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,7 +20,11 @@
20
20
  "collection": "./dist/collection/collection-manifest.json",
21
21
  "exports": {
22
22
  ".": "./dist/index.js",
23
- "./hydrate": "./hydrate/index.js",
23
+ "./hydrate": {
24
+ "types": "./hydrate/index.d.ts",
25
+ "import": "./hydrate/index.mjs",
26
+ "require": "./hydrate/index.js"
27
+ },
24
28
  "./loader": "./loader/index.js",
25
29
  "./dist/esm/loader.js": "./dist/esm/loader.js",
26
30
  "./server": {
@@ -33,7 +37,7 @@
33
37
  }
34
38
  },
35
39
  "dependencies": {
36
- "@authhero/adapter-interfaces": "0.129.0"
40
+ "@authhero/adapter-interfaces": "0.130.0"
37
41
  },
38
42
  "devDependencies": {
39
43
  "@hono/node-server": "^1.14.1",