From a736843cd23064c412af4ffbac7e8ef857922985 Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Wed, 19 Aug 2026 18:37:26 +0000 Subject: [PATCH] FN-017: add authenticated image artifact viewer Protect dashboard image artifacts while providing a dedicated preview experience. - Fetch image media with header authentication and render revocable blob URLs. - Add desktop and mobile viewer behavior across gallery, documents, task, and mailbox surfaces. - Cover viewer, blob-loading, and updated artifact-surface behavior with tests and documentation. Files changed: docs/dashboard-guide.md | 1 + .../app/components/ArtifactImageViewer.css | 9 ++ .../app/components/ArtifactImageViewer.tsx | 100 ++++++++++++++++++ .../dashboard/app/components/ArtifactMedia.tsx | 8 +- .../dashboard/app/components/ArtifactsGallery.tsx | 10 +- .../dashboard/app/components/DocumentsView.tsx | 4 +- .../app/components/MailboxArtifactAttachment.tsx | 29 +++--- .../dashboard/app/components/TaskDocumentsTab.tsx | 114 ++------------------- .../__tests__/ArtifactImageViewer.test.tsx | 48 +++++++++ .../__tests__/ArtifactsGallery.swipe-back.test.tsx | 7 +- .../components/__tests__/DocumentsView.test.tsx | 9 +- .../__tests__/MailboxArtifactAttachment.test.tsx | 34 ++++-- .../components/__tests__/TaskDocumentsTab.test.tsx | 22 ++-- .../hooks/__tests__/useArtifactImageBlob.test.tsx | 92 +++++++++++++++++ .../dashboard/app/hooks/useArtifactImageBlob.ts | 59 +++++++++++ 15 files changed, 387 insertions(+), 159 deletions(-) Fusion-Task-Id: FN-017 Fusion-Task-Lineage: 893c5a50-536a-4f54-a9a4-cba33ea111b5 Co-authored-by: Fusion --- docs/dashboard-guide.md | 1 + .../app/components/ArtifactImageViewer.css | 9 ++ .../app/components/ArtifactImageViewer.tsx | 100 +++++++++++++++ .../app/components/ArtifactMedia.tsx | 8 +- .../app/components/ArtifactsGallery.tsx | 10 +- .../app/components/DocumentsView.tsx | 4 +- .../components/MailboxArtifactAttachment.tsx | 29 ++--- .../app/components/TaskDocumentsTab.tsx | 114 ++---------------- .../__tests__/ArtifactImageViewer.test.tsx | 48 ++++++++ .../ArtifactsGallery.swipe-back.test.tsx | 7 +- .../__tests__/DocumentsView.test.tsx | 9 +- .../MailboxArtifactAttachment.test.tsx | 34 ++++-- .../__tests__/TaskDocumentsTab.test.tsx | 22 ++-- .../__tests__/useArtifactImageBlob.test.tsx | 92 ++++++++++++++ .../app/hooks/useArtifactImageBlob.ts | 59 +++++++++ 15 files changed, 387 insertions(+), 159 deletions(-) create mode 100644 packages/dashboard/app/components/ArtifactImageViewer.css create mode 100644 packages/dashboard/app/components/ArtifactImageViewer.tsx create mode 100644 packages/dashboard/app/components/__tests__/ArtifactImageViewer.test.tsx create mode 100644 packages/dashboard/app/hooks/__tests__/useArtifactImageBlob.test.tsx create mode 100644 packages/dashboard/app/hooks/useArtifactImageBlob.ts diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index bc1e6bbd2d..8dbd0f3c46 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1129,6 +1129,7 @@ Features: - Already-open global and task-detail artifact lists refresh live from the artifact registry event when an agent, dashboard chat session, user action, or system tool registers a new artifact, while preserving active search filters and task scoping - Use the tab-count badges to see the current counts for Project Files, Task Documents, and Artifacts; the Artifacts badge reflects the loaded `GET /api/artifacts` result set, including active search filters - Browse the category-driven gallery: artifacts are broken down into **Images**, **Docs**, **PDFs**, **Videos**, **Audio**, and **Other** content categories (PDFs are detected by MIME type/extension regardless of registry type). "All" renders one section per present category; the chip row filters to a single category, and chips only appear for categories that exist +- Open image artifacts in the dedicated authenticated dashboard viewer. It fetches media with the Authorization header and displays a temporary blob URL, so image links and browser navigation never expose a tokenized raw-media URL. The viewer remains a desktop floating window and uses the established mobile sheet and Back behavior. - Each category has a tailored experience: Images/Videos use a visual-first tile grid with hover metadata and a full-size lightbox; Docs open a full document viewer with rendered markdown; PDFs open an embedded viewer with an open-in-new-tab action; Audio renders inline player rows; Other renders compact download rows - Video artifacts (agent-registered recordings, `path`-ingested MP4/WebM/MOV, and bridged video attachments) play with working seek because the media route serves HTTP byte ranges - HTML doc artifacts (`mimeType: text/html`) render as **live sandboxed previews** by default in the document viewer (scripts allowed, same-origin denied), with a Preview/Source toggle and the same Edit mode as other docs diff --git a/packages/dashboard/app/components/ArtifactImageViewer.css b/packages/dashboard/app/components/ArtifactImageViewer.css new file mode 100644 index 0000000000..f7611c9524 --- /dev/null +++ b/packages/dashboard/app/components/ArtifactImageViewer.css @@ -0,0 +1,9 @@ +/* FNXC:ArtifactImageSecurity 2026-08-19-18:08: The dedicated image viewer fills its shared FloatingWindow while preserving the existing mobile sheet behavior. */ +.artifact-image-viewer { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; } +.artifact-image-viewer__header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); padding: var(--space-md); border-bottom: var(--border-width) solid var(--border); } +.artifact-image-viewer__title { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.artifact-image-viewer__content { display: flex; flex: 1; min-height: 0; align-items: center; justify-content: center; padding: var(--space-md); overflow: auto; } +.artifact-image-viewer__image { display: block; max-width: 100%; max-height: 100%; object-fit: contain; } +.artifact-image-viewer__failure { display: flex; flex-direction: column; align-items: center; gap: var(--space-sm); } +.artifact-image-viewer__error { margin: 0; color: var(--color-error); } +@media (max-width: 768px) { .artifact-image-viewer__content { padding: var(--space-sm); } } diff --git a/packages/dashboard/app/components/ArtifactImageViewer.tsx b/packages/dashboard/app/components/ArtifactImageViewer.tsx new file mode 100644 index 0000000000..9a0381aa17 --- /dev/null +++ b/packages/dashboard/app/components/ArtifactImageViewer.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef } from "react"; +import { X } from "lucide-react"; +import { FloatingWindow } from "./FloatingWindow"; +import { useArtifactImageBlob } from "../hooks/useArtifactImageBlob"; +import "./ArtifactImageViewer.css"; + +export interface ArtifactImageProps { + artifactId: string; + projectId?: string; + title: string; + className?: string; + loading?: "lazy" | "eager"; + onError?: () => void; +} + +/** A safe inline thumbnail: its image source is always a revocable blob URL. */ +export function ArtifactImage({ artifactId, projectId, title, className, loading = "lazy", onError }: ArtifactImageProps) { + const { url, error } = useArtifactImageBlob(artifactId, projectId); + useEffect(() => { if (error) onError?.(); }, [error, onError]); + return url ? {title} : null; +} + +export interface ArtifactImageViewerProps { + artifactId: string; + title: string; + projectId?: string; + taskId?: string; + onOpenTask?: (taskId: string) => void; + onClose: () => void; +} + +/** + * FNXC:ArtifactImageSecurity 2026-08-19-18:08: + * One dashboard-owned viewer is the only image destination across artifact surfaces. It renders a + * revocable blob URL rather than a raw media link, preserving previews without exposing daemon + * credentials in copied URLs, browser history, or image attributes. + */ +export function ArtifactImageViewer({ artifactId, title, projectId, taskId, onOpenTask, onClose }: ArtifactImageViewerProps) { + const closeRef = useRef(null); + const returnFocusRef = useRef(document.activeElement instanceof HTMLElement ? document.activeElement : null); + const onCloseRef = useRef(onClose); + const { url, loading, error, reload } = useArtifactImageBlob(artifactId, projectId); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + closeRef.current?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCloseRef.current(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + returnFocusRef.current?.focus(); + }; + }, []); + + return ( + +
+
+

{title}

+ {taskId && onOpenTask && } + +
+
+ {loading &&

Loading image artifact…

} + {error && ( +
+

{error}

+ +
+ )} + {url && {title}} +
+
+
+ ); +} diff --git a/packages/dashboard/app/components/ArtifactMedia.tsx b/packages/dashboard/app/components/ArtifactMedia.tsx index 6636d75a95..594a21e56f 100644 --- a/packages/dashboard/app/components/ArtifactMedia.tsx +++ b/packages/dashboard/app/components/ArtifactMedia.tsx @@ -1,6 +1,7 @@ import { FileText, Package } from "lucide-react"; import type { TFunction } from "i18next"; import type { ArtifactType, ArtifactWithTask } from "@fusion/core"; +import { ArtifactImage } from "./ArtifactImageViewer"; export function getArtifactTypeLabel(t: TFunction<"app">, type: ArtifactType): string { switch (type) { @@ -18,8 +19,9 @@ export function getArtifactTypeLabel(t: TFunction<"app">, type: ArtifactType): s } interface ArtifactMediaProps { - artifact: Pick; + artifact: Pick; mediaUrl: string; + projectId?: string; title: string; preview?: string; t: TFunction<"app">; @@ -29,10 +31,10 @@ interface ArtifactMediaProps { * FNXC:ArtifactRegistry 2026-06-21-21:31: * The global Documents gallery and the per-task Artifacts tab must share one media renderer so image, video, audio, document, and generic artifact previews cannot drift across dashboard surfaces. */ -export function ArtifactMedia({ artifact, mediaUrl, title, preview, t }: ArtifactMediaProps) { +export function ArtifactMedia({ artifact, mediaUrl, projectId, title, preview, t }: ArtifactMediaProps) { switch (artifact.type) { case "image": - return {title}; + return ; case "video": return