Files
WebForm-mw/app/staff/report/page.tsx
T
Joel Brock 818c979479 Replace raw staff key in URLs with signed, org-bound tokens
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.
2026-10-30 11:17:28 -07:00

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&apos;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=&lt;civi-org-id&gt;</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>
);
}