The Engagement Report tab wrote STAFF_REPORT_KEY into the iframe src for every user with "access CiviCRM", without checking contact permissions. Anyone could copy it from the DOM and read any org's report and files, bypassing Civi ACLs; the key never expired and landed in access logs. - Extension (0.4.0): Tab.php checks Contact_Permission::allow(VIEW) and mints sig = base64url(HMAC-SHA256(key, "webform-mw.staff-report.v1: <org>:<exp>")) with an 8h exp. The secret no longer leaves the server. - App: lib/staff-token.mjs verifies the token (constant-time, org must match, rejects expired or >12h tokens). /staff/report, /api/staff/report and /api/staff/file share staffAuthQuery(); client links carry exp/sig. - Legacy ?key= is still accepted (constant-time compare, logs a warning) so the app can deploy before the extension. Remove it once every Civi install runs extension 0.4.0. - PHP/Node signature compatibility is pinned by a PHP-generated vector.
115 lines
3.3 KiB
TypeScript
115 lines
3.3 KiB
TypeScript
import { Suspense } from "react";
|
|
import { StaffReportView } from "@/components/StaffReportView";
|
|
import { SiteHeader, SiteFooter } from "@/components/SiteChrome";
|
|
import { staffAuthQuery } from "@/lib/staff-auth";
|
|
|
|
interface PageProps {
|
|
searchParams: Promise<{ org?: string; key?: string; exp?: string; sig?: string; frame?: string }>;
|
|
}
|
|
|
|
export const metadata = {
|
|
title: "Staff report — Food Co-op Initiative",
|
|
robots: { index: false, follow: false },
|
|
};
|
|
|
|
export default async function StaffReportPage({ searchParams }: PageProps) {
|
|
const { org, key, exp, sig, frame } = await searchParams;
|
|
const isFramed = frame === "1";
|
|
|
|
// Generic "not found" if the token/key is missing or wrong — don't
|
|
// confirm route existence.
|
|
const authQuery = staffAuthQuery({ org, exp, sig, key });
|
|
if (!authQuery) {
|
|
return <NotFound framed={isFramed} />;
|
|
}
|
|
|
|
const orgId = Number(org);
|
|
const orgValid = !!org && Number.isFinite(orgId) && orgId > 0;
|
|
// CIVI_BASE_URL flows from server config to client only as a base for
|
|
// outbound file links. No secret material is exposed.
|
|
const civiBaseUrl = process.env.CIVI_BASE_URL ?? "";
|
|
|
|
// When embedded in CiviCRM (?frame=1), drop the site header/footer so the
|
|
// report fills the iframe cleanly. Standalone visits keep full chrome.
|
|
const body = (
|
|
<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}
|
|
authQuery={authQuery}
|
|
civiBaseUrl={civiBaseUrl}
|
|
framed={isFramed}
|
|
/>
|
|
</Suspense>
|
|
) : (
|
|
<MissingOrg />
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
|
|
if (isFramed) return body;
|
|
|
|
return (
|
|
<>
|
|
<a
|
|
href="#main"
|
|
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-paper focus:px-3 focus:py-2 focus:text-ink focus:shadow"
|
|
>
|
|
Skip to content
|
|
</a>
|
|
<SiteHeader />
|
|
{body}
|
|
<SiteFooter />
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<SiteHeader />
|
|
<main className="flex-1">{inner}</main>
|
|
<SiteFooter />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MissingOrg() {
|
|
return (
|
|
<div role="alert" className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7">
|
|
<h2 className="font-display text-xl font-medium text-clay-700">
|
|
Missing or invalid org id.
|
|
</h2>
|
|
<p className="mt-3 leading-relaxed text-ink-soft">
|
|
Add <code className="font-mono">?org=<civi-org-id></code> to the URL. The org
|
|
id is the Civi Contact id of the organization (visible in the URL when
|
|
viewing the org in CiviCRM).
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|