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:
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user