Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/__tests__/media/purgeConfirmation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Permanent deletion of a media asset has to ask first.
*
* "Delete permanently" removes the original binary AND every generated size
* from the storage adapter — not just the row. It is the only media action
* with no undo, and it sat one unguarded click away inside the trash preview,
* beside the Restore button it visually matches.
*
* These tests pin the confirmation itself rather than the wording: that the
* mutation does NOT fire on the first click, that Cancel leaves the asset
* alone, and that the second click is what commits.
*
* The dialog is local to the window on purpose. `useConfirmDelete` falls back
* to running `commit()` immediately when no `ConfirmDeleteProvider` is mounted
* (confirmDeleteHook.ts), and this window also renders from the dashboard
* media widget, which mounts none — so routing through that hook would have
* left one surface silently unguarded while looking covered everywhere.
*/

import { afterEach, describe, expect, it } from 'bun:test'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { MediaViewerWindow } from '@admin/pages/media/components/MediaViewerWindow/MediaViewerWindow'
import { AdminSessionContext } from '@admin/sessionContext'

afterEach(cleanup)

/** A trashed asset — the only state in which the purge button renders. */
function trashedAsset() {
return {
id: 'asset_1',
filename: 'logo.png',
mimeType: 'image/png',
sizeBytes: 1200,
publicPath: '/uploads/logo.png',
uploadedByUserId: null,
createdAt: '2026-01-01T00:00:00.000Z',
altText: '',
caption: '',
title: '',
tags: [],
width: null,
height: null,
durationMs: null,
dominantColor: null,
deletedAt: '2026-02-01T00:00:00.000Z',
replacedAt: null,
folderIds: [],
blurHash: null,
variants: [],
posterPath: null,
}
}

function renderViewer() {
const purged: string[] = []
const editor = {
asset: trashedAsset(),
tagPalette: [],
folderById: new Map(),
updateAsset: async () => undefined,
renameAsset: async () => undefined,
replaceAssetFile: async () => undefined,
restoreAsset: async () => undefined,
purgeAsset: async (id: string) => {
purged.push(id)
},
}
// `media.delete` is what renders the purge button at all.
const session = {
user: { id: 'u1', capabilities: ['media.delete', 'media.write'] },
setUser: () => {},
}
render(
<AdminSessionContext value={session as never}>
<MediaViewerWindow
editor={editor as unknown as Parameters<typeof MediaViewerWindow>[0]['editor']}
open
onClose={() => {}}
/>
</AdminSessionContext>,
)
return { purged }
}

/** The button in the sidebar, not the one inside the dialog footer. */
function clickPurgeButton() {
const buttons = screen.getAllByRole('button', { name: /delete permanently/i })
fireEvent.click(buttons[0]!)
}

describe('permanent media deletion asks first', () => {
it('does not purge on the first click', () => {
const { purged } = renderViewer()
clickPurgeButton()
expect(purged).toEqual([])
})

it('names the asset so the operator can see what they are about to lose', () => {
renderViewer()
clickPurgeButton()
// The filename also appears in the window chrome, so match the dialog's
// own heading rather than any occurrence of it.
expect(screen.getByText('Delete "logo.png" permanently?')).toBeTruthy()
})

it('cancelling leaves the asset alone', () => {
const { purged } = renderViewer()
clickPurgeButton()
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(purged).toEqual([])
})

it('confirming is what commits the purge', () => {
const { purged } = renderViewer()
clickPurgeButton()
// The footer button — the last match, since the sidebar button is still
// mounted behind the dialog.
const buttons = screen.getAllByRole('button', { name: /delete permanently/i })
fireEvent.click(buttons[buttons.length - 1]!)
expect(purged).toEqual(['asset_1'])
})
})
21 changes: 19 additions & 2 deletions src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { FilterBar, type FilterBarItem } from '@ui/components/FilterBar'
import { Select } from '@ui/components/Select'
import { Skeleton } from '@ui/components/Skeleton'
import { canDeleteMedia, canWriteMedia } from '@admin/access'
import { useConfirmDelete } from '@admin/shared/dialogs/ConfirmDeleteDialog'
import { useCurrentAdminUser } from '@admin/sessionContext'
import {
ExplorerItemContextMenu,
Expand Down Expand Up @@ -122,6 +123,7 @@ function folderMatchesQuery(folder: CmsMediaFolder, query: string): boolean {

export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanvasProps) {
const currentUser = useCurrentAdminUser()
const confirmDelete = useConfirmDelete()
const [viewMode, setViewModeState] = useState<MediaViewMode>(readStoredMediaViewMode)
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const [renameTarget, setRenameTarget] = useState<CmsMediaAsset | null>(null)
Expand Down Expand Up @@ -530,8 +532,23 @@ export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanv
onDelete={() => {
const target = contextMenu.asset
setContextMenu(null)
if (trashView) void workspace.purgeAsset(target.id)
else void workspace.trashAsset(target.id)
if (!trashView) {
void workspace.trashAsset(target.id)
return
}
// Purging is the one media action nothing can undo: it removes the
// original and every generated size from the storage adapter, not
// just the row. `alwaysConfirm` because the `confirmBeforeDelete`
// preference defaults off, and an operator who turned it off was
// opting out of confirming a TRASH, which is reversible.
confirmDelete({
title: `Delete "${target.filename}" permanently?`,
description:
'This removes the file and every generated size from disk. It cannot be undone.',
confirmLabel: 'Delete permanently',
alwaysConfirm: true,
commit: () => void workspace.purgeAsset(target.id),
})
}}
showRename={canWrite}
showDelete={canDelete}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import { useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Button } from '@ui/components/Button'
import { Dialog } from '@ui/components/Dialog'
import { Input, Textarea } from '@ui/components/Input'
import { canDeleteMedia, canReplaceMedia, canWriteMedia } from '@admin/access'
import { useCurrentAdminUser } from '@admin/sessionContext'
Expand Down Expand Up @@ -101,6 +102,7 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {
const currentUser = useCurrentAdminUser()
const { asset } = editor
const [replaceOpen, setReplaceOpen] = useState(false)
const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false)
const bucket = bucketForMime(asset.mimeType)
const canWrite = canWriteMedia(currentUser)
const canReplace = canReplaceMedia(currentUser)
Expand Down Expand Up @@ -328,7 +330,7 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {
<Button
variant="destructive"
size="sm"
onClick={() => void editor.purgeAsset(asset.id)}
onClick={() => setPurgeConfirmOpen(true)}
>
<TrashSolidIcon size={13} />
<span>Delete permanently</span>
Expand All @@ -348,6 +350,40 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {
onReplace={(file) => editor.replaceAssetFile(asset.id, file)}
/>
)}

{/* Local dialog rather than the shared `useConfirmDelete`: that hook
falls back to running `commit()` immediately when no provider is
mounted, and this window is also rendered from the dashboard widget,
which has none. A confirmation that silently disappears on one
surface is worse than none, because it reads as covered. */}
<Dialog
open={purgeConfirmOpen}
onClose={() => setPurgeConfirmOpen(false)}
tone="danger"
eyebrow="Cannot be undone"
title={`Delete "${asset.filename}" permanently?`}
footer={
<>
<Button variant="secondary" onClick={() => setPurgeConfirmOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setPurgeConfirmOpen(false)
void editor.purgeAsset(asset.id)
}}
>
Delete permanently
</Button>
</>
}
>
<p>
This removes the file and every generated size from disk. Any page
still referencing it will render a broken image.
</p>
</Dialog>
</aside>,
document.body,
)
Expand Down
Loading