Compare commits

..
13 Commits
Author SHA1 Message Date
Joel Brock a0ebef6bc5 copy edit, form subheader 2026-06-22 10:28:10 -07:00
Joel Brock e4085aa0f3 Lightbox: pin and size to visible viewport when framed
The staff-report iframe is expanded to full content height (no nested
scrollbar), so a modal <dialog> centered in its viewport landed in the
middle of the whole document and sat off-screen unless the parent page
was scrolled there. Cross-origin means the iframe can't read the
parent's scroll position itself.

Tab.tpl now broadcasts the iframe's visible slice (webform-mw-viewport,
rAF-throttled on scroll/resize/load and after each height change), and
AttachmentLightbox pins to that slice and fills its height. Falls back
to a fixed box when no viewport message arrives (older extension), and
standalone mode keeps native centering at a taller 85vh.

Extension bumped to 0.3.1 (template change -> re-copy + cv flush on
prod). PRODUCTION_CUTOVER.md change log updated.
2026-06-19 08:03:40 -07:00
Joel Brock b4f733ee65 PROD update info help text on the form with latest from CiviCRM prd 2026-06-19 08:01:01 -07:00
Joel Brock 5124010b8a Lightbox: fix proxy 404, cap modal size, allow multi-Civi embed
Three quick fixes off first-deploy testing:

1. /api/staff/file 404'd for valid files. Refactor fileBelongsToOrg
   to SELECT the org's and activities' file columns and JS-compare
   instead of WHERE ... OR with custom field refs (APIv4 fragility
   around nested OR + dotted custom fields). Same ownership probe,
   same shape /api/staff/report itself uses to read file values.

2. Lightbox ballooned to full report height because the staff iframe
   auto-grows to fit content (often 3000+ px). Cap to a fixed
   640px x min(92vw, 900px) box so it stays a reasonable preview
   regardless of iframe document size.

3. Production frame-ancestors blocked crm.fci.coop from iframing
   survey.fci.coop -- the CSP only included the dev Civi origin
   derived from CIVI_BASE_URL. Add CIVI_FRAME_ALLOWED_ORIGINS
   (comma-separated) so one app deploy can be embedded by both
   dev and prod Civi. Falls back to CIVI_BASE_URL for single-Civi
   compatibility.

PRODUCTION_CUTOVER.md updated inline and in the change log.
2026-06-16 16:15:28 -07:00
Joel Brock 0019996b15 Staff file proxy: restore IDOR check via column ownership
Replaces the dropped entity_file→org check with a probe against the
actual ownership chain — the file_id stored in a custom-field column
on the org or on one of its activities.

For each request:
  1. Discover file-typed CustomField refs in ACTIVITY_GROUP_NAMES and
     ORG_GROUP_NAMES (one CustomField.get).
  2. In parallel, probe:
       - Contact.get(id=orgId)         WHERE any org file field == fileId
       - Activity.get(target=orgId)    WHERE any activity file field == fileId
     using APIv4 OR clauses.
  3. Allow only if at least one probe returns a hit.

This is the same ownership the staff report itself uses to surface the
file — the proxy now refuses to broker bytes for any file id that
wouldn't appear in the org's own report. civicrm_entity_file remains
unused for auth (it's anchored to the submitter, not the org).
2026-06-15 16:03:48 -07:00
Joel Brock e74462da0b Lightbox: fix proxy 404 and PDF iframe block
Two bugs surfaced on first dev-server test:

1. /api/staff/file 404s for valid file ids. The old per-org check
   read civicrm_entity_file and required entity_id==orgId, but our
   upload route anchors files to the submitter's contact id, not the
   org's — the entity_file row is metadata-only on this install
   (see comment in app/api/upload/route.ts). The custom-field column
   is the real ownership signal, which /api/staff/report already uses,
   and the staff key already gates org access. Drop the bogus check;
   keep the entity_table whitelist as defence.

2. Same-origin PDF iframe blocked by frame-ancestors 'none'. The
   strict global CSP excludes /staff/report; add /api/staff/file to
   the same embed-friendly profile so the lightbox iframe can load.

Also move the sandbox/default-src 'none' CSP to the attachment path
only — a strict sandbox header breaks Chrome's PDF viewer on inline
responses (it needs to load fonts and plugin-mode rendering). On
inline we rely on the SAFE_INLINE_MIMES allowlist + X-Content-Type-
Options + the app's global CSP.
2026-06-15 16:00:30 -07:00
Joel Brock 49d0d24950 Staff file proxy: allowlist inline mimes, force download otherwise
Defence in depth against XSS through a non-allowlisted upload path:
our /api/upload route validates mimes, but the underlying civicrm_file
row can be populated through other routes (Civi admin UI uploads,
imports). A row with mime_type=text/html or image/svg+xml would have
been served inline from this same-origin proxy.

- SAFE_INLINE_MIMES allowlist: png/jpeg/gif/webp/pdf only
- Anything outside it is rewritten to application/octet-stream plus
  Content-Disposition: attachment so the browser downloads
- Adds Content-Security-Policy sandbox so even a mistaken inline serve
  cannot run script or exfiltrate
2026-06-15 11:59:12 -07:00
Joel Brock 6850ff9dee Staff report: inline lightbox for image/PDF attachments
Adds a /api/staff/file proxy that re-streams Civi attachments with
Content-Disposition: inline so a native <dialog> lightbox can preview
images and PDFs in place. Office docs keep their plain download link
and gain a "View in Google Docs" secondary link (uses the Civi-signed
URL so Google can fetch without our staff key).

Also threads mime through /api/staff/report (Attachment.get mime_type)
so the dispatcher picks the right affordance without relying solely on
filename inference.
2026-06-15 11:56:45 -07:00
Joel Brock 5203dabeac Submit confirm: trim copy and use 'survey' wording
Drops the trailing sentence about returning later for another check-in (now redundant with the post-submit CTA) and matches the rest of the UI's 'survey' terminology.
2026-06-11 14:41:18 -07:00
Joel Brock 13d7f6e93b Timeline: draw stage-period ranges from activities, milestones on top
Each stage-transition activity now becomes a horizontal range on its lane: start = the activity's date, end = the next activity with a higher stage rank, or extending to today if still in effect. Milestone date-field dots overlay on top of the ranges. Activity dots at each range start carry a tooltip with the activity subject. Added computeStageRanges in lib/stageRank.ts and switched DateTimeline to take activities directly (deriving both the rank resolver and the ranges internally). Wide year-spanning timelines now show their full extent even when no milestone dates have been entered.
2026-06-11 14:14:36 -07:00
Joel Brock d3b88d92c2 Timeline: place dots by co-op stage at the event date
Each date-field event is now plotted on the lane corresponding to the Framework Stage the co-op was in on the field's stored date, resolved from the activity stream's stage transitions. Date_Opened is pinned to Stage 5 as the journey anchor. Events that pre-date any known transition fall back to the field's section rank so they still surface somewhere. Extracts the STAGE_RANK map (previously inline in ReportView) into lib/stageRank.ts alongside the new buildStageRankAtDate resolver factory.
2026-06-11 13:56:27 -07:00
Joel Brock ade938eaaa Survey copy: clarify subtitle and rename submit-another button
Subtitle now tells respondents they can skip fields they do not know. Post-submit CTA says Submit an update since the same form is the ongoing update channel, not a one-off survey.
2026-06-11 13:47:35 -07:00
Joel Brock 8dfbbea0ba Report: drop Stage 0 lane, flip 5→1 axis, fix Actual label clipping
DateTimeline now skips section rank 0 (intake) and renders stage lanes
top-to-bottom 5→1, matching how the framework actually numbers stages.

MembershipChart's latest-Actual label was placed at the circle's
x + 6, which overflowed the SVG when the most-recent point landed near
the right padding. Anchor it to xOf(maxT) - 4 with textAnchor="end",
mirroring the Goal label so both endpoint labels stay inside the chart.
2026-06-11 13:39:42 -07:00
18 changed files with 1367 additions and 139 deletions
+75 -12
View File
@@ -164,16 +164,42 @@ fill out the form to that Organization.
## 5. Install the `webform-mw` Civi extension
The extension adds an **Engagement Report** tab to Organization
contact pages that embeds the staff report in an iframe.
The extension does two jobs:
- adds an **Engagement Report** tab to Organization contact pages that
embeds the staff report in an iframe; and
- exposes the **file-upload proxy route** `civicrm/webform-mw/upload`
(handled by `CRM_WebformMw_Page_Upload`) that the app's `/api/upload`
POSTs binary files to. This route was **added in v0.3.0** — a CRM
running an older version has the report tab but every form file
upload returns **502 Bad Gateway** (the app can't reach the route, so
`/api/upload` fails closed). **Production must be on v0.3.0 or later.**
1. Copy `WebForm-mw/civi-extension/webform-mw/` to the CRM's
`[civicrm.extensionsDir]` (usually
`<civi-root>/sites/default/ext/`). The directory must be named
exactly `webform-mw` (matches `<key>` in `info.xml`).
exactly `webform-mw` (matches `<key>` in `info.xml`). When
**upgrading** an already-installed extension, overwrite the existing
directory in place.
2. `Administer → System Settings → Extensions → Add new → Refresh`,
then **Install** next to "WebForm-mw".
then **Install** next to "WebForm-mw" (first install) or run the
**Upgrade** action if one is offered. If neither, **Disable** then
**Enable** the extension.
**Then flush caches**`cv flush`, or
**Administer → System Settings → Cleanup Caches**. New menu routes
(like `civicrm/webform-mw/upload`) are only registered after the
router is rebuilt; copying files without a flush leaves the upload
route 404ing and uploads 502ing.
Confirm the upload route resolves (a `400` means the route is live;
a `404`/login redirect means the flush didn't take):
```bash
curl -s -X POST https://crm.fci.coop/civicrm/webform-mw/upload
# → {"error":"Missing file part"} ✓ route exists
```
3. Configure — add to `civicrm.settings.php`:
@@ -197,13 +223,20 @@ Full extension docs:
## 6. CSP / `frame-ancestors` — app side
The Next.js app's `/staff/report` route must allow the CiviCRM origin
in its `frame-ancestors` CSP, or the iframe will refuse to render.
The Next.js app's `/staff/report` and `/api/staff/file` routes must
allow every CiviCRM origin that will iframe them, or the browser will
refuse to render.
The build reads `CIVI_BASE_URL` and adds its origin to the CSP
automatically — so make sure `CIVI_BASE_URL` on the app deploy points
at the **production** CRM origin (`https://crm.fci.coop`), not
`client.crm.fci.coop`.
Set **`CIVI_FRAME_ALLOWED_ORIGINS`** (comma-separated) in the app
deploy env. Each origin needs the scheme:
```
CIVI_FRAME_ALLOWED_ORIGINS=https://crm.fci.coop,https://client.crm.fci.coop
```
Include both prod and any staging Civi origins you want to keep
embedding. If unset, the build falls back to the origin of
`CIVI_BASE_URL` (single-Civi compatibility).
Confirm after deploy:
@@ -211,8 +244,11 @@ Confirm after deploy:
curl -sI https://survey.fci.coop/staff/report | grep -i content-security-policy
```
Should include `frame-ancestors 'self' https://crm.fci.coop` (or
whatever your production CRM origin is).
Should include
`frame-ancestors 'self' https://crm.fci.coop https://client.crm.fci.coop`
(or whatever list you configured). If you see only one origin and the
other Civi is failing to embed, the env var is missing or stale —
trigger a new build, not just a restart.
---
@@ -266,6 +302,11 @@ Run these against the production deploy:
5. Open the target Organization in CiviCRM → **Engagement Report**
tab. The staff report should render the submission you just made.
6. On the form, attach a file to any file field. It should upload
without error. A **502** here means the `webform-mw` extension on
this CRM is older than v0.3.0 (or the post-upgrade cache flush was
skipped) — see step 5.
---
## Change log
@@ -276,3 +317,25 @@ so the rationale survives.
- **2026-06-08** — Documented the field-242 "Unknown" default issue
after a production submission was stamped `Stage = "Unknown"`.
Cleared via `CustomField.update`; see step 2.
- **2026-06-18** — Extension bumped to **v0.3.1** (lightbox fix). The
staff-report iframe is expanded to full content height, so the
attachment lightbox (a modal inside the iframe) centered in the whole
document and sat off-screen unless the parent page was scrolled to the
middle. `Tab.tpl` now broadcasts the iframe's visible slice
(`webform-mw-viewport`) and the app pins/sizes the lightbox to it.
**Re-copy the extension to prod and `cv flush`** (template change) —
same procedure as step 5; no settings change.
- **2026-06-16** — Step 5 now states the file-upload feature requires
extension **v0.3.0+** (the `civicrm/webform-mw/upload` proxy route)
and documents the upgrade-vs-first-install path plus the mandatory
cache flush. Surfaced when production uploads returned **502**: prod
Civi still had v0.1.0, which has the Engagement Report tab but not
the upload route, so `/api/upload` couldn't reach it and failed
closed. Added a post-deploy upload check as step 6 of section 8.
- **2026-06-16** — Step 6 split off `CIVI_FRAME_ALLOWED_ORIGINS` as a
separate env from `CIVI_BASE_URL`. Surfaced after the production
cutover hit a `frame-ancestors` block: the app's CSP only listed the
staging Civi origin (derived from `CIVI_BASE_URL`), so prod
(`crm.fci.coop`) couldn't iframe `survey.fci.coop`. The new var
takes a comma-separated list so one app deploy can be embedded by
both dev and prod Civi.
+310
View File
@@ -0,0 +1,310 @@
/**
* GET /api/staff/file?id=<fileId>&org=<orgId>&key=<STAFF_REPORT_KEY>
*
* Server-side proxy that fetches a CiviCRM attachment and re-streams it
* with `Content-Disposition: inline`, so the staff-report lightbox can
* preview images and PDFs in-place. Civi's own `/civicrm/file` handler
* always sends `attachment`, which forces a download — that's correct for
* its UI but wrong for an embedded preview.
*
* Authorization layers:
* 1. STAFF_REPORT_KEY query param (same gate as /api/staff/report).
* 2. Per-file ownership probe: the requested fileId must appear as a
* value in one of the org's file-typed custom-field columns, OR in
* one of the org's activities' file-typed columns. This is the same
* ownership chain the staff report uses to surface the file in the
* first place. NOT enforced via civicrm_entity_file — that linkage
* is metadata-only here (upload anchors to submitter, not org).
*
* The upstream fetch uses the URL Civi returns from `Attachment.get`,
* which includes a freshly-minted `fcs` JWT. We don't carry any user
* session cookies — that JWT is the auth for `/civicrm/file`.
*
* STUB MODE: if Civi env vars are unset, 404. Stub-mode previews aren't
* meaningful (there are no real bytes to serve).
*/
import { NextRequest, NextResponse } from "next/server";
import { isStaffKeyValid } from "@/lib/staff-auth";
import { civi, civi3 } from "@/lib/civicrm";
import { resolveMime } from "@/lib/mime.mjs";
const MAX_BYTES = 10 * 1024 * 1024;
// Only mimes the lightbox actually renders inline are allowed through the
// `Content-Disposition: inline` path. Everything else gets coerced to
// application/octet-stream + attachment so it always downloads.
//
// Why this matters: our /api/upload route validates uploaded mimes
// against an allowlist, but the underlying civicrm_file row can be
// populated by other paths too — a Civi admin uploading directly through
// the CiviCRM UI, a future Civi import, etc. If any of those routes
// stored mime_type="text/html" or "image/svg+xml", an inline serve from
// this same-origin proxy would let arbitrary script run against
// /api/* and the staff key. Defence in depth: don't trust mime_type
// when the response carries `inline`.
//
// Explicitly NOT in this set: svg (script-bearing), html, xml, any text/*.
const SAFE_INLINE_MIMES = new Set<string>([
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"application/pdf",
]);
function isCiviStubMode(): boolean {
return !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
);
}
interface AttachmentRow {
id: string | number;
url?: string;
mime_type?: string;
name?: string;
}
async function fetchAttachment(fileId: number): Promise<AttachmentRow | null> {
const res = await civi3<AttachmentRow>("Attachment", "get", {
id: fileId,
return: "id,url,mime_type,name",
sequential: 1,
});
return res.values?.[0] ?? null;
}
// Same groups /api/staff/report scans. Kept in sync deliberately: this
// proxy must only authorise files reachable through the same set of
// fields the report itself surfaces.
const ACTIVITY_GROUP_NAMES = [
"Check_in_data__organizing_",
"Stage_1",
"Stage_2",
"Stage_3",
"Stage_4",
"Stage_5",
];
const ORG_GROUP_NAMES = ["Food_Co_op_Organizing"];
const ACTIVITY_TYPE_NAME = "Check-in (organizing)";
interface FileFieldRefs {
org: string[];
activity: string[];
}
/**
* Discover the APIv4 field references for File-typed custom fields the
* staff report cares about. Returns refs like "Stage_1.Vision_Upload"
* split by whether they live on the Organization Contact or on Activities.
*/
async function discoverFileFieldRefs(): Promise<FileFieldRefs> {
const res = await civi<{
name: string;
data_type: string;
"custom_group_id.name": string;
}>("CustomField", "get", {
select: ["name", "data_type", "custom_group_id.name"],
where: [
["custom_group_id.name", "IN", [...ACTIVITY_GROUP_NAMES, ...ORG_GROUP_NAMES]],
["data_type", "=", "File"],
["is_active", "=", true],
],
limit: 500,
});
const org: string[] = [];
const activity: string[] = [];
for (const row of res.values ?? []) {
const group = row["custom_group_id.name"];
const ref = `${group}.${row.name}`;
if (ORG_GROUP_NAMES.includes(group)) org.push(ref);
else if (ACTIVITY_GROUP_NAMES.includes(group)) activity.push(ref);
}
return { org, activity };
}
/**
* Confirm fileId is reachable from this org through the same column
* ownership the staff report uses.
*
* Implementation: fetch the org row and the org's activities, selecting
* the file-typed custom-field columns, then check in JS whether any
* column value equals fileId. We avoid APIv4 OR clauses against custom
* fields because that combination has been fragile in practice; the
* SELECT-and-compare path is the same shape /api/staff/report uses
* successfully.
*
* We do NOT use civicrm_entity_file for this check: our upload route
* anchors files to the submitter's contact id (not the org's), so that
* linkage doesn't reflect ownership. The custom-field column is the
* authoritative chain.
*/
async function fileBelongsToOrg(fileId: number, orgId: number): Promise<boolean> {
let refs: FileFieldRefs;
try {
refs = await discoverFileFieldRefs();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[staff/file] CustomField.get failed:", msg);
return false;
}
const matches = (rows: Array<Record<string, unknown>>, fieldRefs: string[]): boolean => {
for (const row of rows) {
for (const ref of fieldRefs) {
const v = row[ref];
if (v === undefined || v === null || v === "") continue;
if (Number(v) === fileId) return true;
}
}
return false;
};
const orgProbe =
refs.org.length > 0
? civi<Record<string, unknown>>("Contact", "get", {
where: [["id", "=", orgId]],
select: ["id", ...refs.org],
limit: 1,
}).catch((e: unknown) => {
console.error(
"[staff/file] Contact.get probe failed:",
e instanceof Error ? e.message : String(e),
);
return { values: [] as Array<Record<string, unknown>> };
})
: Promise.resolve({ values: [] as Array<Record<string, unknown>> });
const activityProbe =
refs.activity.length > 0
? civi<Record<string, unknown>>("Activity", "get", {
where: [
["target_contact_id", "=", orgId],
["activity_type_id:name", "=", ACTIVITY_TYPE_NAME],
],
select: ["id", ...refs.activity],
limit: 500,
}).catch((e: unknown) => {
console.error(
"[staff/file] Activity.get probe failed:",
e instanceof Error ? e.message : String(e),
);
return { values: [] as Array<Record<string, unknown>> };
})
: Promise.resolve({ values: [] as Array<Record<string, unknown>> });
const [orgRes, actRes] = await Promise.all([orgProbe, activityProbe]);
return (
matches(orgRes.values ?? [], refs.org) || matches(actRes.values ?? [], refs.activity)
);
}
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const key = url.searchParams.get("key");
const idStr = url.searchParams.get("id");
const orgStr = url.searchParams.get("org");
const wantsDownload = url.searchParams.get("dl") === "1";
if (!isStaffKeyValid(key)) {
return new NextResponse("Not found", { status: 404 });
}
const fileId = Number(idStr);
const orgId = Number(orgStr);
if (!idStr || !Number.isFinite(fileId) || fileId <= 0) {
return NextResponse.json({ error: "Missing or invalid file id." }, { status: 400 });
}
if (!orgStr || !Number.isFinite(orgId) || orgId <= 0) {
return NextResponse.json({ error: "Missing or invalid org id." }, { status: 400 });
}
if (isCiviStubMode()) {
return new NextResponse("Not found", { status: 404 });
}
let row: AttachmentRow | null;
try {
row = await fetchAttachment(fileId);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[staff/file] Attachment.get failed:", msg);
return NextResponse.json({ error: "Could not look up the file." }, { status: 502 });
}
if (!row || !row.url) {
return new NextResponse("Not found", { status: 404 });
}
// Authorisation: prove this file actually belongs to `orgId` via the
// custom-field columns the staff report itself surfaces. Without this a
// staff key (which is org-agnostic) could be used to enumerate file ids
// outside any report context.
const owned = await fileBelongsToOrg(fileId, orgId);
if (!owned) {
return new NextResponse("Not found", { status: 404 });
}
// The signed URL Civi returns is sometimes a relative path (depends on
// Civi config). Normalise against CIVI_BASE_URL so fetch() has an
// absolute URL.
const base = (process.env.CIVI_BASE_URL ?? "").replace(/\/+$/, "");
const upstream = row.url.startsWith("http")
? row.url
: `${base}${row.url.startsWith("/") ? "" : "/"}${row.url}`;
let upstreamRes: Response;
try {
upstreamRes = await fetch(upstream, { cache: "no-store" });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[staff/file] upstream fetch failed:", msg);
return NextResponse.json({ error: "Upstream fetch failed." }, { status: 502 });
}
if (!upstreamRes.ok || !upstreamRes.body) {
return new NextResponse("Not found", { status: upstreamRes.status === 404 ? 404 : 502 });
}
const contentLengthRaw = upstreamRes.headers.get("content-length");
const contentLength = contentLengthRaw ? Number(contentLengthRaw) : NaN;
if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) {
return NextResponse.json({ error: "File too large for inline preview." }, { status: 413 });
}
const resolved = resolveMime(
row.mime_type ?? upstreamRes.headers.get("content-type"),
row.name,
);
// Inline is only allowed for mimes the lightbox actually renders. Anything
// else (incl. an attacker-controlled mime_type from a non-allowlisted
// upload path) is downgraded to octet-stream + attachment so the browser
// downloads instead of executing.
const inlineSafe = !wantsDownload && SAFE_INLINE_MIMES.has(resolved);
const mime = inlineSafe ? resolved : "application/octet-stream";
const disposition = inlineSafe ? "inline" : "attachment";
const safeName = (row.name ?? `file-${fileId}`).replace(/[\r\n"\\]/g, "_");
const headers: Record<string, string> = {
"Content-Type": mime,
"Content-Disposition": `${disposition}; filename="${safeName}"`,
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
};
// For attachment responses, layer on a strict sandbox CSP as defence in
// depth — the file is being downloaded so the CSP has no UX effect, but
// if a future change ever flips it to inline by mistake, the sandbox
// blocks script + plugins. For inline responses we rely on the SAFE
// MIME allowlist + nosniff + the app's global CSP, because a strict
// `sandbox` here breaks Chrome's PDF viewer (it can't load fonts or
// plugin-mode rendering under sandbox).
if (!inlineSafe) {
headers["Content-Security-Policy"] = "sandbox; default-src 'none'";
}
if (Number.isFinite(contentLength)) {
headers["Content-Length"] = String(contentLength);
}
return new NextResponse(upstreamRes.body, { status: 200, headers });
}
+14 -4
View File
@@ -193,7 +193,7 @@ function buildStubPayload(orgId: number): StaffReportPayload {
{
activityId: 9012,
date: daysAgo(3),
value: { id: 4242, file_name: "co-op-vision.pdf" },
value: { id: 4242, file_name: "co-op-vision.pdf", mime: "application/pdf" },
},
],
},
@@ -373,6 +373,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
}
const urlByFileId = new Map<number, string>();
const mimeByFileId = new Map<number, string>();
if (fileIds.size > 0) {
// APIv3 Attachment.get doesn't accept an IN-clause cleanly on this Civi
// install — passing {IN: [...]} for `id` crashes Civi's error renderer
@@ -382,11 +383,15 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
// small. Each request is independent; we issue them in parallel.
const lookups = await Promise.allSettled(
Array.from(fileIds).map((fid) =>
civi3<{ id: string | number; url?: string }>("Attachment", "get", {
civi3<{ id: string | number; url?: string; mime_type?: string }>(
"Attachment",
"get",
{
id: fid,
return: "id,url",
return: "id,url,mime_type",
sequential: 1,
}).then((r) => ({ fid, row: r.values?.[0] })),
},
).then((r) => ({ fid, row: r.values?.[0] })),
),
);
for (const result of lookups) {
@@ -401,6 +406,9 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
if (row && typeof row.url === "string" && row.url.length > 0) {
urlByFileId.set(fid, row.url);
}
if (row && typeof row.mime_type === "string" && row.mime_type.length > 0) {
mimeByFileId.set(fid, row.mime_type);
}
}
}
@@ -447,6 +455,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
id: raw,
file_name: typeof fname === "string" ? fname : undefined,
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
};
}
return {
@@ -480,6 +489,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
id: v,
file_name: typeof fname === "string" ? fname : undefined,
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
};
}
entries.push({ activityId: row.id, date: row.activity_date_time, value });
+1 -1
View File
@@ -17,7 +17,7 @@
<url desc="Main Extension Page">https://github.com/joelbrock/WebForm-mw</url>
</urls>
<releaseDate>2026-06-05</releaseDate>
<version>0.3.0</version>
<version>0.3.1</version>
<develStage>beta</develStage>
<compatibility>
<ver>5.50</ver>
@@ -22,7 +22,41 @@
// 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';
// Geometry changed tell the child where its visible slice now is.
scheduleViewport();
}, false);
// The iframe is expanded to full content height, so the report has no
// internal scroll context. A modal opened inside it (the attachment
// lightbox) would center in the full iframe and sit off-screen. Broadcast
// the iframe's currently-visible region (in the child's own content
// coordinates) so the lightbox can pin and size itself to it. The child
// listens for 'webform-mw-viewport'.
var ticking = false;
var raf = window.requestAnimationFrame || function (cb) { return setTimeout(cb, 16); };
function postViewport() {
ticking = false;
if (!frame.contentWindow) return;
var r = frame.getBoundingClientRect();
var winH = window.innerHeight || document.documentElement.clientHeight;
var visibleTop = Math.max(0, r.top);
var visibleBottom = Math.min(winH, r.bottom);
frame.contentWindow.postMessage({
type: 'webform-mw-viewport',
// px from the iframe's content top down to the first visible row.
top: Math.max(0, -r.top),
height: Math.max(0, visibleBottom - visibleTop)
}, APP_ORIGIN || '*');
}
function scheduleViewport() {
if (ticking) return;
ticking = true;
raf(postViewport);
}
window.addEventListener('scroll', scheduleViewport, { passive: true });
window.addEventListener('resize', scheduleViewport);
window.addEventListener('load', scheduleViewport);
scheduleViewport();
})();
</script>
{else}
+3 -4
View File
@@ -322,9 +322,8 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
return;
}
const confirmed = window.confirm(
"Submit your check-in?\n\n" +
"Please confirm your responses are complete and ready. " +
"After submitting, you can come back later and submit another check-in with updates.",
"Submit your survey?\n\n" +
"Please confirm your responses are complete and ready. ",
);
if (!confirmed) return;
setSubmitState({ kind: "submitting" });
@@ -759,7 +758,7 @@ function SuccessDestination({
onClick={onAnother}
className="inline-flex items-center justify-center rounded-md border border-rule bg-paper px-5 py-2 text-sm font-medium text-ink-soft transition hover:bg-paper-2/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40"
>
Submit another survey
Submit an update
</button>
<p className="text-xs text-ink-mute">It&apos;s safe to close this window.</p>
</div>
+6 -10
View File
@@ -10,6 +10,7 @@ import type {
StageSectionConfig,
} from "@/types/form";
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
import { STAGE_RANK } from "@/lib/stageRank";
import { StageIcon } from "./StageIcon";
import {
FieldHistoryGroup,
@@ -39,15 +40,6 @@ type LoadState =
type PathwayState = "past" | "current" | "future";
const STAGE_RANK: Record<string, number> = {
Inquiry: 0,
Organizing: 1,
Feasibility: 2,
"Business feasibility": 3,
"Store Implementation": 4,
"Stabilize newly opened co-op": 5,
};
export function ReportView({ config, cid, cs }: ReportViewProps) {
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
@@ -126,7 +118,11 @@ export function ReportView({ config, cid, cs }: ReportViewProps) {
dateRange={dateRange}
/>
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} />
<DateTimeline
sections={config.sections}
fieldHistory={data.fieldHistory}
activities={data.activities}
/>
{sectionsToRender.length === 0 ? (
<EmptyState />
+38 -23
View File
@@ -19,6 +19,7 @@ import {
computeDateRange,
} from "./report/FieldHistory";
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
import { FileLink } from "./report/FileLink";
interface StaffReportViewProps {
org: number;
@@ -207,6 +208,8 @@ export function StaffReportView({
section={section}
options={data.options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/>
))}
@@ -271,10 +274,14 @@ function StaffSection({
section,
options,
civiBaseUrl,
org,
authKey,
}: {
section: StaffReportSection;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
}) {
const filled = section.fields.filter((f) => f.history.length > 0);
const empty = section.fields.filter((f) => f.history.length === 0);
@@ -328,6 +335,8 @@ function StaffSection({
field={f}
options={options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/>
))}
</ul>
@@ -374,10 +383,14 @@ function CompactFieldRow({
field,
options,
civiBaseUrl,
org,
authKey,
}: {
field: StaffReportField;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
}) {
const [open, setOpen] = useState(false);
const latest = field.history[0];
@@ -394,6 +407,8 @@ function CompactFieldRow({
entry={latest}
options={options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/>
</span>
{latest.date ? (
@@ -431,6 +446,8 @@ function CompactFieldRow({
entry={e}
options={options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/>
</span>
</li>
@@ -446,41 +463,33 @@ function FieldValue({
entry,
options,
civiBaseUrl,
org,
authKey,
}: {
field: StaffReportField;
entry: FieldHistoryEntry;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
}) {
if (field.descriptor.render === "file") {
const v = entry.value as
| { id?: number | string; file_name?: string; url?: string }
| { id?: number | string; file_name?: string; url?: string; mime?: string }
| null;
if (!v || v.id === undefined) return <span></span>;
const id = String(v.id);
const name = v.file_name ?? `file-${id}`;
// Prefer the Civi-signed URL (carries the fcs JWT) returned by
// Attachment.get. If absent, fall back to the WebForm-mw Civi extension's
// file-redirect route — it mints the fcs server-side and 302s to the
// real /civicrm/file URL. (Hitting /civicrm/file?id=X bare crashes Civi
// on a null fcs JWT decode.)
let href = "#";
if (v.url) {
href = v.url.startsWith("http")
? v.url
: `${civiBaseUrl}${v.url.startsWith("/") ? "" : "/"}${v.url}`;
} else if (civiBaseUrl) {
href = `${civiBaseUrl}/civicrm/webform-mw/file?id=${encodeURIComponent(id)}`;
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink"
>
{name}
</a>
<FileLink
fileId={id}
fileName={name}
civiSignedUrl={v.url}
mime={v.mime}
org={org}
authKey={authKey}
civiBaseUrl={civiBaseUrl}
/>
);
}
return (
@@ -757,7 +766,13 @@ function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
{ rank: 0, id: "all", label: "All dated events", fields: fieldConfigs },
];
return <DateTimeline sections={sections} fieldHistory={fieldHistory} />;
return (
<DateTimeline
sections={sections}
fieldHistory={fieldHistory}
activities={data.activities}
/>
);
}
// formatLongDate retained for symmetry with other report views; unused here
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useEffect, useRef, useState } from "react";
/**
* Modal preview for image and PDF attachments.
*
* Uses the native <dialog> element for focus trap, Esc-to-close, and
* inert-background semantics — saves ~100 lines of bespoke a11y wiring
* that we'd otherwise have to maintain.
*
* For an `image/*` mime, renders an <img>. For `application/pdf`, an
* <iframe>. Anything else should not reach this component — FileLink is
* responsible for branching office/other types to plain download links.
*/
export function AttachmentLightbox({
open,
onClose,
previewSrc,
downloadHref,
filename,
mime,
}: {
open: boolean;
onClose: () => void;
/** URL the <img>/<iframe> loads from. Should serve with Content-Disposition: inline. */
previewSrc: string;
/** Anchor target for the Download button. Serves with Content-Disposition: attachment. */
downloadHref: string;
filename: string;
/** Resolved mime; used to pick between <img> and <iframe>. */
mime: string;
}) {
const ref = useRef<HTMLDialogElement | null>(null);
// When embedded in the CiviCRM tab, the iframe is auto-expanded to its full
// content height (no nested scrollbar), so a modal <dialog> — which centers
// in *its* viewport — lands in the vertical middle of the whole document and
// is off-screen unless the parent page happens to be scrolled there. The
// iframe can't read the parent's scroll position (cross-origin), so the
// extension's tab template broadcasts the iframe's currently-visible slice
// as `webform-mw-viewport` messages. We pin the dialog to that slice and
// size it to fill the visible height. Standalone (non-embedded) keeps the
// native viewport centering.
const [framed, setFramed] = useState(false);
const vpRef = useRef<{ top: number; height: number } | null>(null);
const [vp, setVp] = useState<{ top: number; height: number } | null>(null);
useEffect(() => {
setFramed(window.parent !== window);
}, []);
useEffect(() => {
const onMsg = (e: MessageEvent) => {
if (e.source !== window.parent) return;
const d = e.data as { type?: string; top?: number; height?: number } | null;
if (!d || d.type !== "webform-mw-viewport") return;
const next = { top: Number(d.top) || 0, height: Number(d.height) || 0 };
vpRef.current = next;
// Only reflect into render state while open — many FileLinks mount a
// (closed) lightbox each, and we don't want every one re-rendering on
// each scroll frame.
if (open) setVp(next);
};
window.addEventListener("message", onMsg);
return () => window.removeEventListener("message", onMsg);
}, [open]);
// Seed from the latest known viewport the moment we open.
useEffect(() => {
if (open) setVp(vpRef.current);
}, [open]);
// Drive the native <dialog>'s open state from our prop.
useEffect(() => {
const dlg = ref.current;
if (!dlg) return;
if (open && !dlg.open) {
dlg.showModal();
} else if (!open && dlg.open) {
dlg.close();
}
}, [open]);
// Native <dialog> fires a 'close' event on Esc and on form-method=dialog
// submit. Mirror that back into React state so the parent stays in sync.
useEffect(() => {
const dlg = ref.current;
if (!dlg) return;
const handle = () => onClose();
dlg.addEventListener("close", handle);
return () => dlg.removeEventListener("close", handle);
}, [onClose]);
// Close when the user clicks the backdrop (everything outside the inner
// panel). The dialog itself receives the click event when the backdrop
// is hit because the panel uses pointer-events the same way.
const onDialogClick = (e: React.MouseEvent<HTMLDialogElement>) => {
if (e.target === ref.current) onClose();
};
const isImage = mime.startsWith("image/");
const isPdf = mime === "application/pdf";
// Vertical placement + height.
// - framed + known viewport: pin to the visible slice and fill it.
// - framed but no viewport yet (e.g. an older extension that doesn't
// broadcast): fall back to a safe fixed box so we never balloon to the
// full multi-thousand-pixel iframe height.
// - standalone: native viewport centering, tall enough for documents.
const margin = 16;
const dialogStyle: React.CSSProperties | undefined =
framed && vp
? { top: Math.max(8, vp.top + margin), bottom: "auto", marginTop: 0, marginBottom: 0 }
: undefined;
const panelHeight = framed
? vp
? Math.max(360, vp.height - margin * 2)
: 640
: undefined;
return (
<dialog
ref={ref}
onClick={onDialogClick}
aria-label={`Preview: ${filename}`}
style={dialogStyle}
className="m-auto w-[min(92vw,900px)] rounded-md bg-transparent p-0 shadow-2xl backdrop:bg-ink/70"
>
<div
style={panelHeight !== undefined ? { height: panelHeight } : undefined}
className={`flex flex-col overflow-hidden rounded-md ${framed ? "" : "h-[85vh] max-h-[860px]"}`}
>
<header className="flex items-center justify-between gap-4 bg-paper px-4 py-2.5 sm:px-5">
<p className="min-w-0 truncate font-display text-sm text-ink">
{filename}
</p>
<div className="flex flex-shrink-0 items-center gap-3">
<a
href={downloadHref}
className="text-xs font-medium text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-ink hover:text-leaf-800"
target="_blank"
rel="noopener noreferrer"
>
Download
</a>
<button
type="button"
onClick={onClose}
className="rounded px-2 py-1 text-xs font-medium text-ink-soft hover:bg-rule-soft/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-700"
aria-label="Close preview"
>
Close
</button>
</div>
</header>
<div className="flex flex-1 items-center justify-center overflow-hidden bg-ink/90 p-3">
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewSrc}
alt={filename}
className="max-h-full max-w-full object-contain"
/>
) : isPdf ? (
<iframe
src={previewSrc}
title={filename}
className="h-full w-full border-0 bg-paper"
/>
) : (
// Defensive: FileLink shouldn't open the lightbox for non-previewable
// types, but if it does, surface a clear message instead of an empty box.
<p className="px-6 text-paper">
Preview not available. Use Download above to open the file.
</p>
)}
</div>
</div>
</dialog>
);
}
+216 -55
View File
@@ -1,66 +1,106 @@
"use client";
import type { FieldHistoryEntry, StageSectionConfig } from "@/types/form";
import type { ActivitySummary, FieldHistoryEntry, StageSectionConfig } from "@/types/form";
import { formatShortDate } from "./FieldHistory";
import { buildStageRankAtDate, computeStageRanges, type StageRange } from "@/lib/stageRank";
/**
* Date-grouped timeline strip at the top of the report. Walks every
* date-type field across all stage sections (05), pulls each field's
* most-recent entered date, and plots events into six horizontal swim
* lanes — one per stage. Stage 5's `Date_Opened` gets visual emphasis
* as the journey's anchor at the right end of the time axis. A faint
* "Today" tick anchors the reader if today falls within the range, and
* an adaptive month/year axis runs beneath the lanes. Each dot exposes
* a tooltip on hover or keyboard focus.
* Date-grouped timeline strip at the top of the report. Renders five
* horizontal swim lanes (Stage 5 on top down to Stage 1 at the bottom)
* and overlays two kinds of events:
*
* 1. Stage-period ranges, one per stage-transition activity, drawn
* as a soft bar from the activity date to the date of the next
* activity that moved the co-op to a higher stage. The latest
* open stage extends to today.
* 2. Milestone dots from date-type custom fields. The dot is placed
* on the lane corresponding to the stage the co-op was in on the
* field's stored date (resolved from the activity stream). When
* that's unknowable (date precedes any transition), it falls back
* to the field's own section rank. Stage 5's `Date_Opened` is
* pinned to Stage 5 as the journey anchor.
*
* A faint "Today" tick anchors the reader if today falls within the
* range, and an adaptive month/year axis runs beneath the lanes. Each
* dot exposes a tooltip on hover or keyboard focus.
*/
export function DateTimeline({
sections,
fieldHistory,
activities,
}: {
sections: StageSectionConfig[];
fieldHistory: Record<string, FieldHistoryEntry[]>;
activities: ActivitySummary[];
}) {
type Event = {
const stageRankAtDate = buildStageRankAtDate(activities);
const ranges = computeStageRanges(activities);
type MilestoneEvent = {
rank: number;
fieldLabel: string;
date: string; // YYYY-MM-DD or full ISO
date: string;
isOpened: boolean;
};
const events: Event[] = [];
const milestones: MilestoneEvent[] = [];
for (const section of sections) {
if (section.rank < 0 || section.rank > 5) continue;
for (const f of section.fields) {
if (f.type !== "date") continue;
const history = fieldHistory[f.name];
if (!history || history.length === 0) continue;
const v = history[0].value;
if (typeof v !== "string" || v.length === 0) continue;
events.push({
rank: section.rank,
fieldLabel: f.label,
date: v,
isOpened: f.name === "Date_Opened",
});
const isOpened = f.name === "Date_Opened";
const resolved = stageRankAtDate(v);
let rank = isOpened ? 5 : (resolved ?? section.rank);
if (rank < 1 || rank > 5) rank = section.rank;
if (rank < 1 || rank > 5) continue;
milestones.push({ rank, fieldLabel: f.label, date: v, isOpened });
}
}
if (events.length === 0) return null;
const times = events.map((e) => new Date(e.date).getTime()).filter(Number.isFinite);
if (times.length === 0) return null;
const openedEvent = events.find((e) => e.isOpened);
const openedTime = openedEvent ? new Date(openedEvent.date).getTime() : undefined;
const minT = Math.min(...times);
// If Date_Opened is set, anchor the right edge there; if any other event
// is later, extend so nothing falls off the chart.
const maxT = Math.max(...times, openedTime ?? -Infinity);
const tRange = maxT - minT || 1;
// Collect every time value that should influence the time axis: range
// starts, range ends (excluding open-ended), milestone dates, and today
// if any range is open (so the open range visibly extends to "now").
const timeBag: number[] = [];
let hasOpenRange = false;
for (const r of ranges) {
const ts = new Date(r.startDate).getTime();
if (Number.isFinite(ts)) timeBag.push(ts);
if (r.endDate === null) {
hasOpenRange = true;
} else {
const te = new Date(r.endDate).getTime();
if (Number.isFinite(te)) timeBag.push(te);
}
}
for (const m of milestones) {
const t = new Date(m.date).getTime();
if (Number.isFinite(t)) timeBag.push(t);
}
const todayT = Date.now();
if (hasOpenRange) timeBag.push(todayT);
if (timeBag.length === 0) return null;
let minT = Math.min(...timeBag);
let maxT = Math.max(...timeBag);
if (minT === maxT) {
const pad = 30 * 24 * 3600 * 1000;
minT -= pad;
maxT += pad;
}
const tRange = maxT - minT || 1;
const todayPct =
todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null;
const ticks = generateAxisTicks(minT, maxT);
const rangesByRank = new Map<number, StageRange[]>();
for (const r of ranges) {
const list = rangesByRank.get(r.rank) ?? [];
list.push(r);
rangesByRank.set(r.rank, list);
}
return (
<section
aria-labelledby="timeline-heading"
@@ -80,9 +120,10 @@ export function DateTimeline({
</p>
</div>
<ol className="mt-4 space-y-2">
{[0, 1, 2, 3, 4, 5].map((rank) => {
const rowEvents = events.filter((e) => e.rank === rank);
const isEmpty = rowEvents.length === 0;
{[5, 4, 3, 2, 1].map((rank) => {
const rowRanges = rangesByRank.get(rank) ?? [];
const rowMilestones = milestones.filter((e) => e.rank === rank);
const isEmpty = rowRanges.length === 0 && rowMilestones.length === 0;
return (
<li
key={rank}
@@ -99,6 +140,30 @@ export function DateTimeline({
(isEmpty ? "bg-rule-soft/60" : "bg-rule-soft")
}
/>
{/* Stage-period ranges: soft bar from activity start to next
higher-stage activity (or to today if still open). */}
{rowRanges.map((r, i) => {
const ts = new Date(r.startDate).getTime();
if (!Number.isFinite(ts)) return null;
const endT =
r.endDate === null
? Math.max(todayT, ts)
: new Date(r.endDate).getTime();
if (!Number.isFinite(endT)) return null;
const startPct = ((ts - minT) / tRange) * 100;
const endPct = ((endT - minT) / tRange) * 100;
const widthPct = Math.max(endPct - startPct, 0.5);
return (
<StageRangeBar
key={`r-${i}`}
rank={rank}
range={r}
startPct={startPct}
widthPct={widthPct}
isOpen={r.endDate === null}
/>
);
})}
{todayPct !== null && (
<span
aria-hidden
@@ -106,19 +171,23 @@ export function DateTimeline({
className="absolute top-0 bottom-0 w-px -translate-x-1/2 border-l border-dashed border-clay-300/70"
/>
)}
{rowEvents.map((e, i) => {
const t = new Date(e.date).getTime();
{/* Activity start dots — placed at each range start for
keyboard focus + tooltip with the activity subject. */}
{rowRanges.map((r, i) => {
const t = new Date(r.startDate).getTime();
if (!Number.isFinite(t)) return null;
const x = ((t - minT) / tRange) * 100;
return (
<TimelineDot
key={i}
rank={rank}
event={e}
x={x}
/>
<ActivityDot key={`a-${i}`} rank={rank} range={r} x={x} />
);
})}
{/* Milestone date-field dots layered on top. */}
{rowMilestones.map((e, i) => {
const t = new Date(e.date).getTime();
if (!Number.isFinite(t)) return null;
const x = ((t - minT) / tRange) * 100;
return <MilestoneDot key={`m-${i}`} rank={rank} event={e} x={x} />;
})}
</div>
</li>
);
@@ -138,7 +207,6 @@ export function DateTimeline({
/>
{ticks.map((tk, i) => {
const x = ((tk.t - minT) / tRange) * 100;
// Edge labels shift so they don't overflow the row.
const alignClass =
x < 6
? "left-0 origin-top-left"
@@ -185,8 +253,14 @@ export function DateTimeline({
)}
{/* Accessible event list — invisible to sighted users but readable by SR */}
<ul className="sr-only">
{events.map((e, i) => (
<li key={i}>
{ranges.map((r, i) => (
<li key={`r-${i}`}>
Stage {r.rank}: {formatShortDate(r.startDate)}
{r.endDate ? ` to ${formatShortDate(r.endDate)}` : " (current)"}
</li>
))}
{milestones.map((e, i) => (
<li key={`m-${i}`}>
Stage {e.rank}: {e.fieldLabel} {formatShortDate(e.date)}
{e.isOpened ? " (opened)" : ""}
</li>
@@ -198,8 +272,6 @@ export function DateTimeline({
function stageDotBg(rank: number): string {
switch (rank) {
case 0:
return "bg-leaf-200";
case 1:
return "bg-leaf-300";
case 2:
@@ -215,14 +287,108 @@ function stageDotBg(rank: number): string {
}
}
function stageRangeBg(rank: number): string {
switch (rank) {
case 1:
return "bg-leaf-300/30";
case 2:
return "bg-leaf-500/25";
case 3:
return "bg-leaf-600/25";
case 4:
return "bg-leaf-700/25";
case 5:
return "bg-clay-700/25";
default:
return "bg-leaf-500/25";
}
}
/** Soft horizontal bar marking a stage period on a lane. */
function StageRangeBar({
rank,
range,
startPct,
widthPct,
isOpen,
}: {
rank: number;
range: StageRange;
startPct: number;
widthPct: number;
isOpen: boolean;
}) {
return (
<span
aria-hidden
title={
`Stage ${rank}: ${formatShortDate(range.startDate)}` +
(range.endDate ? `${formatShortDate(range.endDate)}` : " → current")
}
style={{ left: `${startPct}%`, width: `${widthPct}%` }}
className={
"absolute top-1/2 -translate-y-1/2 h-2 rounded-full " +
stageRangeBg(rank) +
(isOpen ? " ring-1 ring-inset ring-clay-300/40" : "")
}
/>
);
}
/** Dot at the start of a stage period; carries the activity tooltip. */
function ActivityDot({
rank,
range,
x,
}: {
rank: number;
range: StageRange;
x: number;
}) {
const tipAlign =
x < 18 ? "left-0" : x > 82 ? "right-0" : "left-1/2 -translate-x-1/2";
return (
<span
role="img"
tabIndex={0}
aria-label={`Stage ${rank} check-in, ${formatShortDate(range.startDate)}`}
style={{ left: `${x}%` }}
className={
"group/dot absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 ring-1 ring-paper h-3 w-3 " +
stageDotBg(rank)
}
>
<span
role="tooltip"
className={
"pointer-events-none absolute bottom-full z-30 mb-2 hidden whitespace-nowrap rounded-md bg-ink/95 px-2.5 py-1.5 text-[11px] leading-snug text-paper shadow-lg group-hover/dot:block group-focus-within/dot:block " +
tipAlign
}
>
<span className="block font-medium">
{range.subject ?? `Stage ${rank} check-in`}
</span>
<span className="mt-0.5 block tabular-nums text-paper-2/80">
{formatShortDate(range.startDate)}
{range.endDate ? (
<> {formatShortDate(range.endDate)}</>
) : (
<span className="ml-1.5 text-paper-2/60"> current</span>
)}
</span>
</span>
</span>
);
}
/**
* A single dot on the timeline plus its hover/focus tooltip. Keyboard
* A single milestone date-field dot plus its hover/focus tooltip. Keyboard
* users can Tab to each dot and the tooltip will appear via
* `group-focus-within`. The tooltip is anchored above the dot; for dots
* near the left or right edge of the row, anchor flips so the card
* doesn't overflow.
*/
function TimelineDot({
function MilestoneDot({
rank,
event,
x,
@@ -236,11 +402,7 @@ function TimelineDot({
const size = isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
const ring = isOpened ? "ring-2" : "ring-1";
const tipAlign =
x < 18
? "left-0"
: x > 82
? "right-0"
: "left-1/2 -translate-x-1/2";
x < 18 ? "left-0" : x > 82 ? "right-0" : "left-1/2 -translate-x-1/2";
return (
<span
role="img"
@@ -303,7 +465,6 @@ export function generateAxisTicks(minT: number, maxT: number): Array<{ t: number
plan.unit === "year"
? 0
: Math.floor(start.getMonth() / plan.step) * plan.step;
// Don't skip a tick that sits right at minT — start from minT-aligned step.
while (true) {
const t = new Date(y, m, 1).getTime();
if (t > maxT) break;
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { useState } from "react";
import { AttachmentLightbox } from "./AttachmentLightbox";
import { categoryFromMime, resolveMime } from "@/lib/mime.mjs";
/**
* Render an attachment row in the staff report with the right affordance
* for its type:
*
* - image / pdf -> button opens the inline lightbox
* - office -> download link + "View in Google Docs" secondary link
* - other -> plain download link
*
* The proxy URL is `/api/staff/file?id=&org=&key=` (re-streams with
* Content-Disposition: inline for previews, or `&dl=1` for downloads). The
* Civi-signed URL (carrying its short-lived fcs JWT) is passed straight to
* Google's Docs Viewer for office files; we deliberately don't proxy that
* one because Google's servers must fetch it without our staff key.
*/
export function FileLink({
fileId,
fileName,
civiSignedUrl,
mime: explicitMime,
org,
authKey,
civiBaseUrl,
}: {
fileId: number | string;
fileName: string;
/** From Attachment.get (includes fcs). Used for office Google Viewer + download fallback. */
civiSignedUrl?: string;
mime?: string;
org: number;
authKey: string;
civiBaseUrl: string;
}) {
const [open, setOpen] = useState(false);
const mime = resolveMime(explicitMime, fileName);
const category = categoryFromMime(mime);
const proxyBase =
`/api/staff/file?id=${encodeURIComponent(String(fileId))}` +
`&org=${encodeURIComponent(String(org))}` +
`&key=${encodeURIComponent(authKey)}`;
const previewSrc = proxyBase;
// Prefer Civi's signed URL for downloads when present (one fewer hop
// through our Lambda); the proxy is the fallback.
const downloadHref = civiSignedUrl
? absUrl(civiSignedUrl, civiBaseUrl)
: `${proxyBase}&dl=1`;
const linkClass =
"text-ink underline decoration-rule underline-offset-4 hover:decoration-ink";
if (category === "image" || category === "pdf") {
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className={
"bg-transparent p-0 text-left " +
linkClass +
" focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-700"
}
>
{fileName}
</button>
<AttachmentLightbox
open={open}
onClose={() => setOpen(false)}
previewSrc={previewSrc}
downloadHref={downloadHref}
filename={fileName}
mime={mime}
/>
</>
);
}
if (category === "office") {
// Google Docs Viewer renders DOC/DOCX/XLS/XLSX in a new tab. It fetches
// the source URL server-side, so the URL must be reachable without our
// staff key — that's why we pass the Civi-signed URL straight through.
const gview = civiSignedUrl
? `https://docs.google.com/viewer?url=${encodeURIComponent(absUrl(civiSignedUrl, civiBaseUrl))}`
: null;
return (
<span className="inline-flex flex-wrap items-baseline gap-x-2 gap-y-1">
<a
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
{fileName}
</a>
{gview && (
<a
href={gview}
target="_blank"
rel="noopener noreferrer"
title="Opens in Google Docs Viewer (file bytes are sent to Google to render)"
className="text-xs font-medium text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-ink hover:text-leaf-800"
>
View in Google Docs
</a>
)}
</span>
);
}
// "other" — unknown types: just a download link.
return (
<a
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
{fileName}
</a>
);
}
function absUrl(u: string, base: string): string {
if (u.startsWith("http")) return u;
if (!base) return u;
return `${base.replace(/\/+$/, "")}${u.startsWith("/") ? "" : "/"}${u}`;
}
+5 -2
View File
@@ -281,11 +281,14 @@ export function MembershipChart({
/>
))}
{/* Most-recent point value labels */}
{/* Most-recent point value labels. Both anchor to the chart's right
edge so the carry-forward endpoint label stays inside the viewBox
instead of overflowing past the right padding. */}
{actualCoords.length > 0 && (
<text
x={actualCoords[actualCoords.length - 1].x + 6}
x={xOf(maxT) - 4}
y={actualCoords[actualCoords.length - 1].y - 6}
textAnchor="end"
className="fill-leaf-800 text-[10px] font-medium tabular-nums"
>
{numberFmt.format(actualCoords[actualCoords.length - 1].v)}
+10 -10
View File
@@ -184,7 +184,7 @@ const stage0: StageSectionConfig = {
label: "Member-Owner Goal for current Stage",
type: "number",
civiField: `${G0}.Member_Goal_for_current_Stage`,
help: "What is your member-owner goal for your current co-op development Stage?",
help: "What is your member / owner goal for your current co-op development Stage?",
},
{
name: "Members__current_",
@@ -199,7 +199,7 @@ const stage0: StageSectionConfig = {
type: "number",
civiField: `${G0}.Total_members_at_opening`,
visibleWhen: visibleAtOrAfter(S.Stabilize),
help: "How many member-owners will be required at opening for this store?",
help: "How many member-owners did the co-op have on opening day?",
},
// {
// name: "Volunteers_Helping_In_Store",
@@ -285,7 +285,7 @@ const stage0: StageSectionConfig = {
type: "currency",
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
civiField: `${G0}.Member_equity_raised`,
help: "Member-owner equity raised, as of the time of survey",
help: "Member-owner equity raised, as of the time of check-in",
},
// {
// name: "Member_loans_total",
@@ -299,7 +299,7 @@ const stage0: StageSectionConfig = {
type: "currency",
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
civiField: `${G0}.Member_loans_raised`,
help: "Total amount (USD) raised from member-owner loans, as of the time of survey",
help: "Total amount (USD) raised from member-owner loans, as of the time of check-in",
},
// {
// name: "Member_preferred_shares_total",
@@ -320,7 +320,7 @@ const stage0: StageSectionConfig = {
// { name: "Grant_Donations_Needed", label: "Grants / Donations Needed", type: "currency", civiField: `${G0}.Grant_Donations_Needed` },
{ name: "Grants_Donations_Raised", label: "Grants / Donations Raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Grants_Donations_Raised`, help: "Total amount (USD) raised from Grants and Donations in the latest Sources and Uses doc" },
// { name: "Other_sources_total", label: "Other sources total needed", type: "currency", civiField: `${G0}.Other_sources_total` },
{ name: "Other_sources_raised", label: "Other sources raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Other_sources_raised`, help: "Total amount (USD) successfully raised from other sources in the latest Sources and Uses doc (e.g. community loans or grants, free fill, vendor credit, landlord contribution, etc). Does not include member-owner contributions (loans or equity) or bank debt." },
{ name: "Other_sources_raised", label: "Other sources raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Other_sources_raised`, help: "Total amount (USD) successfully raised from other sources in the latest Sources and Uses doc (e.g. community loans or grants, free fill, vendor credit, landlord contribution, etc). Does not include member contributions (loans or equity) or bank debt." },
// { name: "Date_Closed_Folded", label: "Date Closed / Folded", type: "date", civiField: `${G0}.Date_Closed_Folded` },
{
name: "FTEs",
@@ -346,7 +346,7 @@ const stage1: StageSectionConfig = {
label: "Preliminary Market Assessment",
type: "date",
civiField: `${G1}.Preliminary_Market_Assessment`,
help: "What date was your Preliminary Market Assessment completed?",
help: "What date was your most recent Preliminary Market Assessment completed?",
},
{
name: "Preliminary_Market_Assessment_Upload",
@@ -360,7 +360,7 @@ const stage1: StageSectionConfig = {
label: "Preliminary Sources And Uses",
type: "date",
civiField: `${G1}.Preliminary_Sources_Uses`,
help: "When was your Preliminary Sources & Uses completed?",
help: "When was your most recent Preliminary Sources & Uses completed?",
},
{
name: "Preliminary_Sources_Uses_Upload",
@@ -551,14 +551,14 @@ const stage3: StageSectionConfig = {
label: "Capital Campaign: Owner Participation %",
type: "percent",
civiField: `${G3}.Capital_Campaign_Owner_Participation_`,
help: "What percentage of your owners have contributed to your capital campaign?",
help: "What percentage of your member-owners have contributed to your capital campaign?",
},
{
name: "Capital_Campaign_Average_Owner_Investment",
label: "Capital Campaign: Average Owner Investment",
type: "currency",
civiField: `${G3}.Capital_Campaign_Average_Owner_Investment`,
help: "What is the average investment size from your owners? (i.e. total raised divided by number of owners participating. If your co-op raised $100,000 from 100 owners, the average owner investment would be $1,000. $100,000 / 100 = $1,000.)",
help: "What is the average investment size from your member-owners? (i.e. total raised divided by number of member-owners participating. If your co-op raised $100,000 from 100 owners, the average member-owner investment would be $1,000. $100,000 / 100 = $1,000.)",
},
] as FieldConfig[],
fieldGroups: [
@@ -781,7 +781,7 @@ export const formConfig: FormConfig = {
id: "org_engagement_check_in",
title: "Co-op Survey",
subtitle:
"Thank you for updating your co-op information. The questions you'll see depend on where you are in the Framework.",
"Thank you for updating your co-op information.",
stageField: "current_stage",
sections: [submitterInfo, stage0, stage1, stage2, stage3, stage4, stage5],
};
+86
View File
@@ -0,0 +1,86 @@
// @ts-check
/**
* Mime helpers for staff-report attachment rendering.
*
* The staff report shows file attachments with three different affordances:
* - images / PDF -> inline lightbox preview
* - office docs -> plain download + "View in Google Docs" link
* - anything else -> plain download
*
* Civi can serve a mime via `Attachment.get`, but historical uploads may
* have a stale or missing `mime_type` column. Fall back to extension-based
* inference so we always reach a stable category.
*
* Written as JS+JSDoc rather than TS so Node's built-in --test runner can
* import this file directly without any tooling — matches the pattern set
* by lib/staff-field-mapping.mjs.
*
* @typedef {"image" | "pdf" | "office" | "other"} AttachmentCategory
*/
/** @type {Record<string, string>} */
const EXT_TO_MIME = {
pdf: "application/pdf",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
};
const OFFICE_MIMES = new Set([
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
]);
/**
* Pull the lowercased extension from a filename, or "" if absent.
* @param {string | undefined | null} filename
* @returns {string}
*/
export function extOf(filename) {
if (!filename) return "";
const dot = filename.lastIndexOf(".");
if (dot < 0 || dot === filename.length - 1) return "";
return filename.slice(dot + 1).toLowerCase();
}
/**
* Map a filename's extension to a known mime, or null if unrecognised.
* @param {string | undefined | null} filename
* @returns {string | null}
*/
export function mimeFromFilename(filename) {
const ext = extOf(filename);
return EXT_TO_MIME[ext] ?? null;
}
/**
* Resolve a mime by trusting the explicit value first, then falling back to
* filename inference. Returns "application/octet-stream" if nothing matches.
* @param {string | undefined | null} explicit
* @param {string | undefined | null} filename
* @returns {string}
*/
export function resolveMime(explicit, filename) {
if (explicit && explicit !== "application/octet-stream") return explicit;
return mimeFromFilename(filename) ?? explicit ?? "application/octet-stream";
}
/**
* Categorise a mime for UI dispatch.
* @param {string} mime
* @returns {AttachmentCategory}
*/
export function categoryFromMime(mime) {
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
if (OFFICE_MIMES.has(mime)) return "office";
return "other";
}
+82
View File
@@ -0,0 +1,82 @@
// Run with: npm run test:mime
import test from "node:test";
import assert from "node:assert/strict";
import {
extOf,
mimeFromFilename,
resolveMime,
categoryFromMime,
} from "./mime.mjs";
test("extOf returns lowercased extension", () => {
assert.equal(extOf("Photo.JPG"), "jpg");
assert.equal(extOf("doc.tar.gz"), "gz");
});
test("extOf handles missing or trailing-dot names", () => {
assert.equal(extOf(""), "");
assert.equal(extOf(null), "");
assert.equal(extOf("no-extension"), "");
assert.equal(extOf("trailing."), "");
});
test("mimeFromFilename recognises known types", () => {
assert.equal(mimeFromFilename("vision.pdf"), "application/pdf");
assert.equal(mimeFromFilename("budget.XLSX"),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
assert.equal(mimeFromFilename("logo.PNG"), "image/png");
});
test("mimeFromFilename returns null for unknown extensions", () => {
assert.equal(mimeFromFilename("archive.zip"), null);
assert.equal(mimeFromFilename("readme"), null);
});
test("resolveMime trusts an explicit non-octet mime", () => {
assert.equal(resolveMime("application/pdf", "anything.txt"), "application/pdf");
});
test("resolveMime falls back to filename when explicit is missing", () => {
assert.equal(resolveMime(undefined, "report.pdf"), "application/pdf");
assert.equal(resolveMime(null, "image.gif"), "image/gif");
});
test("resolveMime falls back to filename when explicit is octet-stream", () => {
assert.equal(
resolveMime("application/octet-stream", "spreadsheet.xlsx"),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
});
test("resolveMime returns octet-stream when nothing is known", () => {
assert.equal(resolveMime(null, null), "application/octet-stream");
assert.equal(resolveMime(undefined, "unknownfile"), "application/octet-stream");
});
test("categoryFromMime: image", () => {
assert.equal(categoryFromMime("image/png"), "image");
assert.equal(categoryFromMime("image/webp"), "image");
});
test("categoryFromMime: pdf", () => {
assert.equal(categoryFromMime("application/pdf"), "pdf");
});
test("categoryFromMime: office variants", () => {
assert.equal(categoryFromMime("application/msword"), "office");
assert.equal(
categoryFromMime("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
"office",
);
assert.equal(categoryFromMime("application/vnd.ms-excel"), "office");
assert.equal(
categoryFromMime("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
"office",
);
});
test("categoryFromMime: other", () => {
assert.equal(categoryFromMime("application/zip"), "other");
assert.equal(categoryFromMime("text/plain"), "other");
assert.equal(categoryFromMime("application/octet-stream"), "other");
});
+121
View File
@@ -0,0 +1,121 @@
import type { ActivitySummary } from "@/types/form";
/**
* CiviCRM activity stage values → Framework Stage rank (1..5). "Inquiry"
* predates Stage 1 and is conventionally rank 0 (pre-engagement); callers
* that render stage lanes 1..5 should treat 0 as "no rank in scope".
*/
export const STAGE_RANK: Record<string, number> = {
Inquiry: 0,
Organizing: 1,
Feasibility: 2,
"Business feasibility": 3,
"Store Implementation": 4,
"Stabilize newly opened co-op": 5,
};
/**
* Build a resolver that returns the co-op's Framework Stage rank at a given
* point in time, derived from the activity stream. Only activities that
* carry a non-empty `stage` value are stage transitions; their stage holds
* from that activity's date forward until the next transition.
*
* Returns `null` when the date precedes any known transition (i.e. we have
* no evidence of which stage the co-op was in at that point) — callers can
* fall back to a field-section default in that case.
*/
export function buildStageRankAtDate(
activities: ActivitySummary[]
): (isoDate: string) => number | null {
const transitions = activities
.filter(
(a): a is ActivitySummary & { stage: string } =>
typeof a.stage === "string" && a.stage.length > 0
)
.map((a) => ({ t: new Date(a.date).getTime(), stage: a.stage }))
.filter((tr) => Number.isFinite(tr.t))
.sort((a, b) => a.t - b.t);
return (isoDate: string) => {
const t = new Date(isoDate).getTime();
if (!Number.isFinite(t)) return null;
let last: number | null = null;
for (const tr of transitions) {
if (tr.t > t) break;
const r = STAGE_RANK[tr.stage];
if (typeof r === "number") last = r;
}
return last;
};
}
export interface StageRange {
rank: number;
/** ISO date this stage period begins (the stage-transition activity). */
startDate: string;
/**
* ISO date the period ends — the next chronologically-later activity with
* a higher stage rank. `null` means the period is open-ended (still in
* effect today); the chart should extend it to the right edge.
*/
endDate: string | null;
activityId: number;
subject: string | null;
}
/**
* Derive per-activity stage ranges from the activity stream. Each
* stage-transition activity (one with a non-empty `stage`) produces one
* range: start = the activity's date, end = the date of the next
* chronologically-following activity whose stage rank is *strictly higher*.
* If no later activity has a higher rank, the range is open-ended.
*
* Multiple activities at the same stage each yield their own range; they'll
* share the same end date if they sit between the same two higher-rank
* boundaries.
*/
export function computeStageRanges(activities: ActivitySummary[]): StageRange[] {
type Item = {
t: number;
rank: number;
activityId: number;
isoDate: string;
subject: string | null;
};
const items: Item[] = [];
for (const a of activities) {
if (typeof a.stage !== "string" || a.stage.length === 0) continue;
const r = STAGE_RANK[a.stage];
if (typeof r !== "number" || r < 1 || r > 5) continue;
const t = new Date(a.date).getTime();
if (!Number.isFinite(t)) continue;
items.push({
t,
rank: r,
activityId: a.id,
isoDate: a.date,
subject: a.subject ?? null,
});
}
items.sort((x, y) => x.t - y.t);
const ranges: StageRange[] = [];
for (let i = 0; i < items.length; i++) {
const cur = items[i];
let endIso: string | null = null;
for (let j = i + 1; j < items.length; j++) {
if (items[j].rank > cur.rank) {
endIso = items[j].isoDate;
break;
}
}
ranges.push({
rank: cur.rank,
startDate: cur.isoDate,
endDate: endIso,
activityId: cur.activityId,
subject: cur.subject,
});
}
return ranges;
}
+46 -15
View File
@@ -6,14 +6,15 @@ import type { NextConfig } from "next";
*
* Two profiles:
* - strict (default): frame-ancestors 'none' + X-Frame-Options: DENY.
* Applied to every route except /staff/report.
* Applied to every route except the embed-friendly ones.
* - staff-embed: frame-ancestors 'self' <civi-origin>, no X-Frame-Options.
* Lets the CiviCRM "Engagement Report" extension embed the staff page
* in an iframe on contact pages.
* Lets the CiviCRM "Engagement Report" extension embed /staff/report,
* and lets the lightbox iframe inside that page load the
* /api/staff/file proxy for PDF preview.
*
* 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.
* The catch-all source uses a negative lookahead so it does NOT match the
* embed-friendly routes — otherwise both rules apply and the browser ANDs
* the frame-ancestors directives together, blocking embedding entirely.
*/
const isDev = process.env.NODE_ENV !== "production";
const devOnlyDynamicScript = isDev ? " 'unsafe-eval'" : "";
@@ -42,13 +43,38 @@ const sharedHeaders = [
},
];
function civiOriginForCsp(): string {
const raw = process.env.CIVI_BASE_URL;
if (!raw) return "";
/**
* Civi origins permitted to embed the staff report in an iframe.
*
* Reads `CIVI_FRAME_ALLOWED_ORIGINS` (comma-separated origins) so a single
* survey.fci.coop deployment can be framed by both the dev Civi
* (client.crm.fci.coop) and the production Civi (crm.fci.coop). Falls
* back to the origin of `CIVI_BASE_URL` when the multi-origin var isn't
* set so existing single-Civi deployments keep working.
*
* Each entry is validated as a parsable URL; bad values are dropped and
* logged at build time rather than silently making the CSP invalid.
*/
function civiOriginsForCsp(): string[] {
const explicit = process.env.CIVI_FRAME_ALLOWED_ORIGINS;
if (explicit) {
const raws = explicit.split(",").map((s) => s.trim()).filter(Boolean);
const origins: string[] = [];
for (const raw of raws) {
try {
return new URL(raw).origin;
origins.push(new URL(raw).origin);
} catch {
return "";
console.warn(`[next.config] ignoring invalid CIVI_FRAME_ALLOWED_ORIGINS entry: ${raw}`);
}
}
return origins;
}
const base = process.env.CIVI_BASE_URL;
if (!base) return [];
try {
return [new URL(base).origin];
} catch {
return [];
}
}
@@ -59,12 +85,13 @@ const strictHeaders = [
];
const staffEmbedHeaders = (() => {
const origin = civiOriginForCsp();
const frameAncestors = origin ? `'self' ${origin}` : "'self'";
const origins = civiOriginsForCsp();
const frameAncestors = origins.length > 0 ? `'self' ${origins.join(" ")}` : "'self'";
return [
{ key: "Content-Security-Policy", value: buildCsp(frameAncestors) },
...sharedHeaders,
// Intentionally NO X-Frame-Options: frame-ancestors above is the policy.
// (X-Frame-Options can only encode one origin; CSP supersedes here.)
];
})();
@@ -84,8 +111,12 @@ const nextConfig: NextConfig = {
async headers() {
return [
{ source: "/staff/report", headers: staffEmbedHeaders },
// Catch-all that explicitly excludes /staff/report — see header notes.
{ source: "/((?!staff/report).*)", headers: strictHeaders },
// The lightbox in /staff/report iframes this proxy for inline PDF
// previews. Must share the embed-friendly profile so the browser
// doesn't block the same-origin iframe.
{ source: "/api/staff/file", headers: staffEmbedHeaders },
// Catch-all that excludes the embed-friendly routes — see header notes.
{ source: "/((?!staff/report|api/staff/file).*)", headers: strictHeaders },
];
},
};
+3 -1
View File
@@ -8,7 +8,9 @@
"start": "next start",
"lint": "eslint",
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs",
"test:mapping": "node --test lib/staff-field-mapping.test.mjs"
"test:mapping": "node --test lib/staff-field-mapping.test.mjs",
"test:mime": "node --test lib/mime.test.mjs",
"test": "node --test lib/staff-field-mapping.test.mjs lib/mime.test.mjs"
},
"dependencies": {
"next": "16.2.6",