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 <noreply@runfusion.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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); } }
|
||||
100
packages/dashboard/app/components/ArtifactImageViewer.tsx
Normal file
100
packages/dashboard/app/components/ArtifactImageViewer.tsx
Normal file
@@ -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 ? <img className={className} src={url} alt={title} loading={loading} /> : 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<HTMLButtonElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(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 (
|
||||
<FloatingWindow
|
||||
windowKey={`artifact-media-${artifactId}`}
|
||||
title={null}
|
||||
modal
|
||||
onClose={onClose}
|
||||
hideHeader
|
||||
dragHandleSelector=".artifact-image-viewer__header"
|
||||
className="artifact-image-viewer-window artifacts-gallery-viewer"
|
||||
ariaLabel="Artifact media preview"
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
persistGeometryKey="fn-artifact-image-viewer-geometry"
|
||||
defaultSize={{ width: 1024, height: 720 }}
|
||||
minSize={{ width: 320, height: 280 }}
|
||||
>
|
||||
<section className="artifact-image-viewer" aria-label={`Image artifact: ${title}`}>
|
||||
<header className="artifact-image-viewer__header">
|
||||
<h3 className="artifact-image-viewer__title">{title}</h3>
|
||||
{taskId && onOpenTask && <button className="btn btn-sm" type="button" onClick={() => onOpenTask(taskId)}>Open task</button>}
|
||||
<button ref={closeRef} className="modal-close" type="button" onClick={onClose} aria-label="Close artifact preview">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="artifact-image-viewer__content" aria-live="polite">
|
||||
{loading && <p>Loading image artifact…</p>}
|
||||
{error && (
|
||||
<div className="artifact-image-viewer__failure" role="alert">
|
||||
<p className="artifact-image-viewer__error">{error}</p>
|
||||
<button className="btn btn-sm" type="button" onClick={reload}>Retry</button>
|
||||
</div>
|
||||
)}
|
||||
{url && <img className="artifact-image-viewer__image" src={url} alt={title} />}
|
||||
</div>
|
||||
</section>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
@@ -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<ArtifactWithTask, "type">;
|
||||
artifact: Pick<ArtifactWithTask, "id" | "type">;
|
||||
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 <img className="documents-artifact-media" src={mediaUrl} alt={title} loading="lazy" />;
|
||||
return <ArtifactImage className="documents-artifact-media" artifactId={artifact.id} projectId={projectId} title={title} />;
|
||||
case "video":
|
||||
return <video className="documents-artifact-media" controls src={mediaUrl} aria-label={t("documents.artifactVideoLabel", "Video artifact: {{title}}", { title })} />;
|
||||
case "audio":
|
||||
|
||||
@@ -22,6 +22,7 @@ import { artifactMediaUrl, artifactMediaUrlWithToken, fetchArtifact, updateArtif
|
||||
import { withTokenHeader } from "../auth";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
import { ArtifactImage, ArtifactImageViewer } from "./ArtifactImageViewer";
|
||||
import { NavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
|
||||
/*
|
||||
@@ -212,7 +213,10 @@ export function ArtifactsGallery({ artifacts, projectId, isMobile, addToast, onO
|
||||
);
|
||||
})}
|
||||
|
||||
{viewer && viewer.kind === "media" && (
|
||||
{viewer && viewer.kind === "media" && viewer.artifact.type === "image" && (
|
||||
<ArtifactImageViewer artifactId={viewer.artifact.id} title={viewer.artifact.title || t("documents.untitledArtifact", "Untitled artifact")} projectId={projectId} taskId={viewer.artifact.taskId} onOpenTask={onOpenTask} onClose={dismissViewer} />
|
||||
)}
|
||||
{viewer && viewer.kind === "media" && viewer.artifact.type !== "image" && (
|
||||
<MediaLightbox artifact={viewer.artifact} projectId={projectId} t={t} onClose={dismissViewer} onOpenTask={onOpenTask} />
|
||||
)}
|
||||
{viewer && viewer.kind === "pdf" && (
|
||||
@@ -289,7 +293,7 @@ interface TileProps {
|
||||
|
||||
function VisualTile({ artifact, category, projectId, t, onOpen }: TileProps) {
|
||||
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
|
||||
const mediaUrl = artifactMediaUrlWithToken(artifact.id, projectId);
|
||||
const mediaUrl = category === "image" ? "" : artifactMediaUrlWithToken(artifact.id, projectId);
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
@@ -308,7 +312,7 @@ function VisualTile({ artifact, category, projectId, t, onOpen }: TileProps) {
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{category === "image" ? (
|
||||
<img src={mediaUrl} alt={title} loading="lazy" />
|
||||
<ArtifactImage artifactId={artifact.id} projectId={projectId} title={title} />
|
||||
) : (
|
||||
<video src={mediaUrl} muted preload="metadata" aria-label={t("documents.artifactVideoLabel", "Video artifact: {{title}}", { title })} />
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import remarkGfm from "remark-gfm";
|
||||
import type { Artifact, ArtifactWithTask, ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { artifactMediaUrlWithToken, fetchArtifact, fetchTaskDetail, fetchTaskDocument, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, type MarkdownFileEntry } from "../api";
|
||||
import { ArtifactImage, ArtifactImageViewer } from "./ArtifactImageViewer";
|
||||
import { useArtifacts } from "../hooks/useArtifacts";
|
||||
import { useDocuments } from "../hooks/useDocuments";
|
||||
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
|
||||
@@ -149,6 +150,7 @@ interface TaskArtifactInlineViewerProps {
|
||||
|
||||
function TaskArtifactInlineViewer({ artifact, projectId, content, loading, error, renderMarkdown, onToggleMarkdown, onOpenTask, t }: TaskArtifactInlineViewerProps) {
|
||||
const [mediaError, setMediaError] = useState<string | null>(null);
|
||||
const [imageViewerOpen, setImageViewerOpen] = useState(false);
|
||||
const category = getArtifactCategory(artifact);
|
||||
const categoryLabel = getTaskArtifactCategoryLabel(t, category);
|
||||
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
|
||||
@@ -163,7 +165,7 @@ function TaskArtifactInlineViewer({ artifact, projectId, content, loading, error
|
||||
|
||||
const body = (() => {
|
||||
if (category === "image") {
|
||||
return <>{mediaErrorNode}<img className="documents-task-artifact-media" src={mediaUrl} alt={title} onError={() => setMediaError(t("documents.artifactMediaFailed", "Failed to load artifact preview."))} /></>;
|
||||
return <>{mediaErrorNode}<button type="button" className="documents-task-artifact-image-button" onClick={() => setImageViewerOpen(true)} aria-label={t("documents.expandImageArtifact", "Expand image artifact {{title}}", { title })}><ArtifactImage className="documents-task-artifact-media" artifactId={artifact.id} projectId={projectId} title={title} onError={() => setMediaError(t("documents.artifactMediaFailed", "Failed to load artifact preview."))} /></button>{imageViewerOpen && <ArtifactImageViewer artifactId={artifact.id} projectId={projectId} title={title} onClose={() => setImageViewerOpen(false)} />}</>;
|
||||
}
|
||||
if (category === "video") {
|
||||
return <>{mediaErrorNode}<video className="documents-task-artifact-media" controls src={mediaUrl} aria-label={t("documents.videoArtifactLabel", "Video artifact: {{title}}", { title })} onError={() => setMediaError(t("documents.artifactMediaFailed", "Failed to load artifact preview."))} /></>;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ArtifactType } from "@fusion/core";
|
||||
import { artifactMediaUrlWithToken } from "../api";
|
||||
import { ArtifactImage, ArtifactImageViewer } from "./ArtifactImageViewer";
|
||||
|
||||
export interface MailboxArtifactAttachmentProps {
|
||||
artifactId?: unknown;
|
||||
@@ -49,18 +50,17 @@ export const MailboxArtifactAttachment = memo(function MailboxArtifactAttachment
|
||||
const mediaMimeType = readString(mimeType);
|
||||
const task = readString(taskId);
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
const mediaUrl = useMemo(() => id ? artifactMediaUrlWithToken(id, projectId) : "", [id, projectId]);
|
||||
const [imageViewerOpen, setImageViewerOpen] = useState(false);
|
||||
const mediaUrl = useMemo(() => id && type !== "image" ? artifactMediaUrlWithToken(id, projectId) : "", [id, projectId, type]);
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
const openLink = (
|
||||
<a
|
||||
className="mailbox-artifact-attachment__link btn"
|
||||
href={mediaUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t("mailbox.openArtifactAria", "Open artifact: {{label}}", { label })}
|
||||
>
|
||||
const openLink = type === "image" ? (
|
||||
<button type="button" className="mailbox-artifact-attachment__link btn" onClick={() => setImageViewerOpen(true)} aria-label={t("mailbox.openArtifactAria", "Open artifact: {{label}}", { label })}>
|
||||
{t("mailbox.openArtifact", "Open artifact")}
|
||||
</button>
|
||||
) : (
|
||||
<a className="mailbox-artifact-attachment__link btn" href={mediaUrl} target="_blank" rel="noreferrer" aria-label={t("mailbox.openArtifactAria", "Open artifact: {{label}}", { label })}>
|
||||
{t("mailbox.openArtifact", "Open artifact")}
|
||||
</a>
|
||||
);
|
||||
@@ -79,13 +79,9 @@ export const MailboxArtifactAttachment = memo(function MailboxArtifactAttachment
|
||||
let preview: ReactNode = null;
|
||||
if (type === "image" && !imageFailed) {
|
||||
preview = (
|
||||
<img
|
||||
className="mailbox-artifact-attachment__media mailbox-artifact-attachment__image"
|
||||
src={mediaUrl}
|
||||
alt={label}
|
||||
loading="lazy"
|
||||
onError={() => setImageFailed(true)}
|
||||
/>
|
||||
<button type="button" className="mailbox-artifact-attachment__image-button" onClick={() => setImageViewerOpen(true)} aria-label={t("mailbox.openArtifactAria", "Open artifact: {{label}}", { label })}>
|
||||
<ArtifactImage className="mailbox-artifact-attachment__media mailbox-artifact-attachment__image" artifactId={id} projectId={projectId} title={label} onError={() => setImageFailed(true)} />
|
||||
</button>
|
||||
);
|
||||
} else if (type === "video") {
|
||||
preview = (
|
||||
@@ -123,6 +119,7 @@ export const MailboxArtifactAttachment = memo(function MailboxArtifactAttachment
|
||||
{openLink}
|
||||
{taskLink}
|
||||
</div>
|
||||
{imageViewerOpen && type === "image" && <ArtifactImageViewer artifactId={id} projectId={projectId} title={label} onClose={() => setImageViewerOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, ChevronDown, ChevronUp, Plus, Trash2, History, X } from "lucide-react";
|
||||
import { FileText, ChevronDown, ChevronUp, Plus, Trash2, History } from "lucide-react";
|
||||
import "./DocumentsView.css";
|
||||
import "./TaskDocumentsTab.css";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { useArtifacts } from "../hooks/useArtifacts";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { ArtifactMedia, getArtifactTypeLabel } from "./ArtifactMedia";
|
||||
import { ArtifactImageViewer } from "./ArtifactImageViewer";
|
||||
|
||||
// Document key validation: alphanumeric, hyphens, underscores, 1-64 chars
|
||||
const DOCUMENT_KEY_REGEX = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||
@@ -96,12 +97,12 @@ function TaskArtifactCard({ artifact, projectId, onExpandImage }: TaskArtifactCa
|
||||
onClick={() => onExpandImage(artifact)}
|
||||
aria-label={t("documents.expandImageArtifact", "Expand image artifact {{title}}", { title })}
|
||||
>
|
||||
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
|
||||
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} projectId={projectId} title={title} preview={preview} t={t} />
|
||||
<span className="documents-artifact-expand-hint">{t("documents.expandArtifactHint", "Click to expand")}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="documents-artifact-preview">
|
||||
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
|
||||
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} projectId={projectId} title={title} preview={preview} t={t} />
|
||||
</div>
|
||||
)}
|
||||
<div className="documents-artifact-body">
|
||||
@@ -162,9 +163,6 @@ export function TaskDocumentsTab({
|
||||
* Task detail image artifacts must be viewable in-place from the task modal. Keep the expand target image-only so document, audio, video, and generic cards retain their current non-lightbox behavior without empty controls.
|
||||
*/
|
||||
const [lightboxArtifact, setLightboxArtifact] = useState<ArtifactWithTask | null>(null);
|
||||
const lightboxDialogRef = useRef<HTMLDivElement>(null);
|
||||
const lightboxCloseRef = useRef<HTMLButtonElement>(null);
|
||||
const lightboxReturnFocusRef = useRef<HTMLElement | null>(null);
|
||||
const loadedTaskIdRef = useRef(taskId);
|
||||
const documentKeysRef = useRef<Set<string>>(new Set());
|
||||
const { artifacts, loading: artifactsLoading, error: artifactsError } = useArtifacts({ projectId, taskId });
|
||||
@@ -381,79 +379,8 @@ export function TaskDocumentsTab({
|
||||
setEditContent("");
|
||||
}
|
||||
|
||||
const handleExpandArtifactImage = useCallback((artifact: ArtifactWithTask) => {
|
||||
lightboxReturnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
setLightboxArtifact(artifact);
|
||||
}, []);
|
||||
|
||||
const handleCloseLightbox = useCallback(() => {
|
||||
setLightboxArtifact(null);
|
||||
lightboxReturnFocusRef.current?.focus();
|
||||
lightboxReturnFocusRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lightboxArtifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
lightboxCloseRef.current?.focus();
|
||||
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
handleCloseLightbox();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-06-29-17:08:
|
||||
* The artifact preview declares an aria-modal dialog, so keyboard focus must stay inside the lightbox until Escape, overlay click, or the close button dismisses it. Cycle Tab/Shift+Tab over current focusable controls instead of letting focus escape into the task-detail modal behind the overlay.
|
||||
*/
|
||||
const dialog = lightboxDialogRef.current;
|
||||
const focusableElements = Array.from(dialog?.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
) ?? []).filter((element) => element.getAttribute("aria-hidden") !== "true");
|
||||
|
||||
if (!dialog || focusableElements.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
if (event.shiftKey && activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
} else if (!event.shiftKey && activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
} else if (!dialog.contains(activeElement)) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleCloseLightbox, lightboxArtifact]);
|
||||
|
||||
const handleLightboxOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
handleCloseLightbox();
|
||||
}
|
||||
}, [handleCloseLightbox]);
|
||||
const handleExpandArtifactImage = useCallback((artifact: ArtifactWithTask) => setLightboxArtifact(artifact), []);
|
||||
const handleCloseLightbox = useCallback(() => setLightboxArtifact(null), []);
|
||||
|
||||
if (loading || artifactsLoading) {
|
||||
return (
|
||||
@@ -749,32 +676,7 @@ export function TaskDocumentsTab({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{lightboxArtifact && (
|
||||
<div
|
||||
ref={lightboxDialogRef}
|
||||
className="modal-overlay open documents-artifact-lightbox-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("documents.lightboxLabel", "Artifact media preview")}
|
||||
onClick={handleLightboxOverlayClick}
|
||||
>
|
||||
<div className="documents-artifact-lightbox" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="documents-artifact-lightbox-header">
|
||||
<h3 className="documents-artifact-lightbox-title">{lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}</h3>
|
||||
<button ref={lightboxCloseRef} className="modal-close" onClick={handleCloseLightbox} aria-label={t("documents.closeLightbox", "Close artifact preview")}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="documents-artifact-lightbox-media-frame">
|
||||
<img
|
||||
className="documents-artifact-lightbox-media"
|
||||
src={artifactMediaUrlWithToken(lightboxArtifact.id, projectId)}
|
||||
alt={lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{lightboxArtifact && <ArtifactImageViewer artifactId={lightboxArtifact.id} title={lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")} projectId={projectId} onClose={handleCloseLightbox} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ArtifactImageViewer } from "../ArtifactImageViewer";
|
||||
import { useArtifactImageBlob } from "../../hooks/useArtifactImageBlob";
|
||||
|
||||
vi.mock("../../hooks/useArtifactImageBlob", () => ({ useArtifactImageBlob: vi.fn() }));
|
||||
vi.mock("../FloatingWindow", () => ({
|
||||
FloatingWindow: ({ children, ariaLabel }: { children: ReactNode; ariaLabel?: string }) => <div role="dialog" aria-label={ariaLabel}>{children}</div>,
|
||||
}));
|
||||
|
||||
const mockUseArtifactImageBlob = vi.mocked(useArtifactImageBlob);
|
||||
|
||||
describe("ArtifactImageViewer", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("renders a blob image without a raw media link and closes with Escape while restoring focus", () => {
|
||||
mockUseArtifactImageBlob.mockReturnValue({ url: "blob:secure-preview", loading: false, error: null, reload: vi.fn() });
|
||||
const onClose = vi.fn();
|
||||
const trigger = document.createElement("button");
|
||||
trigger.textContent = "Open";
|
||||
document.body.append(trigger);
|
||||
trigger.focus();
|
||||
|
||||
const { unmount } = render(<ArtifactImageViewer artifactId="image-1" title="Secure image" onClose={onClose} />);
|
||||
const image = screen.getByRole("img", { name: "Secure image" });
|
||||
expect(image).toHaveAttribute("src", "blob:secure-preview");
|
||||
expect(image.getAttribute("src")).not.toContain("fn_token");
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
unmount();
|
||||
expect(trigger).toHaveFocus();
|
||||
trigger.remove();
|
||||
});
|
||||
|
||||
it("exposes an accessible error and retry action without an unsafe image URL", () => {
|
||||
const reload = vi.fn();
|
||||
mockUseArtifactImageBlob.mockReturnValue({ url: null, loading: false, error: "Failed to load image artifact.", reload });
|
||||
render(<ArtifactImageViewer artifactId="image-1" title="Broken image" onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load image artifact.");
|
||||
expect(screen.queryByRole("img")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,9 @@ vi.mock("../../api", () => ({
|
||||
vi.mock("../FloatingWindow", () => ({
|
||||
FloatingWindow: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("../../hooks/useArtifactImageBlob", () => ({
|
||||
useArtifactImageBlob: vi.fn(() => ({ url: "blob:secure-preview", loading: false, error: null, reload: vi.fn() })),
|
||||
}));
|
||||
|
||||
const artifacts: ArtifactWithTask[] = [
|
||||
{
|
||||
@@ -69,7 +72,7 @@ function openDocumentViewer() {
|
||||
}
|
||||
|
||||
function expectViewerOpen(container: HTMLElement) {
|
||||
expect(container.querySelector(".artifacts-gallery-viewer")).not.toBeNull();
|
||||
expect(container.querySelector(".artifacts-gallery-viewer, .artifact-image-viewer")).not.toBeNull();
|
||||
}
|
||||
|
||||
describe("ArtifactsGallery mobile viewer navigation history", () => {
|
||||
@@ -104,7 +107,7 @@ describe("ArtifactsGallery mobile viewer navigation history", () => {
|
||||
await waitFor(() => expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "Expand Image artifact" })).toBeInTheDocument();
|
||||
dispatchPopState(0);
|
||||
expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull();
|
||||
expect(container.querySelector(".artifact-image-viewer")).toBeNull();
|
||||
});
|
||||
|
||||
it("routes Android native Back through popstate before dismissing the document viewer", async () => {
|
||||
|
||||
@@ -43,6 +43,9 @@ vi.mock("../../hooks/useArtifacts", () => ({
|
||||
vi.mock("../../hooks/useProjectMarkdownFiles", () => ({
|
||||
useProjectMarkdownFiles: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../hooks/useArtifactImageBlob", () => ({
|
||||
useArtifactImageBlob: vi.fn(() => ({ url: "blob:secure-preview", loading: false, error: null, reload: vi.fn() })),
|
||||
}));
|
||||
|
||||
const mockUseDocuments = vi.mocked(useDocuments);
|
||||
const mockUseArtifacts = vi.mocked(useArtifacts);
|
||||
@@ -699,7 +702,7 @@ describe("DocumentsView", () => {
|
||||
const imageEntry = screen.getByRole("button", { name: "Open KB-001 artifact Task screenshot" });
|
||||
fireEvent.click(imageEntry);
|
||||
expect(imageEntry).toHaveAttribute("aria-current", "true");
|
||||
expect(screen.getByRole("img", { name: "Task screenshot" })).toHaveAttribute("src", "/api/artifacts/task-artifact-image/media?fn_token=daemon-token");
|
||||
expect(screen.getByRole("img", { name: "Task screenshot" })).toHaveAttribute("src", "blob:secure-preview");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
|
||||
@@ -936,7 +939,7 @@ describe("DocumentsView", () => {
|
||||
fireEvent.click(artifactsTab);
|
||||
|
||||
expect(screen.getByRole("tab", { name: /show artifacts/i })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
|
||||
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "blob:secure-preview");
|
||||
expect(screen.getByRole("button", { name: "Expand Image artifact" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Expand Video artifact" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO");
|
||||
@@ -991,7 +994,7 @@ describe("DocumentsView", () => {
|
||||
*/
|
||||
fireEvent.click(screen.getByRole("button", { name: "Expand Image artifact" }));
|
||||
let dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
|
||||
expect(within(dialog).getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
|
||||
expect(within(dialog).getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "blob:secure-preview");
|
||||
expect(screen.getByTestId("floating-window-artifact-media-artifact-image")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("floating-window-resize-se")).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, fireEvent } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MailboxArtifactAttachment } from "../MailboxArtifactAttachment";
|
||||
import { artifactMediaUrlWithToken } from "../../api";
|
||||
|
||||
@@ -7,10 +7,21 @@ vi.mock("../../api", () => ({
|
||||
artifactMediaUrlWithToken: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}&` : "?"}fn_token=daemon-token`),
|
||||
}));
|
||||
|
||||
vi.mock("../ArtifactImageViewer", () => ({
|
||||
ArtifactImage: ({ title, onError }: { title: string; onError?: () => void }) => <img alt={title} src="blob:secure-preview" onError={onError} />,
|
||||
ArtifactImageViewer: ({ title, onClose }: { title: string; onClose: () => void }) => <div role="dialog"><img alt={title} src="blob:secure-preview" /><button type="button" aria-label="Close image artifact preview" onClick={onClose}>Close</button></div>,
|
||||
}));
|
||||
|
||||
const mockArtifactMediaUrlWithToken = vi.mocked(artifactMediaUrlWithToken);
|
||||
|
||||
describe("MailboxArtifactAttachment", () => {
|
||||
it("renders image artifacts inline with the project-scoped media URL", () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
mockArtifactMediaUrlWithToken.mockClear();
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it("renders image artifacts through a blob preview and viewer without a tokenized link", () => {
|
||||
render(
|
||||
<MailboxArtifactAttachment
|
||||
artifactId="art-image"
|
||||
@@ -21,10 +32,13 @@ describe("MailboxArtifactAttachment", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockArtifactMediaUrlWithToken).toHaveBeenCalledWith("art-image", "proj-1");
|
||||
expect(mockArtifactMediaUrlWithToken).not.toHaveBeenCalledWith("art-image", "proj-1");
|
||||
const image = screen.getByRole("img", { name: "Screenshot" });
|
||||
expect(image).toHaveAttribute("src", "/api/artifacts/art-image/media?projectId=proj-1&fn_token=daemon-token");
|
||||
expect(screen.getByRole("link", { name: "Open artifact: Screenshot" })).toHaveAttribute("href", "/api/artifacts/art-image/media?projectId=proj-1&fn_token=daemon-token");
|
||||
expect(image).toHaveAttribute("src", "blob:secure-preview");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Open artifact: Screenshot" }).at(-1)!);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(document.body.innerHTML).not.toContain("fn_token");
|
||||
expect(document.body.innerHTML).not.toContain("daemon-token");
|
||||
});
|
||||
|
||||
it("renders a View task affordance when task metadata and a handler are present", () => {
|
||||
@@ -49,14 +63,14 @@ describe("MailboxArtifactAttachment", () => {
|
||||
render(<MailboxArtifactAttachment artifactId="art-image" artifactType="image" title="Screenshot" taskId="FN-1234" />);
|
||||
|
||||
expect(screen.queryByTestId("mailbox-artifact-view-task")).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "Open artifact: Screenshot" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: "Open artifact: Screenshot" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not render a View task affordance without task metadata", () => {
|
||||
render(<MailboxArtifactAttachment artifactId="art-image" artifactType="image" title="Screenshot" onOpenTask={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByTestId("mailbox-artifact-view-task")).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "Open artifact: Screenshot" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: "Open artifact: Screenshot" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -86,13 +100,13 @@ describe("MailboxArtifactAttachment", () => {
|
||||
expect(screen.queryByTestId("mailbox-artifact-attachment")).toBeNull();
|
||||
});
|
||||
|
||||
it("degrades image load failures to the open artifact link", () => {
|
||||
it("keeps the safe viewer action available after an image load failure", () => {
|
||||
render(<MailboxArtifactAttachment artifactId="art-broken" artifactType="image" title="Broken screenshot" taskId="FN-1234" onOpenTask={vi.fn()} />);
|
||||
|
||||
fireEvent.error(screen.getByRole("img", { name: "Broken screenshot" }));
|
||||
|
||||
expect(screen.queryByRole("img", { name: "Broken screenshot" })).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "Open artifact: Broken screenshot" })).toHaveAttribute("href", "/api/artifacts/art-broken/media?fn_token=daemon-token");
|
||||
expect(screen.getByRole("button", { name: "Open artifact: Broken screenshot" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mailbox-artifact-view-task")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,9 @@ vi.mock("../../api", () => ({
|
||||
vi.mock("../../hooks/useArtifacts", () => ({
|
||||
useArtifacts: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../hooks/useArtifactImageBlob", () => ({
|
||||
useArtifactImageBlob: vi.fn(() => ({ url: "blob:secure-preview", loading: false, error: null, reload: vi.fn() })),
|
||||
}));
|
||||
|
||||
const mockFetchTaskDocuments = vi.mocked(fetchTaskDocuments);
|
||||
const mockFetchTaskDocumentRevisions = vi.mocked(fetchTaskDocumentRevisions);
|
||||
@@ -238,7 +241,7 @@ describe("TaskDocumentsTab", () => {
|
||||
expect(screen.getByRole("heading", { name: "Media artifacts" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
|
||||
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "blob:secure-preview");
|
||||
expect(screen.getByRole("button", { name: "Expand image artifact Image artifact" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Expand image artifact Video artifact/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO");
|
||||
@@ -269,7 +272,7 @@ describe("TaskDocumentsTab", () => {
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("img", { name: "Image artifact" })[1]).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
|
||||
expect(screen.getAllByRole("img", { name: "Image artifact" })[1]).toHaveAttribute("src", "blob:secure-preview");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close artifact preview" }));
|
||||
|
||||
@@ -300,7 +303,7 @@ describe("TaskDocumentsTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("traps keyboard focus inside the image artifact lightbox", async () => {
|
||||
it("moves focus to the shared viewer close control", async () => {
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: mockArtifacts,
|
||||
loading: false,
|
||||
@@ -311,18 +314,7 @@ describe("TaskDocumentsTab", () => {
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Expand image artifact Image artifact" }));
|
||||
const closeButton = screen.getByRole("button", { name: "Close artifact preview" });
|
||||
expect(closeButton).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Tab" });
|
||||
expect(closeButton).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
|
||||
expect(closeButton).toHaveFocus();
|
||||
|
||||
screen.getAllByRole("button", { name: "Collapse" })[0].focus();
|
||||
fireEvent.keyDown(document, { key: "Tab" });
|
||||
expect(closeButton).toHaveFocus();
|
||||
expect(screen.getByRole("button", { name: "Close artifact preview" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("surfaces artifact fetch errors", async () => {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useArtifactImageBlob } from "../useArtifactImageBlob";
|
||||
import { artifactMediaUrl } from "../../api";
|
||||
import { withTokenHeader } from "../../auth";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
artifactMediaUrl: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}` : ""}`),
|
||||
}));
|
||||
vi.mock("../../auth", () => ({ withTokenHeader: vi.fn(() => ({ Authorization: "Bearer dashboard-secret" })) }));
|
||||
|
||||
const mockArtifactMediaUrl = vi.mocked(artifactMediaUrl);
|
||||
const mockWithTokenHeader = vi.mocked(withTokenHeader);
|
||||
const createObjectUrl = vi.fn(() => "blob:artifact-preview");
|
||||
const revokeObjectUrl = vi.fn();
|
||||
|
||||
function imageResponse() {
|
||||
return {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
blob: async () => new Blob(["png"], { type: "image/png" }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function textResponse() {
|
||||
return {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "text/plain" }),
|
||||
blob: async () => new Blob(["not an image"], { type: "text/plain" }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe("useArtifactImageBlob", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
vi.stubGlobal("URL", { ...URL, createObjectURL: createObjectUrl, revokeObjectURL: revokeObjectUrl });
|
||||
createObjectUrl.mockClear();
|
||||
revokeObjectUrl.mockClear();
|
||||
});
|
||||
|
||||
it("fetches a token-free media URL with Authorization and returns only a blob URL", async () => {
|
||||
vi.stubGlobal("URL", { ...URL, createObjectURL: createObjectUrl, revokeObjectURL: revokeObjectUrl });
|
||||
const fetchMock = vi.fn().mockResolvedValue(imageResponse());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useArtifactImageBlob("image-1", "project-1"));
|
||||
|
||||
await waitFor(() => expect(result.current.url).toBe("blob:artifact-preview"));
|
||||
expect(mockArtifactMediaUrl).toHaveBeenCalledWith("image-1", "project-1");
|
||||
expect(mockWithTokenHeader).toHaveBeenCalledWith();
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/artifacts/image-1/media?projectId=project-1", expect.objectContaining({ headers: { Authorization: "Bearer dashboard-secret" } }));
|
||||
expect(result.current.url).not.toContain("fn_token");
|
||||
expect(result.current.url).not.toContain("dashboard-secret");
|
||||
});
|
||||
|
||||
it("aborts and revokes the previous object URL when the artifact changes or unmounts", async () => {
|
||||
vi.stubGlobal("URL", { ...URL, createObjectURL: createObjectUrl, revokeObjectURL: revokeObjectUrl });
|
||||
const fetchMock = vi.fn().mockResolvedValue(imageResponse());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { result, rerender, unmount } = renderHook(({ id }) => useArtifactImageBlob(id), { initialProps: { id: "image-1" } });
|
||||
|
||||
await waitFor(() => expect(result.current.url).toBe("blob:artifact-preview"));
|
||||
rerender({ id: "image-2" });
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ signal: expect.any(AbortSignal) });
|
||||
await waitFor(() => expect(revokeObjectUrl).toHaveBeenCalledWith("blob:artifact-preview"));
|
||||
unmount();
|
||||
expect(revokeObjectUrl.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("keeps non-image and failed responses out of DOM URLs", async () => {
|
||||
vi.stubGlobal("URL", { ...URL, createObjectURL: createObjectUrl, revokeObjectURL: revokeObjectUrl });
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(textResponse()));
|
||||
const { result } = renderHook(() => useArtifactImageBlob("not-image"));
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBe("Failed to load image artifact."));
|
||||
expect(result.current.url).toBeNull();
|
||||
expect(createObjectUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries a failed request without exposing its source URL", async () => {
|
||||
vi.stubGlobal("URL", { ...URL, createObjectURL: createObjectUrl, revokeObjectURL: revokeObjectUrl });
|
||||
const fetchMock = vi.fn().mockRejectedValueOnce(new Error("network")).mockResolvedValueOnce(imageResponse());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { result } = renderHook(() => useArtifactImageBlob("retry-image"));
|
||||
|
||||
await waitFor(() => expect(result.current.error).not.toBeNull());
|
||||
act(() => result.current.reload());
|
||||
await waitFor(() => expect(result.current.url).toBe("blob:artifact-preview"));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
59
packages/dashboard/app/hooks/useArtifactImageBlob.ts
Normal file
59
packages/dashboard/app/hooks/useArtifactImageBlob.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { artifactMediaUrl } from "../api";
|
||||
import { withTokenHeader } from "../auth";
|
||||
|
||||
export interface ArtifactImageBlobState {
|
||||
url: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ArtifactImageSecurity 2026-08-19-18:08:
|
||||
* Registered images must never hand browser elements a tokenized media URL. Fetch through the
|
||||
* existing header-authenticated endpoint and expose only a short-lived object URL, revoking it
|
||||
* whenever its artifact view is replaced or closed so credentials cannot enter DOM URLs.
|
||||
*/
|
||||
export function useArtifactImageBlob(artifactId?: string, projectId?: string): ArtifactImageBlobState {
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const reload = useCallback(() => setReloadKey((current) => current + 1), []);
|
||||
const [state, setState] = useState<Omit<ArtifactImageBlobState, "reload">>({ url: null, loading: Boolean(artifactId), error: null });
|
||||
|
||||
useEffect(() => {
|
||||
if (!artifactId) {
|
||||
setState({ url: null, loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let objectUrl: string | null = null;
|
||||
setState({ url: null, loading: true, error: null });
|
||||
|
||||
void fetch(artifactMediaUrl(artifactId, projectId), {
|
||||
headers: withTokenHeader(),
|
||||
signal: controller.signal,
|
||||
}).then(async (response) => {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!response.ok || !contentType.toLowerCase().startsWith("image/")) {
|
||||
throw new Error("Artifact media is unavailable or is not an image.");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (controller.signal.aborted) return;
|
||||
if (!blob.type.toLowerCase().startsWith("image/")) {
|
||||
throw new Error("Artifact media is not an image.");
|
||||
}
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
if (!controller.signal.aborted) setState({ url: objectUrl, loading: false, error: null });
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) setState({ url: null, loading: false, error: "Failed to load image artifact." });
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [artifactId, projectId, reloadKey]);
|
||||
|
||||
return { ...state, reload };
|
||||
}
|
||||
Reference in New Issue
Block a user