From 3dd142c637385aa1c07c87523ca85306332ba2e0 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Fri, 15 Aug 2025 21:52:45 +0300 Subject: [PATCH] fix(ui): cannot replace the file if the user does not have delete access (#13484) Currently, if you don't have delete access to the document, the UI doesn't allow you to replace the file, which isn't expected. This is also a UI only restriction, and the API allows you do this fine. This PR makes so the "remove file" button renders even if you don't have delete access, while still ensures you have update access. --------- Co-authored-by: Paul Popus --- .gitignore | 2 + packages/ui/src/elements/Upload/index.tsx | 3 +- test/uploads/.gitignore | 1 + test/uploads/config.ts | 9 +++ test/uploads/e2e.spec.ts | 21 ++++++ test/uploads/payload-types.ts | 86 ++++++++++++++++++++++- test/uploads/shared.ts | 1 + 7 files changed, 119 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 1c3738570..2ff25d747 100644 --- a/.gitignore +++ b/.gitignore @@ -331,5 +331,7 @@ test/databaseAdapter.js test/.localstack test/google-cloud-storage test/azurestoragedata/ +/media-without-delete-access + licenses.csv diff --git a/packages/ui/src/elements/Upload/index.tsx b/packages/ui/src/elements/Upload/index.tsx index 37ff56fcc..e7c1af28d 100644 --- a/packages/ui/src/elements/Upload/index.tsx +++ b/packages/ui/src/elements/Upload/index.tsx @@ -339,8 +339,7 @@ export const Upload_v4: React.FC = (props) => { } }, [isFormSubmitting]) - const canRemoveUpload = - docPermissions?.update && 'delete' in docPermissions && docPermissions?.delete + const canRemoveUpload = docPermissions?.update const hasImageSizes = uploadConfig?.imageSizes?.length > 0 const hasResizeOptions = Boolean(uploadConfig?.resizeOptions) diff --git a/test/uploads/.gitignore b/test/uploads/.gitignore index bcacd0cfe..f3524a6b7 100644 --- a/test/uploads/.gitignore +++ b/test/uploads/.gitignore @@ -10,3 +10,4 @@ without-meta-data svg-only /media-gif /custom-file-name-media +/media-without-delete-access diff --git a/test/uploads/config.ts b/test/uploads/config.ts index 2a250bd8e..d1ce54b1b 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -31,6 +31,7 @@ import { listViewPreviewSlug, mediaSlug, mediaWithoutCacheTagsSlug, + mediaWithoutDeleteAccessSlug, mediaWithoutRelationPreviewSlug, mediaWithRelationPreviewSlug, noRestrictFileMimeTypesSlug, @@ -931,6 +932,12 @@ export default buildConfigWithDefaults({ staticDir: path.resolve(dirname, './svg-only'), }, }, + { + slug: mediaWithoutDeleteAccessSlug, + fields: [], + upload: true, + access: { delete: () => false }, + }, ], onInit: async (payload) => { const uploadsDir = path.resolve(dirname, './media') @@ -954,6 +961,8 @@ export default buildConfigWithDefaults({ file: imageFile, }) + await payload.create({ collection: mediaWithoutDeleteAccessSlug, data: {}, file: imageFile }) + const { id: versionedImage } = await payload.create({ collection: versionSlug, data: { diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 1b55dadd4..f02d936c2 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -40,6 +40,7 @@ import { listViewPreviewSlug, mediaSlug, mediaWithoutCacheTagsSlug, + mediaWithoutDeleteAccessSlug, relationPreviewSlug, relationSlug, svgOnlySlug, @@ -89,6 +90,7 @@ let stopCollectingErrorsFromPage: () => boolean let bulkUploadsURL: AdminUrlUtil let fileMimeTypeURL: AdminUrlUtil let svgOnlyURL: AdminUrlUtil +let mediaWithoutDeleteAccessURL: AdminUrlUtil describe('Uploads', () => { let page: Page @@ -129,6 +131,7 @@ describe('Uploads', () => { bulkUploadsURL = new AdminUrlUtil(serverURL, bulkUploadsSlug) fileMimeTypeURL = new AdminUrlUtil(serverURL, fileMimeTypeSlug) svgOnlyURL = new AdminUrlUtil(serverURL, svgOnlySlug) + mediaWithoutDeleteAccessURL = new AdminUrlUtil(serverURL, mediaWithoutDeleteAccessSlug) const context = await browser.newContext() page = await context.newPage() @@ -1599,4 +1602,22 @@ describe('Uploads', () => { await saveDocAndAssert(page, '#action-save', 'error') }) + + test('should be able to replace the file even if the user doesnt have delete access', async () => { + const docID = (await payload.find({ collection: mediaWithoutDeleteAccessSlug, limit: 1 })) + .docs[0]?.id as string + await page.goto(mediaWithoutDeleteAccessURL.edit(docID)) + const removeButton = page.locator('.file-details__remove') + await expect(removeButton).toBeVisible() + await removeButton.click() + await expect(page.locator('input[type="file"]')).toBeAttached() + await page.setInputFiles('input[type="file"]', path.join(dirname, 'test-image.jpg')) + const filename = page.locator('.file-field__filename') + await expect(filename).toHaveValue('test-image.jpg') + await saveDocAndAssert(page) + const filenameFromAPI = ( + await payload.find({ collection: mediaWithoutDeleteAccessSlug, limit: 1 }) + ).docs[0]?.filename + expect(filenameFromAPI).toBe('test-image.jpg') + }) }) diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index d4ccd00d2..29aa36588 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -98,6 +98,7 @@ export interface Config { 'externally-served-media': ExternallyServedMedia; 'uploads-1': Uploads1; 'uploads-2': Uploads2; + 'any-images': AnyImage; 'admin-thumbnail-function': AdminThumbnailFunction; 'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQuery; 'admin-thumbnail-size': AdminThumbnailSize; @@ -119,6 +120,7 @@ export interface Config { 'simple-relationship': SimpleRelationship; 'file-mime-type': FileMimeType; 'svg-only': SvgOnly; + 'media-without-delete-access': MediaWithoutDeleteAccess; users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -157,6 +159,7 @@ export interface Config { 'externally-served-media': ExternallyServedMediaSelect | ExternallyServedMediaSelect; 'uploads-1': Uploads1Select | Uploads1Select; 'uploads-2': Uploads2Select | Uploads2Select; + 'any-images': AnyImagesSelect | AnyImagesSelect; 'admin-thumbnail-function': AdminThumbnailFunctionSelect | AdminThumbnailFunctionSelect; 'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQueriesSelect | AdminThumbnailWithSearchQueriesSelect; 'admin-thumbnail-size': AdminThumbnailSizeSelect | AdminThumbnailSizeSelect; @@ -178,6 +181,7 @@ export interface Config { 'simple-relationship': SimpleRelationshipSelect | SimpleRelationshipSelect; 'file-mime-type': FileMimeTypeSelect | FileMimeTypeSelect; 'svg-only': SvgOnlySelect | SvgOnlySelect; + 'media-without-delete-access': MediaWithoutDeleteAccessSelect | MediaWithoutDeleteAccessSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -1313,6 +1317,24 @@ export interface AdminThumbnailSize { }; }; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "any-images". + */ +export interface AnyImage { + id: string; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "admin-thumbnail-function". @@ -1623,6 +1645,24 @@ export interface SvgOnly { focalX?: number | null; focalY?: number | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media-without-delete-access". + */ +export interface MediaWithoutDeleteAccess { + id: string; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". @@ -1778,6 +1818,10 @@ export interface PayloadLockedDocument { relationTo: 'uploads-2'; value: string | Uploads2; } | null) + | ({ + relationTo: 'any-images'; + value: string | AnyImage; + } | null) | ({ relationTo: 'admin-thumbnail-function'; value: string | AdminThumbnailFunction; @@ -1862,6 +1906,10 @@ export interface PayloadLockedDocument { relationTo: 'svg-only'; value: string | SvgOnly; } | null) + | ({ + relationTo: 'media-without-delete-access'; + value: string | MediaWithoutDeleteAccess; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -3019,6 +3067,23 @@ export interface Uploads2Select { focalX?: T; focalY?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "any-images_select". + */ +export interface AnyImagesSelect { + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "admin-thumbnail-function_select". @@ -3386,6 +3451,23 @@ export interface SvgOnlySelect { focalX?: T; focalY?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media-without-delete-access_select". + */ +export interface MediaWithoutDeleteAccessSelect { + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select". @@ -3450,6 +3532,6 @@ export interface Auth { declare module 'payload' { - // @ts-ignore + // @ts-ignore export interface GeneratedTypes extends Config {} -} +} \ No newline at end of file diff --git a/test/uploads/shared.ts b/test/uploads/shared.ts index 9181aa33b..57439b9bd 100644 --- a/test/uploads/shared.ts +++ b/test/uploads/shared.ts @@ -40,3 +40,4 @@ export const bulkUploadsSlug = 'bulk-uploads' export const fileMimeTypeSlug = 'file-mime-type' export const svgOnlySlug = 'svg-only' export const anyImagesSlug = 'any-images' +export const mediaWithoutDeleteAccessSlug = 'media-without-delete-access'