diff --git a/app/staff/report/page.tsx b/app/staff/report/page.tsx index 029ceef..f0eef31 100644 --- a/app/staff/report/page.tsx +++ b/app/staff/report/page.tsx @@ -4,7 +4,7 @@ import { SiteHeader, SiteFooter } from "@/components/SiteChrome"; import { isStaffKeyValid } from "@/lib/staff-auth"; interface PageProps { - searchParams: Promise<{ org?: string; key?: string }>; + searchParams: Promise<{ org?: string; key?: string; frame?: string }>; } export const metadata = { @@ -13,12 +13,13 @@ export const metadata = { }; export default async function StaffReportPage({ searchParams }: PageProps) { - const { org, key } = await searchParams; + const { org, key, frame } = await searchParams; + const isFramed = frame === "1"; // Generic "not found" if the key is missing or wrong — don't confirm // route existence. if (!isStaffKeyValid(key)) { - return ; + return ; } const orgId = Number(org); @@ -27,6 +28,35 @@ export default async function StaffReportPage({ searchParams }: PageProps) { // outbound file links. No secret material is exposed. const civiBaseUrl = process.env.CIVI_BASE_URL ?? ""; + // When embedded in CiviCRM (?frame=1), drop the site header/footer so the + // report fills the iframe cleanly. Standalone visits keep full chrome. + const body = ( + + + {orgValid ? ( + + + + ) : ( + + )} + + + ); + + if (isFramed) return body; + return ( <> - - - {orgValid ? ( - - - - ) : ( - - )} - - + {body} > ); } -function NotFound() { +function NotFound({ framed }: { framed: boolean }) { + const inner = ( + + Not found + + The page you requested doesn't exist. + + + ); + if (framed) return {inner}; return ( <> - - - Not found - - The page you requested doesn't exist. - - - + {inner} > ); diff --git a/civi-extension/webform-mw/CRM/WebformMw/Page/Tab.php b/civi-extension/webform-mw/CRM/WebformMw/Page/Tab.php new file mode 100644 index 0000000..4a07a9f --- /dev/null +++ b/civi-extension/webform-mw/CRM/WebformMw/Page/Tab.php @@ -0,0 +1,35 @@ +assign('iframeSrc', $src); + // Expose just the app origin so the parent-side postMessage + // listener can validate event.origin without leaking the secret. + $this->assign('appOrigin', parse_url($appUrl, PHP_URL_SCHEME) + . '://' . parse_url($appUrl, PHP_URL_HOST)); + } + $this->assign('configured', $configured); + + parent::run(); + } +} diff --git a/civi-extension/webform-mw/README.md b/civi-extension/webform-mw/README.md new file mode 100644 index 0000000..c9dfce3 --- /dev/null +++ b/civi-extension/webform-mw/README.md @@ -0,0 +1,103 @@ +# WebForm-mw — CiviCRM extension + +Adds an **Engagement Report** tab to Organization contact-view pages that +embeds the FCI Co-op Survey staff report +(`https://survey.fci.coop/staff/report`) in an iframe. The tab shows the +report for the current organization, scoped by the contact id in the URL. + +The embedded app handles its own auth via a shared staff secret. The +extension is otherwise read-only and adds no Civi tables, custom fields, +or scheduled jobs. + +## Install + +1. Copy the entire `webform-mw/` directory to your CiviCRM extensions + directory (typically `/sites/default/ext/` or wherever + `[civicrm.extensionsDir]` points in your `civicrm.settings.php`). + - The directory name on disk must be `webform-mw` (matching `` in + `info.xml`). +2. In CiviCRM: + `Administer → System Settings → Extensions → Add new → Refresh`, + then click **Install** next to "WebForm-mw". +3. Configure (see next section). The tab will be hidden until both + settings are present. + +## Configuration + +The extension reads two values, in this order: + +1. Constants in `civicrm.settings.php` (preferred): + + ```php + define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop'); + define('WEBFORM_MW_STAFF_KEY', '...the STAFF_REPORT_KEY shared with the app...'); + ``` + +2. Or environment variables (`WEBFORM_MW_APP_URL`, `WEBFORM_MW_STAFF_KEY`) + set wherever PHP-FPM / the web server reads its environment from. + +`WEBFORM_MW_STAFF_KEY` must match the `STAFF_REPORT_KEY` configured on the +Next.js app (Amplify environment / SSM Parameter Store). The two are the +same shared secret; rotate them together. + +`WEBFORM_MW_APP_URL` is the public base URL of the WebForm-mw deployment +(no trailing slash). Production: `https://survey.fci.coop`. + +When either value is missing, the tab body shows a help banner with the +exact configuration snippet to paste, so anyone installing the extension +without prior context can self-serve. + +## Behaviour + +- Tab title: **Engagement Report**. +- Visible only on **Organization** contacts (Individuals and Households + see no tab). The check happens server-side in the tabset hook. +- Tab body is an iframe pointing at + `${WEBFORM_MW_APP_URL}/staff/report?org=&key=&frame=1`. +- `frame=1` tells the embedded app to suppress its site header/footer + and emit a `postMessage({type:"webform-mw-height", height})` payload + on render and on resize. The tab's small inline script listens for + this message and auto-sizes the iframe so there's no nested scrollbar. +- The script validates the postMessage `event.origin` against the + configured `WEBFORM_MW_APP_URL` origin before resizing. + +## Security notes + +- The staff secret travels with each tab render inside the iframe `src`. + Anyone permitted to view the Engagement Report tab (i.e., anyone with + CiviCRM access) can read the URL in their browser's DevTools and reuse + the secret to view any org's report. Acceptable model if "CiviCRM + access" and "should view any staff report" overlap; otherwise consider + upgrading the embedded app to per-user signed tokens and minting them + in the page controller. +- The extension uses `access CiviCRM` as its access argument — anyone + with that permission sees the tab on Organization contacts. Tighten by + changing the `` value in `xml/Menu/webform_mw.xml` + to a more specific permission (e.g., `view all contacts`) and + reinstalling. +- The embedded app's CSP (`frame-ancestors`) must include the CiviCRM + origin or the iframe will refuse to render. The Next app reads + `CIVI_BASE_URL` at build time and adds its origin to the staff route's + CSP automatically. Confirm with `curl -I /staff/report` after + deploy — you should see `Content-Security-Policy: ... frame-ancestors + 'self' https:// ...`. + +## Files + +- `info.xml` — extension manifest. Key `webform-mw`, type `module`. +- `webform_mw.php` — `hook_civicrm_tabset` + config readers. +- `CRM/WebformMw/Page/Tab.php` — page controller for the tab snippet. +- `templates/CRM/WebformMw/Page/Tab.tpl` — iframe + height-listener. +- `xml/Menu/webform_mw.xml` — registers the + `civicrm/contact/view/engagement-report` URL. + +## Versions tested + +- CiviCRM 5.50+ +- PHP 7.4+ / 8.x +- Tested against Backdrop-as-host and Drupal-as-host CiviCRM deployments. + +## Uninstalling + +`Administer → Extensions → Disable`, then `Uninstall`. Removes nothing +from the database; the extension stores no Civi data of its own. diff --git a/civi-extension/webform-mw/info.xml b/civi-extension/webform-mw/info.xml new file mode 100644 index 0000000..4e79e14 --- /dev/null +++ b/civi-extension/webform-mw/info.xml @@ -0,0 +1,36 @@ + + + webform_mw + WebForm-mw + + Adds an "Engagement Report" tab to Organization contact pages that embeds + the FCI Co-op Survey staff report (the WebForm-mw Next.js app) for the + organization. Read-only; the embedded app handles its own auth via a + shared staff secret. + + AGPL-3.0 + + Food Co-op Initiative + survey@fci.coop + + + https://github.com/joelbrock/WebForm-mw + + 2026-06-05 + 0.1.0 + beta + + 5.50 + + + Configuration is via PHP constants in civicrm.settings.php (or + environment variables on the CiviCRM server). See README.md. + + + + + + CRM/WebformMw + 22.05.0 + + diff --git a/civi-extension/webform-mw/templates/CRM/WebformMw/Page/Tab.tpl b/civi-extension/webform-mw/templates/CRM/WebformMw/Page/Tab.tpl new file mode 100644 index 0000000..f519244 --- /dev/null +++ b/civi-extension/webform-mw/templates/CRM/WebformMw/Page/Tab.tpl @@ -0,0 +1,40 @@ +{* Engagement Report tab content. *} +{if $configured} + + + + +{else} + + {ts}Engagement Report is not configured.{/ts} + + {ts}Add the following to your civicrm.settings.php (or set both as environment variables on the CiviCRM server) and reload:{/ts} + + define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop'); +define('WEBFORM_MW_STAFF_KEY', '...the STAFF_REPORT_KEY shared with the app...'); + + {ts}See the extension's README.md for details.{/ts} + + +{/if} diff --git a/civi-extension/webform-mw/webform_mw.php b/civi-extension/webform-mw/webform_mw.php new file mode 100644 index 0000000..b6a38ea --- /dev/null +++ b/civi-extension/webform-mw/webform_mw.php @@ -0,0 +1,81 @@ +'); + * + * Both values must be set or the tab renders a help banner explaining what + * to configure. See README.md. + */ + +/** + * Resolve the app URL from constant or env. Empty string when unset. + */ +function _webform_mw_app_url(): string { + if (defined('WEBFORM_MW_APP_URL')) { + return rtrim((string) constant('WEBFORM_MW_APP_URL'), '/'); + } + $env = getenv('WEBFORM_MW_APP_URL'); + return is_string($env) && $env !== '' ? rtrim($env, '/') : ''; +} + +/** + * Resolve the staff secret from constant or env. Empty string when unset. + */ +function _webform_mw_staff_key(): string { + if (defined('WEBFORM_MW_STAFF_KEY')) { + return (string) constant('WEBFORM_MW_STAFF_KEY'); + } + $env = getenv('WEBFORM_MW_STAFF_KEY'); + return is_string($env) ? $env : ''; +} + +/** + * Implements hook_civicrm_tabset(). + * + * Adds the Engagement Report tab to the Organization contact summary tabset. + * Other contact types (Individual, Household) get no tab. + */ +function webform_mw_civicrm_tabset($tabsetName, &$tabs, $context) { + if ($tabsetName !== 'civicrm/contact/view') { + return; + } + $cid = $context['contact_id'] ?? NULL; + if (!$cid) { + return; + } + // Restrict to Organization contacts. + $contactType = NULL; + try { + $contactType = civicrm_api3('Contact', 'getvalue', [ + 'id' => (int) $cid, + 'return' => 'contact_type', + ]); + } + catch (\Throwable $e) { + // Quietly skip — failing here should not break the contact page. + return; + } + if ($contactType !== 'Organization') { + return; + } + + $tabs[] = [ + 'id' => 'engagement_report', + 'title' => ts('Engagement Report'), + 'weight' => 200, + 'count' => NULL, + 'icon' => 'crm-i fa-line-chart', + 'url' => CRM_Utils_System::url( + 'civicrm/contact/view/engagement-report', + "reset=1&cid={$cid}&snippet=1" + ), + ]; +} diff --git a/civi-extension/webform-mw/xml/Menu/webform_mw.xml b/civi-extension/webform-mw/xml/Menu/webform_mw.xml new file mode 100644 index 0000000..a9c1494 --- /dev/null +++ b/civi-extension/webform-mw/xml/Menu/webform_mw.xml @@ -0,0 +1,9 @@ + + + + civicrm/contact/view/engagement-report + Engagement Report + CRM_WebformMw_Page_Tab + access CiviCRM + + diff --git a/components/StaffReportView.tsx b/components/StaffReportView.tsx index a256fac..3c610e7 100644 --- a/components/StaffReportView.tsx +++ b/components/StaffReportView.tsx @@ -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({ 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 ; if (load.kind === "error") return ; const { data } = load; @@ -106,7 +135,11 @@ export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewPr - 0} /> + 0} + framed={framed} + /> {membersField && membersField.history.length > 0 ? ( ({ 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 ( {items.map((it) => ( diff --git a/next.config.ts b/next.config.ts index a2e96ff..57511fa 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,41 +2,37 @@ import path from "node:path"; import type { NextConfig } from "next"; /** - * Security headers applied to every response. + * Security headers. * - * Notes on each: - * - CSP: tight default; allows Google Fonts (next/font) and the same-origin - * /api routes. No third-party scripts. `frame-ancestors 'none'` prevents - * this app being embedded in another site's iframe. - * - HSTS: only meaningful behind HTTPS (Render terminates TLS, so this is - * correct in production). - * - Permissions-Policy: drop everything we don't use. - * - Referrer-Policy: same-origin — never leak the cid+cs query string to - * other origins via the Referer header. - * - X-Content-Type-Options: prevents MIME sniffing. + * Two profiles: + * - strict (default): frame-ancestors 'none' + X-Frame-Options: DENY. + * Applied to every route except /staff/report. + * - staff-embed: frame-ancestors 'self' , no X-Frame-Options. + * Lets the CiviCRM "Engagement Report" extension embed the staff page + * in an iframe on contact pages. + * + * The catch-all source uses a negative lookahead so it does NOT match + * /staff/report — otherwise both rules apply and the browser ANDs the + * frame-ancestors directives together, blocking embedding entirely. */ -// Next.js React dev runtime uses dynamic-script execution for fast-refresh, -// error overlays, and source-map reconstruction. Permit that ONLY in dev so -// HMR works; production CSP stays strict (no dynamic execution allowed). const isDev = process.env.NODE_ENV !== "production"; const devOnlyDynamicScript = isDev ? " 'unsafe-eval'" : ""; -const securityHeaders = [ - { - key: "Content-Security-Policy", - value: [ - "default-src 'self'", - `script-src 'self' 'unsafe-inline'${devOnlyDynamicScript}`, - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", - "font-src 'self' https://fonts.gstatic.com data:", - "img-src 'self' data:", - "connect-src 'self'", - "frame-ancestors 'none'", - "form-action 'self'", - "base-uri 'self'", - "object-src 'none'", - ].join("; "), - }, +const buildCsp = (frameAncestors: string) => + [ + "default-src 'self'", + `script-src 'self' 'unsafe-inline'${devOnlyDynamicScript}`, + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com data:", + "img-src 'self' data:", + "connect-src 'self'", + `frame-ancestors ${frameAncestors}`, + "form-action 'self'", + "base-uri 'self'", + "object-src 'none'", + ].join("; "); + +const sharedHeaders = [ { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "same-origin" }, @@ -44,9 +40,34 @@ const securityHeaders = [ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", }, +]; + +function civiOriginForCsp(): string { + const raw = process.env.CIVI_BASE_URL; + if (!raw) return ""; + try { + return new URL(raw).origin; + } catch { + return ""; + } +} + +const strictHeaders = [ + { key: "Content-Security-Policy", value: buildCsp("'none'") }, + ...sharedHeaders, { key: "X-Frame-Options", value: "DENY" }, ]; +const staffEmbedHeaders = (() => { + const origin = civiOriginForCsp(); + const frameAncestors = origin ? `'self' ${origin}` : "'self'"; + return [ + { key: "Content-Security-Policy", value: buildCsp(frameAncestors) }, + ...sharedHeaders, + // Intentionally NO X-Frame-Options: frame-ancestors above is the policy. + ]; +})(); + const nextConfig: NextConfig = { poweredByHeader: false, reactStrictMode: true, @@ -61,7 +82,11 @@ const nextConfig: NextConfig = { root: path.resolve(__dirname), }, async headers() { - return [{ source: "/:path*", headers: securityHeaders }]; + return [ + { source: "/staff/report", headers: staffEmbedHeaders }, + // Catch-all that explicitly excludes /staff/report — see header notes. + { source: "/((?!staff/report).*)", headers: strictHeaders }, + ]; }, };
+ The page you requested doesn't exist. +
- The page you requested doesn't exist. -
{ts}Engagement Report is not configured.{/ts}
+ {ts}Add the following to your civicrm.settings.php (or set both as environment variables on the CiviCRM server) and reload:{/ts} +
civicrm.settings.php
define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop'); +define('WEBFORM_MW_STAFF_KEY', '...the STAFF_REPORT_KEY shared with the app...');
+ {ts}See the extension's README.md for details.{/ts} +