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:
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Engagement Report tab page.
|
||||
*
|
||||
* Loaded as an AJAX snippet by the contact-view tabset (snippet=1). The
|
||||
* template emits a single iframe pointing at the WebForm-mw staff report
|
||||
* plus a small postMessage listener that auto-sizes the iframe to the
|
||||
* report's content height.
|
||||
*/
|
||||
class CRM_WebformMw_Page_Tab extends CRM_Core_Page {
|
||||
|
||||
public function run() {
|
||||
$cid = CRM_Utils_Request::retrieve('cid', 'Positive', $this, TRUE);
|
||||
|
||||
$appUrl = _webform_mw_app_url();
|
||||
$key = _webform_mw_staff_key();
|
||||
$configured = ($appUrl !== '' && $key !== '');
|
||||
|
||||
if ($configured) {
|
||||
$src = $appUrl . '/staff/report'
|
||||
. '?org=' . urlencode((string) $cid)
|
||||
. '&key=' . urlencode($key)
|
||||
. '&frame=1';
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
@@ -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 `<civi-root>/sites/default/ext/` or wherever
|
||||
`[civicrm.extensionsDir]` points in your `civicrm.settings.php`).
|
||||
- The directory name on disk must be `webform-mw` (matching `<key>` 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=<cid>&key=<secret>&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 `<access_arguments>` 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 <app>/staff/report` after
|
||||
deploy — you should see `Content-Security-Policy: ... frame-ancestors
|
||||
'self' https://<your-civi-host> ...`.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0"?>
|
||||
<extension key="webform-mw" type="module">
|
||||
<file>webform_mw</file>
|
||||
<name>WebForm-mw</name>
|
||||
<description>
|
||||
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.
|
||||
</description>
|
||||
<license>AGPL-3.0</license>
|
||||
<maintainer>
|
||||
<author>Food Co-op Initiative</author>
|
||||
<email>survey@fci.coop</email>
|
||||
</maintainer>
|
||||
<urls>
|
||||
<url desc="Main Extension Page">https://github.com/joelbrock/WebForm-mw</url>
|
||||
</urls>
|
||||
<releaseDate>2026-06-05</releaseDate>
|
||||
<version>0.1.0</version>
|
||||
<develStage>beta</develStage>
|
||||
<compatibility>
|
||||
<ver>5.50</ver>
|
||||
</compatibility>
|
||||
<comments>
|
||||
Configuration is via PHP constants in civicrm.settings.php (or
|
||||
environment variables on the CiviCRM server). See README.md.
|
||||
</comments>
|
||||
<classloader>
|
||||
<psr0 prefix="CRM_" path=""/>
|
||||
</classloader>
|
||||
<civix>
|
||||
<namespace>CRM/WebformMw</namespace>
|
||||
<format>22.05.0</format>
|
||||
</civix>
|
||||
</extension>
|
||||
@@ -0,0 +1,40 @@
|
||||
{* Engagement Report tab content. *}
|
||||
{if $configured}
|
||||
<div id="wfmw-engagement-report-wrap" style="margin:-1em -1em 0 -1em;">
|
||||
<iframe
|
||||
id="wfmw-engagement-report"
|
||||
src="{$iframeSrc|escape:'htmlall'}"
|
||||
title="Engagement Report"
|
||||
style="width:100%;height:1200px;border:0;display:block;background:transparent;"
|
||||
referrerpolicy="same-origin"
|
||||
loading="eager"
|
||||
></iframe>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var APP_ORIGIN = {$appOrigin|json_encode};
|
||||
var frame = document.getElementById('wfmw-engagement-report');
|
||||
if (!frame) return;
|
||||
window.addEventListener('message', function (e) {
|
||||
if (APP_ORIGIN && e.origin !== APP_ORIGIN) return;
|
||||
var d = e && e.data;
|
||||
if (!d || d.type !== 'webform-mw-height' || typeof d.height !== 'number') return;
|
||||
// Add a little headroom so the report's own bottom padding isn't clipped.
|
||||
var h = Math.max(600, Math.floor(d.height) + 24);
|
||||
frame.style.height = h + 'px';
|
||||
}, false);
|
||||
})();
|
||||
</script>
|
||||
{else}
|
||||
<div class="messages status no-popup">
|
||||
<p><strong>{ts}Engagement Report is not configured.{/ts}</strong></p>
|
||||
<p>
|
||||
{ts}Add the following to your <code>civicrm.settings.php</code> (or set both as environment variables on the CiviCRM server) and reload:{/ts}
|
||||
</p>
|
||||
<pre>define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop');
|
||||
define('WEBFORM_MW_STAFF_KEY', '...the STAFF_REPORT_KEY shared with the app...');</pre>
|
||||
<p>
|
||||
{ts}See the extension's README.md for details.{/ts}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* WebForm-mw CiviCRM extension.
|
||||
*
|
||||
* Adds an "Engagement Report" tab to Organization contact-view pages that
|
||||
* embeds the WebForm-mw Next.js staff report via an iframe.
|
||||
*
|
||||
* Configuration (in civicrm.settings.php or as env vars on the Civi server):
|
||||
*
|
||||
* define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop');
|
||||
* define('WEBFORM_MW_STAFF_KEY', '<the STAFF_REPORT_KEY shared with the app>');
|
||||
*
|
||||
* 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"
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1" ?>
|
||||
<menu>
|
||||
<item>
|
||||
<path>civicrm/contact/view/engagement-report</path>
|
||||
<title>Engagement Report</title>
|
||||
<page_callback>CRM_WebformMw_Page_Tab</page_callback>
|
||||
<access_arguments>access CiviCRM</access_arguments>
|
||||
</item>
|
||||
</menu>
|
||||
Reference in New Issue
Block a user