From bb72a8a505195efa9f00b9bb5c18712907ebe490 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 22 Apr 2026 08:47:52 -0500 Subject: [PATCH 01/32] feat: add validateAllowedEmailDomainEntry helper for domain-authorized promo codes --- ...alidate-allowed-email-domain-entry.test.js | 37 +++++++++++++++++++ src/utils/methods.js | 17 +++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/utils/__tests__/validate-allowed-email-domain-entry.test.js diff --git a/src/utils/__tests__/validate-allowed-email-domain-entry.test.js b/src/utils/__tests__/validate-allowed-email-domain-entry.test.js new file mode 100644 index 000000000..e5a0f2e6f --- /dev/null +++ b/src/utils/__tests__/validate-allowed-email-domain-entry.test.js @@ -0,0 +1,37 @@ +import { validateAllowedEmailDomainEntry } from "../methods"; + +describe("validateAllowedEmailDomainEntry", () => { + describe("valid entries", () => { + it.each([ + ["@acme.com"], + ["@sub.acme.com"], + ["@a.b.c.d"], + [".edu"], + [".co.uk"], + [".EDU"], // uppercase TLD accepted per server's /i flag + [".CO.UK"], // uppercase multi-part TLD accepted + [".a"], + ["user@example.com"], + ["first.last+tag@example.co"] + ])("accepts %s", (entry) => { + expect(validateAllowedEmailDomainEntry(entry)).toBe(true); + }); + }); + + describe("invalid entries", () => { + it.each([ + [""], + [" "], + ["acme.com"], // no leading @ + ["@acme"], // no dot after @ + ["@"], // empty domain + ["user@"], // no domain after @ + ["@@acme.com"], // double @ + ["user @example.com"], // whitespace + [null], + [undefined] + ])("rejects %p", (entry) => { + expect(validateAllowedEmailDomainEntry(entry)).toBe(false); + }); + }); +}); diff --git a/src/utils/methods.js b/src/utils/methods.js index 0af19ce6e..625e7a81e 100644 --- a/src/utils/methods.js +++ b/src/utils/methods.js @@ -285,6 +285,23 @@ export const validateEmail = (email) => /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ ); +// Mirrors summit-api app/Rules/AllowedEmailDomainsArray.php (see SDS line 59). +// Update in lockstep if the server regex changes. +const ALLOWED_DOMAIN_RE = /^@[\w][\w-]*(?:\.[\w][\w-]*)+$/; +const ALLOWED_TLD_RE = /^\.[a-z0-9]+(?:\.[a-z0-9]+)*$/i; +const ALLOWED_EMAIL_RE = /^[^@\s]+@[\w][\w.-]+$/; + +export const validateAllowedEmailDomainEntry = (entry) => { + if (typeof entry !== "string") return false; + const trimmed = entry.trim(); + if (trimmed.length === 0) return false; + return ( + ALLOWED_DOMAIN_RE.test(trimmed) || + ALLOWED_TLD_RE.test(trimmed) || + ALLOWED_EMAIL_RE.test(trimmed) + ); +}; + export const parseSpeakerAuditLog = (logString) => { const logEntries = logString.split("|"); const userChanges = {}; From b375eab4608ec01563a1c31d254c3f49fa0455d9 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 22 Apr 2026 08:51:48 -0500 Subject: [PATCH 02/32] feat: extend DEFAULT_ENTITY with domain-authorized promo code fields --- .../__tests__/promocode-reducer.test.js | 19 +++++++++++++++++++ src/reducers/promocodes/promocode-reducer.js | 5 ++++- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 src/reducers/promocodes/__tests__/promocode-reducer.test.js diff --git a/src/reducers/promocodes/__tests__/promocode-reducer.test.js b/src/reducers/promocodes/__tests__/promocode-reducer.test.js new file mode 100644 index 000000000..350c696b6 --- /dev/null +++ b/src/reducers/promocodes/__tests__/promocode-reducer.test.js @@ -0,0 +1,19 @@ +import { DEFAULT_ENTITY } from "../promocode-reducer"; + +describe("DEFAULT_ENTITY", () => { + it("includes allowed_email_domains as an empty array", () => { + expect(DEFAULT_ENTITY.allowed_email_domains).toEqual([]); + }); + + it("includes quantity_per_account as 0 (unlimited sentinel)", () => { + expect(DEFAULT_ENTITY.quantity_per_account).toBe(0); + }); + + it("includes auto_apply as false", () => { + expect(DEFAULT_ENTITY.auto_apply).toBe(false); + }); + + it("still contains the existing allows_to_reassign field defaulting to true", () => { + expect(DEFAULT_ENTITY.allows_to_reassign).toBe(true); + }); +}); diff --git a/src/reducers/promocodes/promocode-reducer.js b/src/reducers/promocodes/promocode-reducer.js index f87879475..23d38e99d 100644 --- a/src/reducers/promocodes/promocode-reducer.js +++ b/src/reducers/promocodes/promocode-reducer.js @@ -72,7 +72,10 @@ export const DEFAULT_ENTITY = { description: "", tags: [], allows_to_delegate: false, - allows_to_reassign: true + allows_to_reassign: true, + allowed_email_domains: [], + quantity_per_account: 0, + auto_apply: false }; const DEFAULT_STATE = { From defbe27b5da430ee4aea3fc67d63afced436a5bf Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 22 Apr 2026 08:54:27 -0500 Subject: [PATCH 03/32] feat: add i18n strings for domain-authorized promo codes and WithPromoCode audience --- src/i18n/en.json | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/i18n/en.json b/src/i18n/en.json index 09acb8d4a..92ad8a832 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -995,15 +995,25 @@ "promocode_saved": "Promo Code saved successfully.", "promocode_created": "Promo Code created successfully.", "apply_to_all_tix_discounts": "Discounts", + "allowed_email_domains": "Allowed Email Domains", + "quantity_per_account": "Max Per Account", + "auto_apply": "Auto-apply for qualifying users", "placeholders": { "select_badge_features": "Select one or more Badge Features", "select_badge_type": "Select a Badge Type", "select_ticket_types": "Select Ticket Types", "select_class_name": "Select a Class", - "select_tags": "Select Tags" + "select_tags": "Select Tags", + "allowed_email_domains": "Add a domain, TLD, or email and press Enter" }, "info": { - "quantity_available": "If zero is set, then Promo Code has no limit to be used" + "quantity_available": "If zero is set, then Promo Code has no limit to be used", + "allowed_email_domains": "Supported formats: @acme.com (exact domain), .edu (TLD suffix), user@example.com (exact email). Users whose email matches any entry can redeem this code.", + "quantity_per_account": "Maximum tickets a single account can purchase with this code. Enter 0 for unlimited.", + "auto_apply": "When enabled, the registration widget silently applies this code for qualifying users at checkout, without requiring them to enter it manually." + }, + "errors": { + "allowed_email_domains_format": "Each entry must be an exact domain (@acme.com), a TLD suffix (.edu), or a full email address." } }, "discount_ticket": { @@ -1855,6 +1865,8 @@ "sales_end_date": "Sale End", "ticket_type_saved": "Ticket Type saved successfully", "ticket_type_created": "Ticket Type created successfully", + "audience_with_promo_code": "With Promo Code", + "info_audience_with_promo_code": "Hidden from public listings. Only visible when a qualifying promo code includes this ticket type in its Allowed Ticket Types.", "placeholders": { "select_currency": "Select currency", "select_ticket_type": "Select Ticket Type", From dcee4d250c9080dc6fd348d14ec34f84d0d1c891 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 22 Apr 2026 09:12:46 -0500 Subject: [PATCH 04/32] fix: tighten email-domain validation and guard allowed_email_domains null coercion --- .../__tests__/promocode-reducer.test.js | 49 ++++++++++++++++++- src/reducers/promocodes/promocode-reducer.js | 4 ++ ...alidate-allowed-email-domain-entry.test.js | 16 +++++- src/utils/methods.js | 7 ++- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/reducers/promocodes/__tests__/promocode-reducer.test.js b/src/reducers/promocodes/__tests__/promocode-reducer.test.js index 350c696b6..3e0d5c7e8 100644 --- a/src/reducers/promocodes/__tests__/promocode-reducer.test.js +++ b/src/reducers/promocodes/__tests__/promocode-reducer.test.js @@ -1,4 +1,5 @@ -import { DEFAULT_ENTITY } from "../promocode-reducer"; +import promocodeReducer, { DEFAULT_ENTITY } from "../promocode-reducer"; +import { RECEIVE_PROMOCODE } from "../../../actions/promocode-actions"; describe("DEFAULT_ENTITY", () => { it("includes allowed_email_domains as an empty array", () => { @@ -17,3 +18,49 @@ describe("DEFAULT_ENTITY", () => { expect(DEFAULT_ENTITY.allows_to_reassign).toBe(true); }); }); + +describe("RECEIVE_PROMOCODE allowed_email_domains coercion", () => { + it("forces null allowed_email_domains to [] (not empty string)", () => { + const state = promocodeReducer(undefined, { + type: RECEIVE_PROMOCODE, + payload: { + response: { + id: 1, + class_name: "DOMAIN_AUTHORIZED_PROMO_CODE", + allowed_email_domains: null, + ticket_types_rules: [] + } + } + }); + expect(state.entity.allowed_email_domains).toEqual([]); + }); + + it("preserves an array of allowed_email_domains from the server", () => { + const state = promocodeReducer(undefined, { + type: RECEIVE_PROMOCODE, + payload: { + response: { + id: 1, + class_name: "DOMAIN_AUTHORIZED_PROMO_CODE", + allowed_email_domains: ["@acme.com", ".edu"], + ticket_types_rules: [] + } + } + }); + expect(state.entity.allowed_email_domains).toEqual(["@acme.com", ".edu"]); + }); + + it("forces undefined allowed_email_domains to []", () => { + const state = promocodeReducer(undefined, { + type: RECEIVE_PROMOCODE, + payload: { + response: { + id: 1, + class_name: "DOMAIN_AUTHORIZED_PROMO_CODE", + ticket_types_rules: [] + } + } + }); + expect(state.entity.allowed_email_domains).toEqual([]); + }); +}); diff --git a/src/reducers/promocodes/promocode-reducer.js b/src/reducers/promocodes/promocode-reducer.js index 23d38e99d..4c69b804a 100644 --- a/src/reducers/promocodes/promocode-reducer.js +++ b/src/reducers/promocodes/promocode-reducer.js @@ -141,6 +141,10 @@ const promocodeReducer = (state = DEFAULT_STATE, action) => { } } + if (!Array.isArray(entity.allowed_email_domains)) { + entity.allowed_email_domains = []; + } + entity.owner = { email: entity.email, first_name: entity.first_name, diff --git a/src/utils/__tests__/validate-allowed-email-domain-entry.test.js b/src/utils/__tests__/validate-allowed-email-domain-entry.test.js index e5a0f2e6f..f95c343db 100644 --- a/src/utils/__tests__/validate-allowed-email-domain-entry.test.js +++ b/src/utils/__tests__/validate-allowed-email-domain-entry.test.js @@ -29,7 +29,21 @@ describe("validateAllowedEmailDomainEntry", () => { ["@@acme.com"], // double @ ["user @example.com"], // whitespace [null], - [undefined] + [undefined], + ["user@abc"], // no TLD dot + ["user@example..com"], // consecutive dots + ["user@example.com."], // trailing dot + ["user@acme"] // no dot, single-segment domain + ])("rejects %p", (entry) => { + expect(validateAllowedEmailDomainEntry(entry)).toBe(false); + }); + }); + + describe("domain-only invalid entries (ALLOWED_DOMAIN_RE)", () => { + it.each([ + ["@example..com"], // consecutive dots + ["@example.com."], // trailing dot + ["@.com"] // empty first label ])("rejects %p", (entry) => { expect(validateAllowedEmailDomainEntry(entry)).toBe(false); }); diff --git a/src/utils/methods.js b/src/utils/methods.js index 625e7a81e..fb8e3a97c 100644 --- a/src/utils/methods.js +++ b/src/utils/methods.js @@ -285,11 +285,14 @@ export const validateEmail = (email) => /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ ); -// Mirrors summit-api app/Rules/AllowedEmailDomainsArray.php (see SDS line 59). +// Client-side form-validation for the allowed_email_domains field. +// Stricter than summit-api's app/Rules/AllowedEmailDomainsArray.php (see SDS line 59): +// we require at least one dot-separated label (rejects "@acme", "user@abc") so the UI +// surfaces obvious mistakes at entry time. The server remains the authority. // Update in lockstep if the server regex changes. const ALLOWED_DOMAIN_RE = /^@[\w][\w-]*(?:\.[\w][\w-]*)+$/; const ALLOWED_TLD_RE = /^\.[a-z0-9]+(?:\.[a-z0-9]+)*$/i; -const ALLOWED_EMAIL_RE = /^[^@\s]+@[\w][\w.-]+$/; +const ALLOWED_EMAIL_RE = /^[^@\s]+@[\w][\w-]*(?:\.[\w][\w-]*)+$/; export const validateAllowedEmailDomainEntry = (entry) => { if (typeof entry !== "string") return false; From f542f069f7c5cf44df2843215c06e85d88822c84 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 22 Apr 2026 09:28:04 -0500 Subject: [PATCH 05/32] feat: add DomainAuthorizedBasePCForm fragment with domain/per-account/auto-apply fields --- .../domain-authorized-base-pc-form.test.js | 116 +++++++++++++++ .../forms/domain-authorized-base-pc-form.js | 133 ++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 src/components/forms/promocode-form/forms/__tests__/domain-authorized-base-pc-form.test.js create mode 100644 src/components/forms/promocode-form/forms/domain-authorized-base-pc-form.js diff --git a/src/components/forms/promocode-form/forms/__tests__/domain-authorized-base-pc-form.test.js b/src/components/forms/promocode-form/forms/__tests__/domain-authorized-base-pc-form.test.js new file mode 100644 index 000000000..5bc34f767 --- /dev/null +++ b/src/components/forms/promocode-form/forms/__tests__/domain-authorized-base-pc-form.test.js @@ -0,0 +1,116 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import DomainAuthorizedBasePCForm from "../domain-authorized-base-pc-form"; + +// Mock openstack-uicore TagInput as a native input so we can exercise the +// component's onChange path without pulling in the real TagInput runtime. +// (TagInput's onCreate path is covered by the integration test in Task 12, +// where the mock exposes a programmatic hook.) +jest.mock("openstack-uicore-foundation/lib/components", () => { + const React = require("react"); + return { + Input: (props) => + React.createElement("input", { + id: props.id, + type: props.type ?? "text", + min: props.min, + value: props.value ?? "", + onChange: props.onChange, + className: props.className + }), + TagInput: (props) => + React.createElement("input", { + id: props.id, + "data-testid": `taginput-${props.id}`, + value: "", + onChange: () => {} + }) + }; +}); + +const noop = () => {}; +const baseEntity = { + allowed_email_domains: [], + quantity_per_account: 0, + auto_apply: false +}; + +describe("DomainAuthorizedBasePCForm", () => { + it("renders the three new controls", () => { + const { container } = render( + ""} + /> + ); + expect( + container.querySelector("#allowed_email_domains") + ).toBeInTheDocument(); + expect( + container.querySelector("#quantity_per_account") + ).toBeInTheDocument(); + expect(container.querySelector("#auto_apply")).toBeInTheDocument(); + }); + + it("shows visible helper text '0 = unlimited' beneath Max Per Account", () => { + render( + ""} + /> + ); + expect(screen.getByText(/0 = unlimited/i)).toBeInTheDocument(); + }); + + it("does not render the inline domain error on first render", () => { + render( + ""} + /> + ); + expect( + screen.queryByText(/Each entry must be an exact domain/i) + ).not.toBeInTheDocument(); + }); + + it("propagates quantity_per_account changes through props.handleChange", () => { + const handleChange = jest.fn(); + const { container } = render( + ""} + /> + ); + const input = container.querySelector("#quantity_per_account"); + fireEvent.change(input, { + target: { id: "quantity_per_account", value: "5" } + }); + expect(handleChange).toHaveBeenCalled(); + const evt = handleChange.mock.calls[0][0]; + expect(evt.target.id).toBe("quantity_per_account"); + expect(evt.target.value).toBe("5"); + }); + + it("propagates auto_apply toggle through props.handleChange", () => { + const handleChange = jest.fn(); + const { container } = render( + ""} + /> + ); + const checkbox = container.querySelector("#auto_apply"); + fireEvent.click(checkbox); + expect(handleChange).toHaveBeenCalled(); + const evt = handleChange.mock.calls[0][0]; + expect(evt.target.id).toBe("auto_apply"); + expect(evt.target.type).toBe("checkbox"); + }); +}); diff --git a/src/components/forms/promocode-form/forms/domain-authorized-base-pc-form.js b/src/components/forms/promocode-form/forms/domain-authorized-base-pc-form.js new file mode 100644 index 000000000..06f91037e --- /dev/null +++ b/src/components/forms/promocode-form/forms/domain-authorized-base-pc-form.js @@ -0,0 +1,133 @@ +import React, { useState } from "react"; +import T from "i18n-react"; +import { Input, TagInput } from "openstack-uicore-foundation/lib/components"; +import { validateAllowedEmailDomainEntry } from "../../../../utils/methods"; + +const normalizeTagValues = (value) => { + if (!Array.isArray(value)) return []; + return value.map((entry) => + typeof entry === "string" ? entry : entry?.value ?? entry?.label ?? "" + ); +}; + +const DomainAuthorizedBasePCForm = (props) => { + const { entity, handleChange } = props; + const [domainsError, setDomainsError] = useState(""); + + const domains = Array.isArray(entity.allowed_email_domains) + ? entity.allowed_email_domains + : []; + + const domainsAsTags = domains.map((d) => ({ value: d, label: d })); + + const fireChange = (id, value, type = "text") => { + handleChange({ target: { id, value, type } }); + }; + + const handleQuantityChange = (ev) => { + fireChange("quantity_per_account", ev.target.value, "number"); + }; + + const handleAutoApplyChange = (ev) => { + fireChange("auto_apply", ev.target.checked, "checkbox"); + }; + + const handleDomainsChange = (ev) => { + // TagInput emits the full new array on removal/reorder. + const next = normalizeTagValues(ev?.target?.value ?? []); + fireChange("allowed_email_domains", next); + setDomainsError(""); + }; + + const handleNewDomain = (newEntry) => { + const trimmed = (newEntry ?? "").trim(); + if (!validateAllowedEmailDomainEntry(trimmed)) { + setDomainsError( + T.translate("edit_promocode.errors.allowed_email_domains_format") + ); + return; + } + if (domains.includes(trimmed)) { + setDomainsError(""); + return; + } + setDomainsError(""); + fireChange("allowed_email_domains", [...domains, trimmed]); + }; + + return ( + <> +
+
+