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/google-cloud-storage
test/azurestoragedata/
/media-without-delete-access
licenses.csv

View File

@@ -339,8 +339,7 @@ export const Upload_v4: React.FC<UploadProps_v4> = (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)

View File

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

View File

@@ -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: {

View File

@@ -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')
})
})

View File

@@ -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<false> | ExternallyServedMediaSelect<true>;
'uploads-1': Uploads1Select<false> | Uploads1Select<true>;
'uploads-2': Uploads2Select<false> | Uploads2Select<true>;
'any-images': AnyImagesSelect<false> | AnyImagesSelect<true>;
'admin-thumbnail-function': AdminThumbnailFunctionSelect<false> | AdminThumbnailFunctionSelect<true>;
'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQueriesSelect<false> | AdminThumbnailWithSearchQueriesSelect<true>;
'admin-thumbnail-size': AdminThumbnailSizeSelect<false> | AdminThumbnailSizeSelect<true>;
@@ -178,6 +181,7 @@ export interface Config {
'simple-relationship': SimpleRelationshipSelect<false> | SimpleRelationshipSelect<true>;
'file-mime-type': FileMimeTypeSelect<false> | FileMimeTypeSelect<true>;
'svg-only': SvgOnlySelect<false> | SvgOnlySelect<true>;
'media-without-delete-access': MediaWithoutDeleteAccessSelect<false> | MediaWithoutDeleteAccessSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<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
* 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<T extends boolean = true> {
focalX?: 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
* via the `definition` "admin-thumbnail-function_select".
@@ -3386,6 +3451,23 @@ export interface SvgOnlySelect<T extends boolean = true> {
focalX?: 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
* via the `definition` "users_select".
@@ -3450,6 +3532,6 @@ export interface Auth {
declare module 'payload' {
// @ts-ignore
// @ts-ignore
export interface GeneratedTypes extends Config {}
}
}

View File

@@ -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'