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:
+51
-23
@@ -4,7 +4,7 @@ import { SiteHeader, SiteFooter } from "@/components/SiteChrome";
|
|||||||
import { isStaffKeyValid } from "@/lib/staff-auth";
|
import { isStaffKeyValid } from "@/lib/staff-auth";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: Promise<{ org?: string; key?: string }>;
|
searchParams: Promise<{ org?: string; key?: string; frame?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -13,12 +13,13 @@ export const metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function StaffReportPage({ searchParams }: PageProps) {
|
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
|
// Generic "not found" if the key is missing or wrong — don't confirm
|
||||||
// route existence.
|
// route existence.
|
||||||
if (!isStaffKeyValid(key)) {
|
if (!isStaffKeyValid(key)) {
|
||||||
return <NotFound />;
|
return <NotFound framed={isFramed} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId = Number(org);
|
const orgId = Number(org);
|
||||||
@@ -27,6 +28,35 @@ export default async function StaffReportPage({ searchParams }: PageProps) {
|
|||||||
// outbound file links. No secret material is exposed.
|
// outbound file links. No secret material is exposed.
|
||||||
const civiBaseUrl = process.env.CIVI_BASE_URL ?? "";
|
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 = (
|
||||||
|
<main id="main" className="flex-1">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isFramed
|
||||||
|
? "mx-auto max-w-5xl px-3 py-4"
|
||||||
|
: "mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{orgValid ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<StaffReportView
|
||||||
|
org={orgId}
|
||||||
|
authKey={key!}
|
||||||
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
framed={isFramed}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<MissingOrg />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isFramed) return body;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<a
|
<a
|
||||||
@@ -36,34 +66,32 @@ export default async function StaffReportPage({ searchParams }: PageProps) {
|
|||||||
Skip to content
|
Skip to content
|
||||||
</a>
|
</a>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main id="main" className="flex-1">
|
{body}
|
||||||
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14">
|
|
||||||
{orgValid ? (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<StaffReportView org={orgId} authKey={key!} civiBaseUrl={civiBaseUrl} />
|
|
||||||
</Suspense>
|
|
||||||
) : (
|
|
||||||
<MissingOrg />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotFound() {
|
function NotFound({ framed }: { framed: boolean }) {
|
||||||
|
const inner = (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
framed
|
||||||
|
? "mx-auto max-w-2xl px-4 py-8 text-center"
|
||||||
|
: "mx-auto max-w-2xl px-4 py-20 text-center"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 className="font-display text-3xl text-ink">Not found</h1>
|
||||||
|
<p className="mt-3 text-ink-soft">
|
||||||
|
The page you requested doesn't exist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (framed) return <main className="flex-1">{inner}</main>;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main className="flex-1">
|
<main className="flex-1">{inner}</main>
|
||||||
<div className="mx-auto max-w-2xl px-4 py-20 text-center">
|
|
||||||
<h1 className="font-display text-3xl text-ink">Not found</h1>
|
|
||||||
<p className="mt-3 text-ink-soft">
|
|
||||||
The page you requested doesn't exist.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -25,6 +25,8 @@ interface StaffReportViewProps {
|
|||||||
authKey: string;
|
authKey: string;
|
||||||
/** CIVI_BASE_URL, used to build outbound file links. */
|
/** CIVI_BASE_URL, used to build outbound file links. */
|
||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
|
/** True when the page is being embedded in a CiviCRM tab via iframe. */
|
||||||
|
framed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadState =
|
type LoadState =
|
||||||
@@ -34,7 +36,12 @@ type LoadState =
|
|||||||
|
|
||||||
const STAGE_OPTION_GROUP_ID = 75;
|
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" });
|
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,6 +73,28 @@ export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewPr
|
|||||||
};
|
};
|
||||||
}, [org, authKey]);
|
}, [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 === "loading") return <LoadingState />;
|
||||||
if (load.kind === "error") return <ErrorState message={load.message} />;
|
if (load.kind === "error") return <ErrorState message={load.message} />;
|
||||||
const { data } = load;
|
const { data } = load;
|
||||||
@@ -106,7 +135,11 @@ export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewPr
|
|||||||
<div className="h-px bg-rule" />
|
<div className="h-px bg-rule" />
|
||||||
</header>
|
</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 ? (
|
{membersField && membersField.history.length > 0 ? (
|
||||||
<MembershipChart
|
<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({
|
function SectionAnchorNav({
|
||||||
sections,
|
sections,
|
||||||
hasActivities,
|
hasActivities,
|
||||||
|
framed,
|
||||||
}: {
|
}: {
|
||||||
sections: StaffReportSection[];
|
sections: StaffReportSection[];
|
||||||
hasActivities: boolean;
|
hasActivities: boolean;
|
||||||
|
framed: boolean;
|
||||||
}) {
|
}) {
|
||||||
const items = sections.map((s) => ({
|
const items = sections.map((s) => ({
|
||||||
href: `#section-${s.groupName}`,
|
href: `#section-${s.groupName}`,
|
||||||
label: s.groupKind === "org" ? "Org profile" : s.groupTitle,
|
label: s.groupKind === "org" ? "Org profile" : s.groupTitle,
|
||||||
}));
|
}));
|
||||||
if (hasActivities) items.push({ href: "#section-submissions", label: "Submissions" });
|
if (hasActivities) items.push({ href: "#section-submissions", label: "Submissions" });
|
||||||
|
const stickyCls = framed ? "" : "sticky top-0 z-30 backdrop-blur";
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label="Section navigation"
|
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]">
|
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px]">
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
|
|||||||
+56
-31
@@ -2,41 +2,37 @@ import path from "node:path";
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security headers applied to every response.
|
* Security headers.
|
||||||
*
|
*
|
||||||
* Notes on each:
|
* Two profiles:
|
||||||
* - CSP: tight default; allows Google Fonts (next/font) and the same-origin
|
* - strict (default): frame-ancestors 'none' + X-Frame-Options: DENY.
|
||||||
* /api routes. No third-party scripts. `frame-ancestors 'none'` prevents
|
* Applied to every route except /staff/report.
|
||||||
* this app being embedded in another site's iframe.
|
* - staff-embed: frame-ancestors 'self' <civi-origin>, no X-Frame-Options.
|
||||||
* - HSTS: only meaningful behind HTTPS (Render terminates TLS, so this is
|
* Lets the CiviCRM "Engagement Report" extension embed the staff page
|
||||||
* correct in production).
|
* in an iframe on contact pages.
|
||||||
* - Permissions-Policy: drop everything we don't use.
|
*
|
||||||
* - Referrer-Policy: same-origin — never leak the cid+cs query string to
|
* The catch-all source uses a negative lookahead so it does NOT match
|
||||||
* other origins via the Referer header.
|
* /staff/report — otherwise both rules apply and the browser ANDs the
|
||||||
* - X-Content-Type-Options: prevents MIME sniffing.
|
* 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 isDev = process.env.NODE_ENV !== "production";
|
||||||
const devOnlyDynamicScript = isDev ? " 'unsafe-eval'" : "";
|
const devOnlyDynamicScript = isDev ? " 'unsafe-eval'" : "";
|
||||||
|
|
||||||
const securityHeaders = [
|
const buildCsp = (frameAncestors: string) =>
|
||||||
{
|
[
|
||||||
key: "Content-Security-Policy",
|
"default-src 'self'",
|
||||||
value: [
|
`script-src 'self' 'unsafe-inline'${devOnlyDynamicScript}`,
|
||||||
"default-src 'self'",
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
`script-src 'self' 'unsafe-inline'${devOnlyDynamicScript}`,
|
"font-src 'self' https://fonts.gstatic.com data:",
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
"img-src 'self' data:",
|
||||||
"font-src 'self' https://fonts.gstatic.com data:",
|
"connect-src 'self'",
|
||||||
"img-src 'self' data:",
|
`frame-ancestors ${frameAncestors}`,
|
||||||
"connect-src 'self'",
|
"form-action 'self'",
|
||||||
"frame-ancestors 'none'",
|
"base-uri 'self'",
|
||||||
"form-action 'self'",
|
"object-src 'none'",
|
||||||
"base-uri 'self'",
|
].join("; ");
|
||||||
"object-src 'none'",
|
|
||||||
].join("; "),
|
const sharedHeaders = [
|
||||||
},
|
|
||||||
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
|
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
|
||||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
{ key: "Referrer-Policy", value: "same-origin" },
|
{ key: "Referrer-Policy", value: "same-origin" },
|
||||||
@@ -44,9 +40,34 @@ const securityHeaders = [
|
|||||||
key: "Permissions-Policy",
|
key: "Permissions-Policy",
|
||||||
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
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" },
|
{ 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 = {
|
const nextConfig: NextConfig = {
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
@@ -61,7 +82,11 @@ const nextConfig: NextConfig = {
|
|||||||
root: path.resolve(__dirname),
|
root: path.resolve(__dirname),
|
||||||
},
|
},
|
||||||
async headers() {
|
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 },
|
||||||
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user