Staff report: CSP frame-ancestors + frame-mode + WebForm-mw Civi extension

App side:
- Per-route CSP: /staff/report now sets frame-ancestors 'self'
  <CIVI_BASE_URL origin> and drops X-Frame-Options so the CiviCRM
  extension can iframe it. All other routes keep frame-ancestors
  'none' + X-Frame-Options: DENY via a path-negation source.
- Staff page recognises ?frame=1 and renders without SiteHeader/
  SiteFooter so it fills the iframe cleanly.
- StaffReportView posts its scrollHeight to the parent window via
  postMessage when framed; the Civi tab listens and auto-resizes
  the iframe (no nested scrollbar). Anchor strip drops its sticky
  positioning in frame mode since there's no internal scroll.

CiviCRM extension (civi-extension/webform-mw/, key webform-mw):
- info.xml + main hook file (webform_mw.php) implementing
  hook_civicrm_tabset to add an 'Engagement Report' tab to
  Organization contact-view pages.
- CRM/WebformMw/Page/Tab.php + Smarty template render an iframe
  pointing at <WEBFORM_MW_APP_URL>/staff/report?org=<cid>&key=&frame=1,
  with a postMessage listener that validates event.origin against
  the configured app URL before resizing.
- Config via PHP constants in civicrm.settings.php (WEBFORM_MW_APP_URL,
  WEBFORM_MW_STAFF_KEY) or matching env vars. Help banner shown when
  unconfigured.
- README documents install, config, behaviour, security caveats.
This commit is contained in:
Joel Brock
2026-06-05 17:42:35 -07:00
parent b548b6425b
commit 4ca3c194d7
9 changed files with 457 additions and 58 deletions
+46 -4
View File
@@ -25,6 +25,8 @@ interface StaffReportViewProps {
authKey: string;
/** CIVI_BASE_URL, used to build outbound file links. */
civiBaseUrl: string;
/** True when the page is being embedded in a CiviCRM tab via iframe. */
framed?: boolean;
}
type LoadState =
@@ -34,7 +36,12 @@ type LoadState =
const STAGE_OPTION_GROUP_ID = 75;
export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewProps) {
export function StaffReportView({
org,
authKey,
civiBaseUrl,
framed = false,
}: StaffReportViewProps) {
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
useEffect(() => {
@@ -66,6 +73,28 @@ export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewPr
};
}, [org, authKey]);
// When embedded, post our content height to the parent so the Civi tab's
// iframe can resize to fit (no nested scrollbars). The receiving script
// lives in the WebForm-mw Civi extension's tab template.
useEffect(() => {
if (!framed || typeof window === "undefined") return;
if (window.parent === window) return;
const post = () => {
window.parent.postMessage(
{ type: "webform-mw-height", height: document.documentElement.scrollHeight },
"*",
);
};
post();
const ro = new ResizeObserver(post);
ro.observe(document.documentElement);
window.addEventListener("load", post);
return () => {
ro.disconnect();
window.removeEventListener("load", post);
};
}, [framed, load]);
if (load.kind === "loading") return <LoadingState />;
if (load.kind === "error") return <ErrorState message={load.message} />;
const { data } = load;
@@ -106,7 +135,11 @@ export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewPr
<div className="h-px bg-rule" />
</header>
<SectionAnchorNav sections={data.sections} hasActivities={data.activities.length > 0} />
<SectionAnchorNav
sections={data.sections}
hasActivities={data.activities.length > 0}
framed={framed}
/>
{membersField && membersField.history.length > 0 ? (
<MembershipChart
@@ -140,23 +173,32 @@ function Stat({ label, value }: { label: string; value: React.ReactNode }) {
);
}
/** Sticky horizontal anchor strip — one chip per section + Submissions. */
/**
* Horizontal anchor strip — one chip per section + Submissions.
*
* Sticky in standalone mode; non-sticky when embedded in a CiviCRM tab
* (the iframe auto-resizes to fit content so there's no internal scroll
* for `sticky` to engage against).
*/
function SectionAnchorNav({
sections,
hasActivities,
framed,
}: {
sections: StaffReportSection[];
hasActivities: boolean;
framed: boolean;
}) {
const items = sections.map((s) => ({
href: `#section-${s.groupName}`,
label: s.groupKind === "org" ? "Org profile" : s.groupTitle,
}));
if (hasActivities) items.push({ href: "#section-submissions", label: "Submissions" });
const stickyCls = framed ? "" : "sticky top-0 z-30 backdrop-blur";
return (
<nav
aria-label="Section navigation"
className="sticky top-0 z-30 -mx-4 border-y border-rule bg-paper/95 px-4 py-2 backdrop-blur sm:-mx-6 sm:px-6"
className={`${stickyCls} -mx-4 border-y border-rule bg-paper/95 px-4 py-2 sm:-mx-6 sm:px-6`}
>
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px]">
{items.map((it) => (