From b548b6425be6518d3cc4e5d0419e1913d15fda13 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Fri, 5 Jun 2026 17:28:17 -0700 Subject: [PATCH] Staff report: compact rows, anchor nav, Civi file links, Y1 matrix UX iteration after first live look: - Sticky anchor strip below the header with a chip per section (incl. Submissions) so staff can jump around a long page. - Compact one-line rows that show only the latest value; multi-history fields get a muted 'N earlier entries' toggle that reveals the rest inline. Same affordance for file fields. - Empty fields collapse under a single 'N empty fields' toggle per section instead of taking a row each. - Stage 5: Y1_Q_ fields render as a read-only matrix table (rows: metrics; columns: Q1..Q4) matching the form's matrix layout. File proxy (/api/staff/file) deleted. APIv4 Attachment isn't exposed on this Civi instance (per the June upload spike), which is why the previous proxy returned broken images. Staff are already authenticated to Civi when they arrive here, so file fields now render as outbound links to CIVI_BASE_URL/civicrm/file?reset=1&id= and the browser uses the staff session. No more proxy auth, no more SSRF surface to harden, no broken images. CIVI_BASE_URL flows from the staff page (server component) into the client as a prop. No secret material crosses the boundary. --- app/api/staff/file/route.ts | 135 --------- app/staff/report/page.tsx | 5 +- components/StaffReportView.tsx | 481 ++++++++++++++++++++++----------- 3 files changed, 329 insertions(+), 292 deletions(-) delete mode 100644 app/api/staff/file/route.ts diff --git a/app/api/staff/file/route.ts b/app/api/staff/file/route.ts deleted file mode 100644 index 7c45a03..0000000 --- a/app/api/staff/file/route.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * GET /api/staff/file?id=&key= - * - * Streams an attachment from CiviCRM to the caller. The Civi API user's - * credentials never leave the server. Auth is the same shared - * STAFF_REPORT_KEY used by /api/staff/report. - * - * In stub mode, returns a tiny placeholder PNG so the UI's preview path - * is exercisable in dev. - */ - -import { NextRequest, NextResponse } from "next/server"; -import { isStaffKeyValid } from "@/lib/staff-auth"; -import { civi } from "@/lib/civicrm"; - -function isCiviStubMode(): boolean { - return !( - process.env.CIVI_BASE_URL && - process.env.CIVI_API_KEY && - process.env.CIVI_SITE_KEY - ); -} - -// 1x1 transparent PNG, base64-encoded — used as a stub attachment so the -// UI's image preview path renders something in dev. -const STUB_PNG_B64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; - -export async function GET(req: NextRequest) { - const url = new URL(req.url); - const key = url.searchParams.get("key"); - const idStr = url.searchParams.get("id"); - - if (!isStaffKeyValid(key)) { - return new NextResponse("Not found", { status: 404 }); - } - - const id = Number(idStr); - if (!idStr || !Number.isFinite(id) || id <= 0) { - return new NextResponse("Bad request", { status: 400 }); - } - - if (isCiviStubMode()) { - const bytes = Buffer.from(STUB_PNG_B64, "base64"); - return new NextResponse(bytes, { - status: 200, - headers: { - "content-type": "image/png", - "content-disposition": `inline; filename="stub-${id}.png"`, - "cache-control": "private, max-age=60", - }, - }); - } - - try { - // Look up the attachment URL and metadata. - const meta = await civi<{ id: number; url: string; mime_type: string; name: string }>( - "Attachment", - "get", - { - select: ["id", "url", "mime_type", "name"], - where: [["id", "=", id]], - }, - ); - const row = meta.values?.[0]; - if (!row?.url) { - return new NextResponse("Not found", { status: 404 }); - } - - // SSRF guard: only follow URLs whose origin matches CIVI_BASE_URL. Civi - // returns absolute URLs for attachments; if a compromised Civi (or DB row - // tamper) ever set this to an attacker-controlled host, the basic-auth - // creds attached below would leak. Validating the origin closes that. - const civiOrigin = new URL(process.env.CIVI_BASE_URL!).origin; - let upstreamUrl: URL; - try { - upstreamUrl = new URL(row.url); - } catch { - console.error(`[staff/file] malformed civi url id=${id}`); - return new NextResponse("Upstream error", { status: 502 }); - } - if (upstreamUrl.origin !== civiOrigin) { - console.error( - `[staff/file] refused cross-origin upstream id=${id} origin=${upstreamUrl.origin}`, - ); - return new NextResponse("Upstream error", { status: 502 }); - } - - const headers: Record = {}; - if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) { - const creds = Buffer.from( - `${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`, - ).toString("base64"); - headers["Authorization"] = `Basic ${creds}`; - } - - const upstream = await fetch(upstreamUrl, { headers, redirect: "manual" }); - if (!upstream.ok || !upstream.body) { - console.error( - `[staff/file] upstream fetch failed: id=${id} status=${upstream.status}`, - ); - return new NextResponse("Upstream error", { status: 502 }); - } - - // XSS guard: only allow a fixed allowlist of MIME types to render inline - // (browsers execute scripts inside SVGs and HTML, and will sniff some - // ambiguous types). Everything else is forced to attachment with a - // neutralised content-type. nosniff blocks MIME sniffing entirely. - const SAFE_INLINE = new Set([ - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "application/pdf", - ]); - const safeName = (row.name || `file-${id}`).replace(/[\r\n"]/g, ""); - const declaredMime = row.mime_type || upstream.headers.get("content-type") || "application/octet-stream"; - const isInline = SAFE_INLINE.has(declaredMime); - const servedMime = isInline ? declaredMime : "application/octet-stream"; - return new NextResponse(upstream.body, { - status: 200, - headers: { - "content-type": servedMime, - "content-disposition": `${isInline ? "inline" : "attachment"}; filename="${safeName}"`, - "cache-control": "private, max-age=60", - "x-content-type-options": "nosniff", - "content-security-policy": "default-src 'none'; sandbox; style-src 'unsafe-inline'", - }, - }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.error(`[staff/file] fetch threw: ${msg}`); - return new NextResponse("Upstream error", { status: 502 }); - } -} diff --git a/app/staff/report/page.tsx b/app/staff/report/page.tsx index 88d6af9..029ceef 100644 --- a/app/staff/report/page.tsx +++ b/app/staff/report/page.tsx @@ -23,6 +23,9 @@ export default async function StaffReportPage({ searchParams }: PageProps) { 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 ?? ""; return ( <> @@ -37,7 +40,7 @@ export default async function StaffReportPage({ searchParams }: PageProps) {
{orgValid ? ( - + ) : ( diff --git a/components/StaffReportView.tsx b/components/StaffReportView.tsx index 8443853..a256fac 100644 --- a/components/StaffReportView.tsx +++ b/components/StaffReportView.tsx @@ -13,7 +13,7 @@ import type { import { MembershipChart, MEMBERS_ACTUAL_NAME, MEMBERS_GOAL_NAME } from "./report/MembershipChart"; import { DateTimeline } from "./report/DateTimeline"; import { - FieldHistoryRow, + FormattedValue, formatShortDate, formatLongDate, computeDateRange, @@ -23,6 +23,8 @@ import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates"; interface StaffReportViewProps { org: number; authKey: string; + /** CIVI_BASE_URL, used to build outbound file links. */ + civiBaseUrl: string; } type LoadState = @@ -30,7 +32,9 @@ type LoadState = | { kind: "error"; message: string } | { kind: "ready"; data: StaffReportPayload }; -export function StaffReportView({ org, authKey }: StaffReportViewProps) { +const STAGE_OPTION_GROUP_ID = 75; + +export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewProps) { const [load, setLoad] = useState({ kind: "loading" }); useEffect(() => { @@ -67,19 +71,18 @@ export function StaffReportView({ org, authKey }: StaffReportViewProps) { const { data } = load; if (data.sections.length === 0 && data.activities.length === 0) return ; - // 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 stageLabel = data.currentStage + ? data.options[STAGE_OPTION_GROUP_ID]?.find((o) => o.value === data.currentStage)?.label + ?? data.currentStage + : "—"; const dateRange = computeDateRange(data.activities.map((a) => a.date)); return ( -
+

Staff report · Internal use only @@ -103,18 +106,24 @@ export function StaffReportView({ org, authKey }: StaffReportViewProps) {

+ 0} /> + {membersField && membersField.history.length > 0 ? ( ) : null} - {/* Build a synthetic "sections + fieldHistory" view the DateTimeline understands. */} {data.sections.map((section) => ( - + ))} @@ -122,10 +131,6 @@ export function StaffReportView({ org, authKey }: StaffReportViewProps) { ); } -function historyOnly(f: StaffReportField): FieldHistoryEntry[] { - return f.history; -} - function Stat({ label, value }: { label: string; value: React.ReactNode }) { return (
@@ -135,58 +140,229 @@ function Stat({ label, value }: { label: string; value: React.ReactNode }) { ); } +/** Sticky horizontal anchor strip — one chip per section + Submissions. */ +function SectionAnchorNav({ + sections, + hasActivities, +}: { + sections: StaffReportSection[]; + hasActivities: boolean; +}) { + const items = sections.map((s) => ({ + href: `#section-${s.groupName}`, + label: s.groupKind === "org" ? "Org profile" : s.groupTitle, + })); + if (hasActivities) items.push({ href: "#section-submissions", label: "Submissions" }); + return ( + + ); +} + function StaffSection({ section, options, - authKey, + civiBaseUrl, }: { section: StaffReportSection; options: Record; - authKey: string; + civiBaseUrl: string; }) { const filled = section.fields.filter((f) => f.history.length > 0); const empty = section.fields.filter((f) => f.history.length === 0); + const [showEmpty, setShowEmpty] = useState(false); + + // Stage 5 Y1 matrix: pull Labor / Margin / Member_Sales quarterly fields + // out into a single tabular display matching the form's matrix layout. + const isStage5 = section.groupName === "Stage_5"; + const matrixFields = isStage5 ? collectY1MatrixFields(filled) : null; + const filledOutsideMatrix = + matrixFields + ? filled.filter((f) => !matrixFields.usedNames.has(f.descriptor.name)) + : filled; return ( -
-

{section.groupTitle}

+
+

+ {section.groupTitle} +

{section.fields.length} field{section.fields.length === 1 ? "" : "s"} ·{" "} {filled.length} with data

-
    - {filled.map((f) => ( -
  • - {f.descriptor.render === "file" ? ( - - ) : ( - - )} -
  • - ))} - {empty.map((f) => ( -
  • + ) : null} + + {filledOutsideMatrix.length > 0 ? ( +
      + {filledOutsideMatrix.map((f) => ( + + ))} +
    + ) : null} + + {empty.length > 0 ? ( +
    +
  • - ))} -
+ {empty.length} empty field{empty.length === 1 ? "" : "s"} + {showEmpty ? "▾" : "▸"} + + {showEmpty ? ( +
    + {empty.map((f) => ( +
  • + {f.descriptor.label} + +
  • + ))} +
+ ) : null} +
+ ) : null} ); } /** - * Adapt a StaffReportField to the FieldConfig shape FieldHistoryRow / FormattedValue - * expect. The renderer only reads `name`, `label`, `type`, `optionGroupId`. + * Compact, latest-only row. If the field has multiple history entries, a + * muted "N earlier entries" toggle reveals the rest inline. + * + * File-typed values render as an outbound link to CiviCRM rather than a + * proxied download — staff are already logged into Civi when they arrive + * here, and the server doesn't need to broker bytes. */ +function CompactFieldRow({ + field, + options, + civiBaseUrl, +}: { + field: StaffReportField; + options: Record; + civiBaseUrl: string; +}) { + const [open, setOpen] = useState(false); + const latest = field.history[0]; + const earlier = field.history.slice(1); + + return ( +
  • +
    + {field.descriptor.label} +
    + + {latest.date ? ( + + · {formatShortDate(latest.date)} + + ) : null} +
    +
    + {earlier.length > 0 ? ( +
    + + {open ? ( +
      + {earlier.map((e, i) => ( +
    • + + {formatShortDate(e.date)} +
    • + ))} +
    + ) : null} +
    + ) : null} +
  • + ); +} + +function FieldValue({ + field, + entry, + options, + civiBaseUrl, +}: { + field: StaffReportField; + entry: FieldHistoryEntry; + options: Record; + civiBaseUrl: string; +}) { + if (field.descriptor.render === "file") { + const v = entry.value as { id?: number | string; file_name?: string } | null; + if (!v || v.id === undefined) return ; + const id = String(v.id); + const name = v.file_name ?? `file-${id}`; + // Civi serves uploaded files at /civicrm/file?reset=1&id=. + // The staff member is already authenticated to Civi (they came from + // there); the browser sends their session cookie automatically. + const href = civiBaseUrl + ? `${civiBaseUrl}/civicrm/file?reset=1&id=${encodeURIComponent(id)}` + : "#"; + return ( + + {name} + + ); + } + return ( + + ); +} + function fieldConfigFor(f: StaffReportField): FieldConfig { return { name: f.descriptor.name, @@ -201,7 +377,6 @@ function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldCo case "currency": return "currency"; case "date": - return "date"; case "datetime": return "date"; case "select": @@ -221,101 +396,107 @@ function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldCo } } -function FileFieldRow({ field, authKey }: { field: StaffReportField; authKey: string }) { - return ( -
    -

    {field.descriptor.label}

    -
      - {field.history.map((entry) => ( - - ))} -
    -
    - ); +/** + * Stage 5 Y1 matrix: detect fields whose names match Y1_Q_ and + * group them into a read-only table mirroring the form's matrix layout. + * The metric set is whatever's actually present in the data so a Civi + * schema addition (e.g. Y1_Q*_Labor_Hours) appears automatically. + */ +interface Y1MatrixRow { + metric: string; // e.g. "Labor" | "Margin" | "Member_Sales_" + label: string; // human label from the first field's descriptor (sans Y1_Q_ prefix) + byQuarter: Map; +} +interface Y1MatrixData { + rows: Y1MatrixRow[]; + quarters: number[]; + usedNames: Set; +} +function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null { + const re = /^Y1_Q(\d+)_(.+)$/; + const used = new Set(); + const byMetric = new Map>(); + const quartersSet = new Set(); + const metricLabel = new Map(); + + for (const f of filled) { + const m = re.exec(f.descriptor.name); + if (!m) continue; + const quarter = Number(m[1]); + const metric = m[2]; + used.add(f.descriptor.name); + quartersSet.add(quarter); + if (!byMetric.has(metric)) byMetric.set(metric, new Map()); + byMetric.get(metric)!.set(quarter, f); + if (!metricLabel.has(metric)) { + // Strip "Y1 Q " prefix variants from the label if present. + const cleaned = f.descriptor.label + .replace(/^Y1\s*Q\d+\s*/i, "") + .replace(/_/g, " ") + .trim(); + metricLabel.set(metric, cleaned || metric.replace(/_/g, " ")); + } + } + + if (byMetric.size === 0) return null; + const quarters = Array.from(quartersSet).sort((a, b) => a - b); + const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byQuarter]) => ({ + metric, + label: metricLabel.get(metric) ?? metric, + byQuarter, + })); + return { rows, quarters, usedNames: used }; } -function FilePreviewItem({ - entry, - authKey, +function Y1MatrixTable({ + rows, + quarters, + options, }: { - entry: FieldHistoryEntry; - authKey: string; + rows: Y1MatrixRow[]; + quarters: number[]; + options: Record; }) { - 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 here would just show a broken thumbnail. - const isImage = ["png", "jpg", "jpeg", "gif", "webp"].includes(ext); - const isPdf = ext === "pdf"; - return ( -
  • - {isImage ? ( - - {/* eslint-disable-next-line @next/next/no-img-element */} - {name} - - ) : null} -
    - - {name} - - {entry.date ? ( -

    submitted {formatLongDate(entry.date)}

    - ) : null} - {isPdf ? : null} -
    -
  • - ); -} - -function PdfPreviewButton({ href, name }: { href: string; name: string }) { - const [open, setOpen] = useState(false); - return ( - <> - - {open ? ( -
    setOpen(false)} - > -
    e.stopPropagation()} - > - -