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 <paul@payloadcms.com>
This commit is contained in:
Sasha
2025-08-15 21:52:45 +03:00
committed by GitHub
parent 1909063e42
commit 3dd142c637
7 changed files with 119 additions and 4 deletions

2
.gitignore vendored
View File

@@ -331,5 +331,7 @@ test/databaseAdapter.js
test/.localstack test/.localstack
test/google-cloud-storage test/google-cloud-storage
test/azurestoragedata/ test/azurestoragedata/
/media-without-delete-access
licenses.csv licenses.csv

View File

@@ -339,8 +339,7 @@ export const Upload_v4: React.FC<UploadProps_v4> = (props) => {
} }
}, [isFormSubmitting]) }, [isFormSubmitting])
const canRemoveUpload = const canRemoveUpload = docPermissions?.update
docPermissions?.update && 'delete' in docPermissions && docPermissions?.delete
const hasImageSizes = uploadConfig?.imageSizes?.length > 0 const hasImageSizes = uploadConfig?.imageSizes?.length > 0
const hasResizeOptions = Boolean(uploadConfig?.resizeOptions) const hasResizeOptions = Boolean(uploadConfig?.resizeOptions)

View File

@@ -10,3 +10,4 @@ without-meta-data
svg-only svg-only
/media-gif /media-gif
/custom-file-name-media /custom-file-name-media
/media-without-delete-access

View File

@@ -31,6 +31,7 @@ import {
listViewPreviewSlug, listViewPreviewSlug,
mediaSlug, mediaSlug,
mediaWithoutCacheTagsSlug, mediaWithoutCacheTagsSlug,
mediaWithoutDeleteAccessSlug,
mediaWithoutRelationPreviewSlug, mediaWithoutRelationPreviewSlug,
mediaWithRelationPreviewSlug, mediaWithRelationPreviewSlug,
noRestrictFileMimeTypesSlug, noRestrictFileMimeTypesSlug,
@@ -931,6 +932,12 @@ export default buildConfigWithDefaults({
staticDir: path.resolve(dirname, './svg-only'), staticDir: path.resolve(dirname, './svg-only'),
}, },
}, },
{
slug: mediaWithoutDeleteAccessSlug,
fields: [],
upload: true,
access: { delete: () => false },
},
], ],
onInit: async (payload) => { onInit: async (payload) => {
const uploadsDir = path.resolve(dirname, './media') const uploadsDir = path.resolve(dirname, './media')
@@ -954,6 +961,8 @@ export default buildConfigWithDefaults({
file: imageFile, file: imageFile,
}) })
await payload.create({ collection: mediaWithoutDeleteAccessSlug, data: {}, file: imageFile })
const { id: versionedImage } = await payload.create({ const { id: versionedImage } = await payload.create({
collection: versionSlug, collection: versionSlug,
data: { data: {

View File

@@ -40,6 +40,7 @@ import {
listViewPreviewSlug, listViewPreviewSlug,
mediaSlug, mediaSlug,
mediaWithoutCacheTagsSlug, mediaWithoutCacheTagsSlug,
mediaWithoutDeleteAccessSlug,
relationPreviewSlug, relationPreviewSlug,
relationSlug, relationSlug,
svgOnlySlug, svgOnlySlug,
@@ -89,6 +90,7 @@ let stopCollectingErrorsFromPage: () => boolean
let bulkUploadsURL: AdminUrlUtil let bulkUploadsURL: AdminUrlUtil
let fileMimeTypeURL: AdminUrlUtil let fileMimeTypeURL: AdminUrlUtil
let svgOnlyURL: AdminUrlUtil let svgOnlyURL: AdminUrlUtil
let mediaWithoutDeleteAccessURL: AdminUrlUtil
describe('Uploads', () => { describe('Uploads', () => {
let page: Page let page: Page
@@ -129,6 +131,7 @@ describe('Uploads', () => {
bulkUploadsURL = new AdminUrlUtil(serverURL, bulkUploadsSlug) bulkUploadsURL = new AdminUrlUtil(serverURL, bulkUploadsSlug)
fileMimeTypeURL = new AdminUrlUtil(serverURL, fileMimeTypeSlug) fileMimeTypeURL = new AdminUrlUtil(serverURL, fileMimeTypeSlug)
svgOnlyURL = new AdminUrlUtil(serverURL, svgOnlySlug) svgOnlyURL = new AdminUrlUtil(serverURL, svgOnlySlug)
mediaWithoutDeleteAccessURL = new AdminUrlUtil(serverURL, mediaWithoutDeleteAccessSlug)
const context = await browser.newContext() const context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
@@ -1599,4 +1602,22 @@ describe('Uploads', () => {
await saveDocAndAssert(page, '#action-save', 'error') 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')
})
}) })

View File

@@ -98,6 +98,7 @@ export interface Config {
'externally-served-media': ExternallyServedMedia; 'externally-served-media': ExternallyServedMedia;
'uploads-1': Uploads1; 'uploads-1': Uploads1;
'uploads-2': Uploads2; 'uploads-2': Uploads2;
'any-images': AnyImage;
'admin-thumbnail-function': AdminThumbnailFunction; 'admin-thumbnail-function': AdminThumbnailFunction;
'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQuery; 'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQuery;
'admin-thumbnail-size': AdminThumbnailSize; 'admin-thumbnail-size': AdminThumbnailSize;
@@ -119,6 +120,7 @@ export interface Config {
'simple-relationship': SimpleRelationship; 'simple-relationship': SimpleRelationship;
'file-mime-type': FileMimeType; 'file-mime-type': FileMimeType;
'svg-only': SvgOnly; 'svg-only': SvgOnly;
'media-without-delete-access': MediaWithoutDeleteAccess;
users: User; users: User;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
@@ -157,6 +159,7 @@ export interface Config {
'externally-served-media': ExternallyServedMediaSelect<false> | ExternallyServedMediaSelect<true>; 'externally-served-media': ExternallyServedMediaSelect<false> | ExternallyServedMediaSelect<true>;
'uploads-1': Uploads1Select<false> | Uploads1Select<true>; 'uploads-1': Uploads1Select<false> | Uploads1Select<true>;
'uploads-2': Uploads2Select<false> | Uploads2Select<true>; 'uploads-2': Uploads2Select<false> | Uploads2Select<true>;
'any-images': AnyImagesSelect<false> | AnyImagesSelect<true>;
'admin-thumbnail-function': AdminThumbnailFunctionSelect<false> | AdminThumbnailFunctionSelect<true>; 'admin-thumbnail-function': AdminThumbnailFunctionSelect<false> | AdminThumbnailFunctionSelect<true>;
'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQueriesSelect<false> | AdminThumbnailWithSearchQueriesSelect<true>; 'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQueriesSelect<false> | AdminThumbnailWithSearchQueriesSelect<true>;
'admin-thumbnail-size': AdminThumbnailSizeSelect<false> | AdminThumbnailSizeSelect<true>; 'admin-thumbnail-size': AdminThumbnailSizeSelect<false> | AdminThumbnailSizeSelect<true>;
@@ -178,6 +181,7 @@ export interface Config {
'simple-relationship': SimpleRelationshipSelect<false> | SimpleRelationshipSelect<true>; 'simple-relationship': SimpleRelationshipSelect<false> | SimpleRelationshipSelect<true>;
'file-mime-type': FileMimeTypeSelect<false> | FileMimeTypeSelect<true>; 'file-mime-type': FileMimeTypeSelect<false> | FileMimeTypeSelect<true>;
'svg-only': SvgOnlySelect<false> | SvgOnlySelect<true>; 'svg-only': SvgOnlySelect<false> | SvgOnlySelect<true>;
'media-without-delete-access': MediaWithoutDeleteAccessSelect<false> | MediaWithoutDeleteAccessSelect<true>;
users: UsersSelect<false> | UsersSelect<true>; users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admin-thumbnail-function". * via the `definition` "admin-thumbnail-function".
@@ -1623,6 +1645,24 @@ export interface SvgOnly {
focalX?: number | null; focalX?: number | null;
focalY?: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users". * via the `definition` "users".
@@ -1778,6 +1818,10 @@ export interface PayloadLockedDocument {
relationTo: 'uploads-2'; relationTo: 'uploads-2';
value: string | Uploads2; value: string | Uploads2;
} | null) } | null)
| ({
relationTo: 'any-images';
value: string | AnyImage;
} | null)
| ({ | ({
relationTo: 'admin-thumbnail-function'; relationTo: 'admin-thumbnail-function';
value: string | AdminThumbnailFunction; value: string | AdminThumbnailFunction;
@@ -1862,6 +1906,10 @@ export interface PayloadLockedDocument {
relationTo: 'svg-only'; relationTo: 'svg-only';
value: string | SvgOnly; value: string | SvgOnly;
} | null) } | null)
| ({
relationTo: 'media-without-delete-access';
value: string | MediaWithoutDeleteAccess;
} | null)
| ({ | ({
relationTo: 'users'; relationTo: 'users';
value: string | User; value: string | User;
@@ -3019,6 +3067,23 @@ export interface Uploads2Select<T extends boolean = true> {
focalX?: T; focalX?: T;
focalY?: T; focalY?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "any-images_select".
*/
export interface AnyImagesSelect<T extends boolean = true> {
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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admin-thumbnail-function_select". * via the `definition` "admin-thumbnail-function_select".
@@ -3386,6 +3451,23 @@ export interface SvgOnlySelect<T extends boolean = true> {
focalX?: T; focalX?: T;
focalY?: T; focalY?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media-without-delete-access_select".
*/
export interface MediaWithoutDeleteAccessSelect<T extends boolean = true> {
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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select". * via the `definition` "users_select".

View File

@@ -40,3 +40,4 @@ export const bulkUploadsSlug = 'bulk-uploads'
export const fileMimeTypeSlug = 'file-mime-type' export const fileMimeTypeSlug = 'file-mime-type'
export const svgOnlySlug = 'svg-only' export const svgOnlySlug = 'svg-only'
export const anyImagesSlug = 'any-images' export const anyImagesSlug = 'any-images'
export const mediaWithoutDeleteAccessSlug = 'media-without-delete-access'