{!isHome && }
- }>
-
-
+
+ }>
+
+
+
diff --git a/apps/admin/src/components/shell/error-boundary.test.tsx b/apps/admin/src/components/shell/error-boundary.test.tsx
new file mode 100644
index 0000000..b54ca3d
--- /dev/null
+++ b/apps/admin/src/components/shell/error-boundary.test.tsx
@@ -0,0 +1,144 @@
+import { render, screen, within } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import { AppError, ErrorCode } from "@civfix/shared"
+
+import { ErrorBoundary, isChunkLoadError } from "@/components/shell/error-boundary"
+
+const RENDER_FAILED_COPY = "This page hit an unexpected error. Try again, or reload the dashboard."
+
+function chunkLoadError(): Error {
+ const err = new Error("Loading chunk 812 failed.\n(error: https://admin.civfix.org/_next/static/chunks/812.js)")
+ err.name = "ChunkLoadError"
+ return err
+}
+
+let shouldThrow: unknown = null
+
+function Flaky() {
+ if (shouldThrow !== null) throw shouldThrow
+ return
Page content
+}
+
+beforeEach(() => {
+ // React reports every error a boundary catches to console.error; keep the run output readable.
+ vi.spyOn(console, "error").mockImplementation(() => undefined)
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ shouldThrow = null
+})
+
+describe("isChunkLoadError", () => {
+ it("recognizes webpack's ChunkLoadError by name", () => {
+ expect(isChunkLoadError(chunkLoadError())).toBe(true)
+ })
+
+ it.each([
+ "Loading CSS chunk 44 failed.",
+ "Failed to fetch dynamically imported module: https://admin.civfix.org/_next/static/chunks/a.js",
+ "Importing a module script failed.",
+ "error loading dynamically imported module",
+ ])("recognizes the browser message %j", (message) => {
+ expect(isChunkLoadError(new Error(message))).toBe(true)
+ })
+
+ it("does not treat an ordinary render error or a non-error as a chunk failure", () => {
+ expect(isChunkLoadError(new TypeError("Cannot read properties of undefined (reading 'cls')"))).toBe(false)
+ expect(isChunkLoadError("Loading chunk 1 failed")).toBe(false)
+ expect(isChunkLoadError(null)).toBe(false)
+ })
+})
+
+describe("ErrorBoundary", () => {
+ it("renders its children when nothing throws", () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.getByText("Page content")).toBeInTheDocument()
+ expect(screen.queryByRole("alert")).toBeNull()
+ })
+
+ it("replaces a render error with an alert offering Try again and Reload", async () => {
+ const user = userEvent.setup()
+ const reload = vi.fn()
+ shouldThrow = new TypeError("Cannot read properties of undefined (reading 'cls')")
+ render(
+
+
+ ,
+ )
+
+ const alert = screen.getByRole("alert")
+ expect(alert).toHaveTextContent("Something went wrong")
+ expect(alert).toHaveTextContent(RENDER_FAILED_COPY)
+ expect(alert).not.toHaveTextContent("Cannot read properties")
+ expect(within(alert).getByRole("button", { name: "Try again" })).toBeInTheDocument()
+
+ await user.click(within(alert).getByRole("button", { name: "Reload" }))
+ expect(reload).toHaveBeenCalledTimes(1)
+ })
+
+ it("shows an API error's own message", () => {
+ shouldThrow = new AppError(ErrorCode.FORBIDDEN, "Operators only.")
+ render(
+
+
+ ,
+ )
+ const alert = screen.getByRole("alert")
+ expect(alert).toHaveTextContent("Operators only.")
+ expect(alert).not.toHaveTextContent(RENDER_FAILED_COPY)
+ })
+
+ it("remounts its children on Try again", async () => {
+ const user = userEvent.setup()
+ shouldThrow = new Error("transient")
+ render(
+
+
+ ,
+ )
+ expect(screen.getByRole("alert")).toBeInTheDocument()
+
+ shouldThrow = null
+ await user.click(screen.getByRole("button", { name: "Try again" }))
+ expect(screen.queryByRole("alert")).toBeNull()
+ expect(screen.getByText("Page content")).toBeInTheDocument()
+ })
+
+ it("offers only Reload for a chunk that failed to download", async () => {
+ const user = userEvent.setup()
+ const reload = vi.fn()
+ shouldThrow = chunkLoadError()
+ render(
+
+
+ ,
+ )
+
+ const alert = screen.getByRole("alert")
+ expect(alert).toHaveTextContent("This page could not load")
+ expect(alert).not.toHaveTextContent("812.js")
+ expect(within(alert).queryByRole("button", { name: "Try again" })).toBeNull()
+
+ await user.click(within(alert).getByRole("button", { name: "Reload" }))
+ expect(reload).toHaveBeenCalledTimes(1)
+ })
+
+ it("catches a thrown non-error value", () => {
+ shouldThrow = "boom"
+ render(
+
+
+ ,
+ )
+ const alert = screen.getByRole("alert")
+ expect(alert).toHaveTextContent("Something went wrong")
+ expect(alert).toHaveTextContent(RENDER_FAILED_COPY)
+ })
+})
diff --git a/apps/admin/src/components/shell/error-boundary.tsx b/apps/admin/src/components/shell/error-boundary.tsx
new file mode 100644
index 0000000..66d09ca
--- /dev/null
+++ b/apps/admin/src/components/shell/error-boundary.tsx
@@ -0,0 +1,79 @@
+"use client"
+
+import * as React from "react"
+
+import { ErrorState } from "@/components/shared/data-states"
+import { isAppError } from "@/lib/api"
+import { errorMessage } from "@/lib/error-messages"
+
+const CHUNK_LOAD_MESSAGE =
+ /Loading (CSS )?chunk [\w-]+ failed|Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i
+
+export function isChunkLoadError(error: unknown): boolean {
+ if (!(error instanceof Error)) return false
+ return error.name === "ChunkLoadError" || CHUNK_LOAD_MESSAGE.test(error.message)
+}
+
+const RENDER_FAILED = "This page hit an unexpected error. Try again, or reload the dashboard."
+
+function reloadPage(): void {
+ window.location.reload()
+}
+
+interface ErrorBoundaryProps {
+ children: React.ReactNode
+ reload?: () => void
+}
+
+type ErrorBoundaryState = { failed: false } | { failed: true; error: unknown }
+
+// No componentDidCatch: Next's app-router onCaughtError already console.errors every error an explicit
+// boundary catches, so logging here would report each one twice.
+export class ErrorBoundary extends React.Component
{
+ override state: ErrorBoundaryState = { failed: false }
+
+ static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
+ return { failed: true, error }
+ }
+
+ private retry = () => this.setState({ failed: false })
+
+ override render() {
+ if (!this.state.failed) return this.props.children
+ return (
+
+ )
+ }
+}
+
+function BoundaryFallback({
+ error,
+ onRetry,
+ onReload,
+}: {
+ error: unknown
+ onRetry: () => void
+ onReload: () => void
+}) {
+ // React.lazy caches a rejected import, so remounting re-throws the same chunk failure; only a full
+ // reload fetches the new build's chunk manifest.
+ const chunkFailed = isChunkLoadError(error)
+ // A developer exception's text (a TypeError naming a property) means nothing to an operator; only an
+ // AppError carries copy written for them.
+ const message = chunkFailed
+ ? "The dashboard may have been updated. Reload to get the latest version."
+ : errorMessage(error, {}, { fallback: RENDER_FAILED, preferServerMessage: isAppError(error) })
+ return (
+
+ )
+}
diff --git a/apps/admin/src/features/events/event-status-fallback.test.tsx b/apps/admin/src/features/events/event-status-fallback.test.tsx
new file mode 100644
index 0000000..eaada3f
--- /dev/null
+++ b/apps/admin/src/features/events/event-status-fallback.test.tsx
@@ -0,0 +1,88 @@
+import { screen, within } from "@testing-library/react"
+import type {
+ AdminEventDTO,
+ AdminEventListItemDTO,
+ AdminEventListResponse,
+ AdminOrgDTO,
+ AdminOrgEventListResponse,
+ EventStatus,
+} from "@civfix/shared"
+import { describe, expect, it, vi } from "vitest"
+
+import type * as ApiModule from "@/lib/api"
+import { apiMock } from "@/test/api-mock"
+import { renderWithQuery } from "@/test/render"
+import { detailCard, listCard, queueRowOf } from "@/test/panes"
+import { EventsPage } from "@/features/events/events-page"
+import { OrgEventsPanel } from "@/features/orgs/org-events-panel"
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const { apiMock } = await import("@/test/api-mock")
+ return { ...(await importOriginal()), api: apiMock }
+})
+
+vi.mock("@/components/map/leaflet-map", () => ({
+ LeafletMap: () =>
,
+}))
+
+// The client passes a response through unparsed when it fails the schema, so a status added to the
+// contract after this build still reaches the page.
+const POSTPONED = {
+ id: "aaaa1111-0000-4000-8000-000000000001",
+ title: "Beach sweep",
+ status: "postponed" as EventStatus,
+ eventKind: "cleanup",
+ flagged: false,
+ place: "Ocean Beach",
+ attendees: 4,
+ capacity: null,
+ bags: 0,
+ organizer: { id: "u-org", name: "Olive Park", handle: "@olive", joined: "Mar 2026" },
+ date: { rel: "in 3 days", abs: "Sep 26, 9:00 AM" },
+ coords: [37.8, -122.26],
+} satisfies AdminEventListItemDTO
+
+describe("EventsPage with an event status this build does not know", () => {
+ it("renders the raw status in a neutral pill in the list and the detail pane", async () => {
+ apiMock.listAdminEvents.mockResolvedValue({
+ items: [POSTPONED],
+ nextCursor: null,
+ counts: { all: 1, upcoming: 0, in_progress: 0, completed: 0, flagged: 0 },
+ } satisfies AdminEventListResponse)
+ apiMock.getAdminEvent.mockResolvedValue({
+ ...POSTPONED,
+ desc: "About the sweep",
+ address: "Ocean Beach main entrance",
+ timeline: [],
+ messages: [],
+ linkedReports: [],
+ } satisfies AdminEventDTO)
+
+ renderWithQuery( )
+
+ const listPill = await within(listCard()).findByText("postponed")
+ expect(listPill).toHaveClass("pill", "priority-low")
+ expect(within(listCard()).queryByText("Upcoming")).toBeNull()
+
+ const card = detailCard()
+ expect(await within(card).findByRole("heading", { name: "Beach sweep" })).toBeInTheDocument()
+ expect(within(card).getByText("postponed")).toHaveClass("pill", "priority-low")
+ expect(screen.queryByText("Upcoming", { selector: ".pill" })).toBeNull()
+ })
+})
+
+describe("the org events tab with an event status this build does not know", () => {
+ it("renders the raw status in a neutral pill, not as Upcoming", async () => {
+ apiMock.adminListOrgEvents.mockResolvedValue({
+ items: [POSTPONED],
+ nextCursor: null,
+ } satisfies AdminOrgEventListResponse)
+ const org = { id: "org-1", eventCount: 1 } as AdminOrgDTO
+
+ renderWithQuery( )
+
+ const row = queueRowOf(await screen.findByText("Beach sweep"))
+ expect(within(row).getByText("postponed")).toHaveClass("pill", "priority-low")
+ expect(within(row).queryByText("Upcoming")).toBeNull()
+ })
+})
diff --git a/apps/admin/src/features/events/events-page.tsx b/apps/admin/src/features/events/events-page.tsx
index 09b6485..ee9b955 100644
--- a/apps/admin/src/features/events/events-page.tsx
+++ b/apps/admin/src/features/events/events-page.tsx
@@ -14,8 +14,10 @@ import { Icons, type IconComponent } from "@/components/icons"
import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives"
import { LoadingState, ErrorState } from "@/components/shared/data-states"
import { confirmDialog } from "@/components/shared/dialog"
+import { usePristineDismiss } from "@/components/shared/backdrop-dismiss"
+import { useModalFocus } from "@/components/shared/modal-focus"
import { useDebounced } from "@/hooks/use-debounced"
-import { cancelBlockedFor, EVENT_STATUS_VIEW } from "@/lib/event-status"
+import { cancelBlockedFor, EVENT_STATUS_VIEW, eventStatusView } from "@/lib/event-status"
import { eventKindView, EVENT_KIND_PIN_KIND } from "@/lib/event-kind"
import { categoryPinSrc } from "@/lib/category"
import { reportStatusView } from "@/lib/report-status"
@@ -39,8 +41,6 @@ const LeafletMap = dynamic(() => import("@/components/map/leaflet-map").then((m)
loading: () =>
,
})
-const STATUS_VIEW = EVENT_STATUS_VIEW
-
const TL_ICON: Record = {
create: Icons.Pin,
status: Icons.Clock,
@@ -133,6 +133,18 @@ function LinkReportsPicker({
}) {
const [query, setQuery] = React.useState("")
const [picked, setPicked] = React.useState>(() => new Set())
+ const modalRef = useModalFocus(true)
+ const searchRef = React.useRef(null)
+ const titleId = React.useId()
+
+ // Focused here rather than with autoFocus: autoFocus lands before useModalFocus records the element
+ // to restore, so closing would return focus to the dead field instead of the opener.
+ React.useEffect(() => {
+ searchRef.current?.focus()
+ }, [])
+
+ // The search text is cheap to retype, so only a selection makes the picker a draft worth keeping.
+ const backdrop = usePristineDismiss(onClose, picked.size === 0)
const listQuery = useReportList({ q: query.trim() || undefined })
const candidates = React.useMemo(
@@ -152,11 +164,17 @@ function LinkReportsPicker({
const canLink = picked.size > 0 && !pending
return (
-
-
e.stopPropagation()}>
+
+
-
Link reports
-
+ Link reports
+
@@ -164,11 +182,11 @@ function LinkReportsPicker({
setQuery(e.target.value)}
- autoFocus
/>
{listQuery.isLoading ? (
@@ -244,6 +262,7 @@ function EventRow({
}) {
const kindView = eventKindView(item.eventKind)
const KindIco = kindView.icon
+ const statusPill = eventStatusView(item.status)
const nav = useNav()
return (
@@ -290,9 +309,7 @@ function EventRow({
-
- {STATUS_VIEW[item.status].label}
-
+ {statusPill.label}
{item.date.abs}
@@ -373,6 +390,7 @@ function EventDetail({ eventId, onCancelled }: { eventId: string; onCancelled: (
}
const cancelBlockedReason = cancelBlockedFor(event.status)
+ const statusPill = eventStatusView(event.status)
const onCancel = async () => {
const ok = await confirmDialog({
@@ -444,10 +462,10 @@ function EventDetail({ eventId, onCancelled }: { eventId: string; onCancelled: (
)}
- {STATUS_VIEW[event.status].label}
+ {statusPill.label}
@@ -821,10 +839,10 @@ export function EventsPage({ focusId }: SectionPageProps) {
{ value: "upcoming", label: "Upcoming", count: counts.upcoming },
{
value: "in_progress",
- label: STATUS_VIEW.in_progress.label,
+ label: EVENT_STATUS_VIEW.in_progress.label,
count: counts.in_progress,
},
- { value: "completed", label: STATUS_VIEW.completed.label, count: counts.completed },
+ { value: "completed", label: EVENT_STATUS_VIEW.completed.label, count: counts.completed },
{ value: "flagged", label: "Flagged", count: counts.flagged },
]}
value={filter}
diff --git a/apps/admin/src/features/events/link-reports-modal.test.tsx b/apps/admin/src/features/events/link-reports-modal.test.tsx
new file mode 100644
index 0000000..6dbe872
--- /dev/null
+++ b/apps/admin/src/features/events/link-reports-modal.test.tsx
@@ -0,0 +1,192 @@
+import { screen, within } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import type {
+ AdminEventDTO,
+ AdminEventListItemDTO,
+ AdminEventListResponse,
+ AdminReportListItemDTO,
+ AdminReportListResponse,
+} from "@civfix/shared"
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
+
+import type * as ApiModule from "@/lib/api"
+import { AppShell } from "@/components/shell/app-shell"
+import { apiMock } from "@/test/api-mock"
+import { renderWithQuery } from "@/test/render"
+import { dragOutToBackdrop, overlayOf } from "@/test/modal"
+import { waitForSectionPage } from "@/test/shell"
+import { useUiStore } from "@/store/ui-store"
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const { apiMock } = await import("@/test/api-mock")
+ return { ...(await importOriginal
()), api: apiMock }
+})
+
+vi.mock("@/components/map/leaflet-map", () => ({
+ LeafletMap: () =>
,
+}))
+
+const EVENT = {
+ id: "aaaa1111-0000-4000-8000-000000000001",
+ title: "Beach sweep",
+ status: "upcoming",
+ eventKind: "cleanup",
+ flagged: false,
+ place: "Ocean Beach",
+ attendees: 4,
+ capacity: null,
+ bags: 0,
+ organizer: { id: "u-org", name: "Olive Park", handle: "@olive", joined: "Mar 2026" },
+ date: { rel: "in 3 days", abs: "Sep 26, 9:00 AM" },
+ coords: [37.8, -122.26],
+} satisfies AdminEventListItemDTO
+
+const REPORT = {
+ id: "r-1",
+ title: "Trash pile by the pier",
+ category: "trash",
+ status: "published",
+ flagged: false,
+ place: "Ocean Beach",
+ reporter: { id: "u-rita", name: "Rita Gomez", handle: "@rita", joined: "Feb 2026" },
+ confirmations: 0,
+ submitted: { rel: "3h", abs: "Sep 23, 2026, 9:00 AM" },
+ coords: [37.8, -122.26],
+ address: "Great Hwy",
+ hasPhoto: false,
+ thumbnailUrl: null,
+} satisfies AdminReportListItemDTO
+
+const TITLE = "Link reports"
+
+// Rendered inside the real shell so its global "Escape goes home" handler is live.
+async function openPicker() {
+ apiMock.listAdminEvents.mockResolvedValue({
+ items: [EVENT],
+ nextCursor: null,
+ counts: { all: 1, upcoming: 1, in_progress: 0, completed: 0, flagged: 0 },
+ } satisfies AdminEventListResponse)
+ apiMock.getAdminEvent.mockResolvedValue({
+ ...EVENT,
+ desc: "About the sweep",
+ address: "Ocean Beach main entrance",
+ timeline: [],
+ messages: [],
+ linkedReports: [],
+ } satisfies AdminEventDTO)
+ apiMock.listAdminReports.mockResolvedValue({
+ items: [REPORT],
+ nextCursor: null,
+ } as AdminReportListResponse)
+ const user = userEvent.setup()
+ renderWithQuery( )
+ await waitForSectionPage()
+ // Wait on cheap text, then query the role once: a role query walks the whole shell on every DOM
+ // mutation, and on a loaded machine those walks starve the renders it is waiting for.
+ await screen.findByText("About the sweep")
+ const opener = screen.getByRole("button", { name: "Link reports" })
+ await user.click(opener)
+ const dialog = screen.getByRole("dialog", { name: TITLE })
+ await within(dialog).findByText(REPORT.title)
+ return { user, opener, dialog }
+}
+
+function reportRow(dialog: HTMLElement): HTMLElement {
+ return within(dialog).getByRole("button", { name: new RegExp(REPORT.title) })
+}
+
+// The section page is a React.lazy chunk: the first render in this worker would otherwise pay the cold
+// module transform inside the first wait's 1000 ms budget, which a loaded machine overruns.
+beforeAll(async () => {
+ await import("@/features/events/events-page")
+})
+
+beforeEach(() => {
+ window.history.replaceState(null, "", "#/events")
+ useUiStore.setState({ page: "events", focusId: null })
+})
+
+describe("Events link-reports modal", () => {
+ it("is a labelled modal dialog with a named close button", async () => {
+ const { dialog } = await openPicker()
+ expect(dialog).toHaveAttribute("aria-modal", "true")
+ expect(within(dialog).getByRole("button", { name: "Close" })).toBeInTheDocument()
+ })
+
+ it("starts in the search field and keeps Tab inside the dialog", async () => {
+ const { user, dialog } = await openPicker()
+ expect(within(dialog).getByPlaceholderText("Search title, place, reporter…")).toHaveFocus()
+
+ for (let i = 0; i < 8; i++) {
+ await user.tab()
+ expect(dialog).toContainElement(document.activeElement as HTMLElement)
+ }
+ for (let i = 0; i < 8; i++) {
+ await user.tab({ shift: true })
+ expect(dialog).toContainElement(document.activeElement as HTMLElement)
+ }
+ })
+
+ it("keeps a selection on Escape from a button and does not send the shell home", async () => {
+ const { user, dialog } = await openPicker()
+ await user.click(reportRow(dialog))
+ expect(within(dialog).getByText("1 selected")).toBeInTheDocument()
+
+ await user.keyboard("{Escape}")
+
+ expect(screen.getByRole("dialog", { name: TITLE })).toBeInTheDocument()
+ expect(screen.getByText("1 selected")).toBeInTheDocument()
+ expect(window.location.hash).toBe("#/events")
+ expect(useUiStore.getState().page).toBe("events")
+ })
+
+ it("keeps a selection on a backdrop click", async () => {
+ const { user, dialog } = await openPicker()
+ await user.click(reportRow(dialog))
+
+ await user.click(overlayOf(dialog))
+
+ expect(screen.getByRole("dialog", { name: TITLE })).toBeInTheDocument()
+ expect(screen.getByText("1 selected")).toBeInTheDocument()
+ })
+
+ it("closes with nothing selected on Escape without leaving Events, and restores focus", async () => {
+ const { user, opener, dialog } = await openPicker()
+ within(dialog).getByRole("button", { name: "Cancel" }).focus()
+
+ await user.keyboard("{Escape}")
+
+ expect(screen.queryByRole("dialog")).toBeNull()
+ expect(window.location.hash).toBe("#/events")
+ expect(useUiStore.getState().page).toBe("events")
+ expect(opener).toHaveFocus()
+ })
+
+ it("closes with nothing selected on a backdrop click", async () => {
+ const { user, dialog } = await openPicker()
+ await user.click(overlayOf(dialog))
+ expect(screen.queryByRole("dialog")).toBeNull()
+ })
+
+ it("stays open with its search, nothing selected, when a press in it is released over the backdrop", async () => {
+ const { user, dialog } = await openPicker()
+ const search = within(dialog).getByPlaceholderText("Search title, place, reporter…")
+ await user.type(search, "pier")
+
+ dragOutToBackdrop(search, overlayOf(dialog))
+
+ expect(screen.getByRole("dialog", { name: TITLE })).toBeInTheDocument()
+ expect(screen.getByPlaceholderText("Search title, place, reporter…")).toHaveValue("pier")
+ expect(screen.getByText("Select reports to link")).toBeInTheDocument()
+ })
+
+ it("drops a selection only through the close button, and returns focus to the opener", async () => {
+ const { user, opener, dialog } = await openPicker()
+ await user.click(reportRow(dialog))
+
+ await user.click(within(dialog).getByRole("button", { name: "Close" }))
+
+ expect(screen.queryByRole("dialog")).toBeNull()
+ expect(opener).toHaveFocus()
+ })
+})
diff --git a/apps/admin/src/features/inbox/attachment-chip.tsx b/apps/admin/src/features/inbox/attachment-chip.tsx
new file mode 100644
index 0000000..19c95c1
--- /dev/null
+++ b/apps/admin/src/features/inbox/attachment-chip.tsx
@@ -0,0 +1,46 @@
+"use client"
+
+import type { MailAttachment } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { isWebUrl } from "@/lib/external-url"
+
+function sizeLabel(bytes: number): string {
+ return `${(bytes / 1024).toFixed(0)}k`
+}
+
+// An attachment key comes from mail the platform received, so only an http(s) url becomes a link;
+// any other scheme (javascript:, data:) or a bare storage key renders inert.
+export function AttachmentChip({ attachment }: { attachment: MailAttachment }) {
+ if (!isWebUrl(attachment.key)) {
+ return (
+
+ {attachment.filename}
+ {sizeLabel(attachment.size)}
+ link unavailable
+
+ )
+ }
+ return (
+
+ {attachment.filename}
+ {sizeLabel(attachment.size)}
+
+ )
+}
+
+export function AttachmentList({ attachments }: { attachments: MailAttachment[] }) {
+ if (attachments.length === 0) return null
+ return (
+
+ {attachments.map((att) => (
+
+ ))}
+
+ )
+}
diff --git a/apps/admin/src/features/inbox/inbox-attachments.test.tsx b/apps/admin/src/features/inbox/inbox-attachments.test.tsx
new file mode 100644
index 0000000..4837e19
--- /dev/null
+++ b/apps/admin/src/features/inbox/inbox-attachments.test.tsx
@@ -0,0 +1,60 @@
+import { screen } from "@testing-library/react"
+import type { InboundEmailDTO } from "@civfix/shared"
+import { describe, expect, it, vi } from "vitest"
+
+import type * as ApiModule from "@/lib/api"
+import { apiMock } from "@/test/api-mock"
+import { renderWithQuery } from "@/test/render"
+import { InboxReader } from "@/features/inbox/inbox-views"
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const { apiMock } = await import("@/test/api-mock")
+ return { ...(await importOriginal()), api: apiMock }
+})
+
+const MESSAGE = {
+ id: "i-1",
+ from: "resident@example.com",
+ recipient: "hello@civfix.org",
+ localPart: "hello",
+ subject: "Photos of the dumping",
+ preview: "See attached",
+ ts: "2026-09-20T15:00:00.000Z",
+ status: "read",
+ unread: false,
+ hasAttachments: true,
+ bodyText: "See attached",
+ bodyHtml: null,
+ messageId: null,
+ attachments: [
+ { key: "https://media.civfix.org/att/1?sig=abc", filename: "dumping.jpg", size: 20480 },
+ { key: "http://localhost:8080/_local-storage/att/2", filename: "map.pdf", size: 4096 },
+ { key: "javascript:alert(1)", filename: "invoice.html", size: 1024 },
+ { key: "inbound/2026/att-4.bin", filename: "raw.bin", size: 2048 },
+ ],
+} satisfies InboundEmailDTO
+
+describe("InboxReader attachments", () => {
+ it("links only http(s) attachment urls, in a new tab without opener or referrer", async () => {
+ apiMock.getInboxMessage.mockResolvedValue(MESSAGE)
+ renderWithQuery( )
+
+ const photo = await screen.findByRole("link", { name: /dumping\.jpg/ })
+ expect(photo).toHaveAttribute("href", "https://media.civfix.org/att/1?sig=abc")
+ expect(photo).toHaveAttribute("target", "_blank")
+ expect(photo).toHaveAttribute("rel", "noopener noreferrer")
+ expect(screen.getByRole("link", { name: /map\.pdf/ })).toHaveAttribute(
+ "href",
+ "http://localhost:8080/_local-storage/att/2",
+ )
+
+ expect(screen.getAllByRole("link")).toHaveLength(2)
+ expect(screen.getByText("invoice.html")).toBeInTheDocument()
+ expect(screen.getByText("raw.bin")).toBeInTheDocument()
+ expect(screen.getByText("invoice.html").closest("a")).toBeNull()
+ expect(screen.getByText("invoice.html")).toHaveTextContent("link unavailable")
+ expect(screen.getByText("raw.bin")).toHaveTextContent("link unavailable")
+ expect(screen.getAllByText("link unavailable")).toHaveLength(2)
+ expect(document.querySelector('a[href^="javascript:"]')).toBeNull()
+ })
+})
diff --git a/apps/admin/src/features/inbox/inbox-views.tsx b/apps/admin/src/features/inbox/inbox-views.tsx
index a92a518..7c04bef 100644
--- a/apps/admin/src/features/inbox/inbox-views.tsx
+++ b/apps/admin/src/features/inbox/inbox-views.tsx
@@ -12,6 +12,7 @@ import {
import { Icons } from "@/components/icons"
import { EmptyState } from "@/components/shared/page-primitives"
import { LoadingState, ErrorState } from "@/components/shared/data-states"
+import { AttachmentList } from "@/features/inbox/attachment-chip"
import { useInboxMessage, useSetInboxStatus } from "@/features/inbox/use-inbox"
import { replyOriginLabel } from "@/features/inbox/inbox-feed"
import { AuthVerdictBadge, PublicationBadge } from "@/features/mail/mail-badges"
@@ -129,24 +130,7 @@ export function InboxReader({ id }: { id: string }) {
- {sel.attachments.length > 0 && (
-