Files
WebForm-mw/components/StaffReportView.tsx
T
Joel Brock d7a1396640 Staff file proxy: harden against SVG XSS and SSRF
- Allowlist inline MIME types (png/jpeg/gif/webp/pdf only); everything
  else, including SVG and HTML, served as application/octet-stream
  with content-disposition: attachment.
- X-Content-Type-Options: nosniff and a restrictive CSP on every response.
- Validate the upstream URL Civi returns: must match CIVI_BASE_URL origin
  before we attach basic-auth creds and follow it. redirect: manual to
  prevent off-host hops.
- Drop SVG from the client's inline-image list (server forces download).
2026-06-05 17:02:42 -07:00

410 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import type {
FieldConfig,
FieldHistoryEntry,
SelectOption,
StageSectionConfig,
StaffReportField,
StaffReportPayload,
StaffReportSection,
} from "@/types/form";
import { MembershipChart, MEMBERS_ACTUAL_NAME, MEMBERS_GOAL_NAME } from "./report/MembershipChart";
import { DateTimeline } from "./report/DateTimeline";
import {
FieldHistoryRow,
formatShortDate,
formatLongDate,
computeDateRange,
} from "./report/FieldHistory";
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
interface StaffReportViewProps {
org: number;
authKey: string;
}
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: StaffReportPayload };
export function StaffReportView({ org, authKey }: StaffReportViewProps) {
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
useEffect(() => {
let alive = true;
(async () => {
try {
const res = await fetch(
`/api/staff/report?org=${encodeURIComponent(String(org))}&key=${encodeURIComponent(authKey)}`,
{ cache: "no-store" },
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
if (alive)
setLoad({
kind: "error",
message: body.error ?? `Request failed (${res.status})`,
});
return;
}
const data = (await res.json()) as StaffReportPayload;
if (alive) setLoad({ kind: "ready", data });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (alive) setLoad({ kind: "error", message: msg });
}
})();
return () => {
alive = false;
};
}, [org, authKey]);
if (load.kind === "loading") return <LoadingState />;
if (load.kind === "error") return <ErrorState message={load.message} />;
const { data } = load;
if (data.sections.length === 0 && data.activities.length === 0) return <EmptyState />;
// Pull the membership + goal series out wherever they live (Check_in_data__organizing_).
const checkInSection = data.sections.find((s) => s.groupName === "Check_in_data__organizing_");
const membersField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_ACTUAL_NAME);
const goalField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_GOAL_NAME);
const stageLabel =
data.currentStage
? data.options[75]?.find((o) => o.value === data.currentStage)?.label ?? data.currentStage
: "—";
const dateRange = computeDateRange(data.activities.map((a) => a.date));
return (
<article className="space-y-12">
<header className="space-y-4">
<p className="text-[11px] uppercase tracking-[0.18em] text-leaf-700">
Staff report · Internal use only
</p>
<h1 className="font-display text-[40px] font-normal leading-[1.05] tracking-tight text-ink sm:text-[52px]">
{data.orgName}
</h1>
<dl className="grid grid-cols-2 gap-6 text-sm sm:grid-cols-4">
<Stat label="Current stage" value={stageLabel} />
<Stat label="Submissions" value={String(data.activities.length)} />
<Stat
label="Date range"
value={
dateRange
? `${formatShortDate(dateRange.from)} ${formatShortDate(dateRange.to)}`
: "—"
}
/>
<Stat label="Org id" value={<code className="font-mono">{data.orgId}</code>} />
</dl>
<div className="h-px bg-rule" />
</header>
{membersField && membersField.history.length > 0 ? (
<MembershipChart
membersHistory={historyOnly(membersField)}
goalHistory={goalField ? historyOnly(goalField) : undefined}
/>
) : null}
{/* Build a synthetic "sections + fieldHistory" view the DateTimeline understands. */}
<StaffDateTimeline data={data} />
{data.sections.map((section) => (
<StaffSection key={section.groupName} section={section} options={data.options} authKey={authKey} />
))}
<ActivityTable activities={data.activities} options={data.options} />
</article>
);
}
function historyOnly(f: StaffReportField): FieldHistoryEntry[] {
return f.history;
}
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt className="text-[11px] uppercase tracking-[0.16em] text-ink-soft">{label}</dt>
<dd className="mt-1 text-[15px] text-ink">{value}</dd>
</div>
);
}
function StaffSection({
section,
options,
authKey,
}: {
section: StaffReportSection;
options: Record<number, SelectOption[]>;
authKey: string;
}) {
const filled = section.fields.filter((f) => f.history.length > 0);
const empty = section.fields.filter((f) => f.history.length === 0);
return (
<section className="space-y-4">
<h2 className="font-display text-2xl font-medium text-ink">{section.groupTitle}</h2>
<p className="text-[12px] uppercase tracking-[0.16em] text-ink-soft">
{section.fields.length} field{section.fields.length === 1 ? "" : "s"} ·{" "}
{filled.length} with data
</p>
<ul className="divide-y divide-rule rounded-md border border-rule bg-paper">
{filled.map((f) => (
<li key={f.descriptor.name} className="px-4 py-4">
{f.descriptor.render === "file" ? (
<FileFieldRow field={f} authKey={authKey} />
) : (
<FieldHistoryRow
field={fieldConfigFor(f)}
entries={f.history}
options={options}
/>
)}
</li>
))}
{empty.map((f) => (
<li
key={f.descriptor.name}
className="flex items-center justify-between px-4 py-3 text-sm text-ink-soft"
>
<span>{f.descriptor.label}</span>
<span></span>
</li>
))}
</ul>
</section>
);
}
/**
* Adapt a StaffReportField to the FieldConfig shape FieldHistoryRow / FormattedValue
* expect. The renderer only reads `name`, `label`, `type`, `optionGroupId`.
*/
function fieldConfigFor(f: StaffReportField): FieldConfig {
return {
name: f.descriptor.name,
label: f.descriptor.label,
type: renderToFieldType(f.descriptor.render),
optionGroupId: f.descriptor.optionGroupId,
};
}
function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldConfig["type"] {
switch (r) {
case "currency":
return "currency";
case "date":
return "date";
case "datetime":
return "date";
case "select":
return "select";
case "multiselect":
return "multiselect";
case "file":
return "file";
case "longtext":
return "textarea";
case "boolean":
return "boolean";
case "number":
return "number";
default:
return "text";
}
}
function FileFieldRow({ field, authKey }: { field: StaffReportField; authKey: string }) {
return (
<div>
<p className="font-medium text-ink">{field.descriptor.label}</p>
<ul className="mt-2 space-y-2">
{field.history.map((entry) => (
<FilePreviewItem key={`${entry.activityId}-${entry.date}`} entry={entry} authKey={authKey} />
))}
</ul>
</div>
);
}
function FilePreviewItem({
entry,
authKey,
}: {
entry: FieldHistoryEntry;
authKey: string;
}) {
const v = entry.value as { id?: number | string; file_name?: string } | null;
if (!v || v.id === undefined) return null;
const id = String(v.id);
const name = v.file_name ?? `file-${id}`;
const href = `/api/staff/file?id=${encodeURIComponent(id)}&key=${encodeURIComponent(authKey)}`;
const ext = (name.split(".").pop() ?? "").toLowerCase();
// SVG omitted on purpose — the proxy forces SVG to download (XSS hardening),
// so an inline <img> here would just show a broken thumbnail.
const isImage = ["png", "jpg", "jpeg", "gif", "webp"].includes(ext);
const isPdf = ext === "pdf";
return (
<li className="flex items-start gap-3">
{isImage ? (
<a href={href} target="_blank" rel="noopener noreferrer">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={href}
alt={name}
className="h-20 w-20 rounded border border-rule object-cover"
/>
</a>
) : null}
<div className="flex-1">
<a
href={href}
download={name}
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink"
>
{name}
</a>
{entry.date ? (
<p className="text-xs text-ink-soft">submitted {formatLongDate(entry.date)}</p>
) : null}
{isPdf ? <PdfPreviewButton href={href} name={name} /> : null}
</div>
</li>
);
}
function PdfPreviewButton({ href, name }: { href: string; name: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="mt-1 text-xs uppercase tracking-[0.12em] text-leaf-700 underline-offset-4 hover:underline"
>
Preview
</button>
{open ? (
<div
role="dialog"
aria-modal="true"
aria-label={`Preview of ${name}`}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/60 p-4"
onClick={() => setOpen(false)}
>
<div
className="relative max-h-[90vh] w-full max-w-4xl overflow-hidden rounded-lg bg-paper shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setOpen(false)}
className="absolute right-3 top-3 z-10 rounded bg-paper px-2 py-1 text-xs uppercase tracking-[0.12em] text-ink shadow"
>
Close
</button>
<iframe src={href} title={name} className="h-[85vh] w-full" />
</div>
</div>
) : null}
</>
);
}
function ActivityTable({
activities,
options,
}: {
activities: StaffReportPayload["activities"];
options: Record<number, SelectOption[]>;
}) {
if (activities.length === 0) {
return (
<section className="space-y-3">
<h2 className="font-display text-2xl font-medium text-ink">All submissions</h2>
<p className="text-sm text-ink-soft">No submissions recorded for this organization yet.</p>
</section>
);
}
const stageOptions = options[75] ?? [];
const stageLabel = (v: string | null | undefined) =>
v ? stageOptions.find((o) => o.value === v)?.label ?? v : "—";
return (
<section className="space-y-3">
<h2 className="font-display text-2xl font-medium text-ink">All submissions</h2>
<div className="overflow-x-auto rounded-md border border-rule">
<table className="min-w-full text-sm">
<thead className="bg-paper text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
<tr>
<th className="px-3 py-2">Date</th>
<th className="px-3 py-2">Stage snapshot</th>
<th className="px-3 py-2">Subject</th>
<th className="px-3 py-2">Submitted by</th>
<th className="px-3 py-2">Activity id</th>
</tr>
</thead>
<tbody className="divide-y divide-rule">
{activities.map((a) => (
<tr key={a.id}>
<td className="px-3 py-2 text-ink">{formatShortDate(a.date)}</td>
<td className="px-3 py-2 text-ink">{stageLabel(a.stage ?? null)}</td>
<td className="px-3 py-2 text-ink">{a.subject ?? "—"}</td>
<td className="px-3 py-2 text-ink">{a.submittedBy ?? "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-ink-soft">{a.id}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
/**
* The shared DateTimeline expects `sections: StageSectionConfig[]` and
* `fieldHistory: Record<string, FieldHistoryEntry[]>`. Build that shape
* from the staff payload so the timeline strip works untouched.
*
* We synthesise a single section per CiviCRM custom group with its
* dated fields, and key the fieldHistory by field name (DateTimeline only
* looks at the date values, not the field meta).
*/
function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
const fieldHistory: Record<string, FieldHistoryEntry[]> = {};
const fieldConfigs: FieldConfig[] = [];
for (const section of data.sections) {
if (section.groupKind !== "activity") continue;
for (const f of section.fields) {
if (f.descriptor.render !== "date" && f.descriptor.render !== "datetime") continue;
if (f.history.length === 0) continue;
fieldHistory[f.descriptor.name] = f.history;
fieldConfigs.push(fieldConfigFor(f));
}
}
if (fieldConfigs.length === 0) return null;
// DateTimeline groups its dots by `section.rank`. Use rank 0 for
// everything since the staff view doesn't surface stage progression.
const sections: StageSectionConfig[] = [
{
rank: 0,
id: "all",
label: "All dated events",
fields: fieldConfigs,
},
];
return <DateTimeline sections={sections} fieldHistory={fieldHistory} />;
}