Skip to content

Commit 2347cd9

Browse files
paulpopusPatrikKozakclaude
authored
feat: move trash out of beta and delete access can now be limited to trash only (#15210)
### Access control can be limited to only trash You can now limit users to only trash without being able to permanently delete: ```ts import type { CollectionConfig } from 'payload' export const Posts: CollectionConfig = { slug: 'posts', trash: true, access: { delete: ({ req: { user }, data }) => { // Not logged in - no access if (!user) { return false } // Admins can do anything (trash or permanently delete) if (user.roles?.includes('admin')) { return true } // Regular users: check what operation they're attempting // If data.deletedAt is being set, it's a trash operation - allow it if (data?.deletedAt) { return true } // Otherwise it's a permanent delete - deny for non-admins return false }, }, fields: [ // ... ], } ``` --------- Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent f5a5bd8 commit 2347cd9

30 files changed

Lines changed: 1561 additions & 104 deletions

File tree

docs/access-control/collections.mdx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -258,10 +258,18 @@ export const canDeleteCustomer: Access<Customer> = async ({ req, id }) => {
258258

259259
The following arguments are provided to the `delete` function:
260260

261-
| Option | Description |
262-
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
263-
| **`req`** | The [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object with additional `user` property, which is the currently logged in user. |
264-
| **`id`** | `id` of document requested to delete. |
261+
| Option | Description |
262+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
263+
| **`req`** | The [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object with additional `user` property, which is the currently logged in user. |
264+
| **`id`** | `id` of document requested to delete. |
265+
| **`data`** | The data being set on the document. For [Trash-enabled](../trash/overview) collections, check `data.deletedAt` to differentiate between soft delete and permanent delete operations. |
266+
267+
<Banner type="success">
268+
**Tip:** For Collections with [Trash](../trash/overview) enabled, you can use
269+
the `data` argument to allow users to soft delete (trash) documents while
270+
restricting permanent deletion. See [Trash Access
271+
Control](../trash/overview#access-control) for examples.
272+
</Banner>
265273

266274
### Admin
267275

docs/trash/overview.mdx

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,6 @@ Trash (also known as soft delete) allows documents to be marked as deleted witho
1010

1111
Soft delete is a safer way to manage content lifecycle, giving editors a chance to review and recover documents that may have been deleted by mistake.
1212

13-
<Banner type="warning">
14-
**Note:** The Trash feature is currently in beta and may be subject to change
15-
in minor version updates.
16-
</Banner>
17-
1813
## Collection Configuration
1914

2015
To enable soft deleting for a collection, set the `trash` property to `true`:
@@ -183,12 +178,85 @@ query {
183178

184179
## Access Control
185180

186-
All trash-related actions (delete, permanent delete) respect the `delete` access control defined in your collection config.
181+
All trash-related actions (soft delete, permanent delete, restore) respect the `delete` access control defined in your collection config.
187182

188183
This means:
189184

190185
- If a user is denied delete access, they cannot soft delete or permanently delete documents
191186

187+
### Differentiating Between Trash and Permanent Delete
188+
189+
You can configure access control to allow some users to trash documents while restricting permanent deletion to admins only. This is useful when you want editors to be able to "delete" content (move to trash) but only allow administrators to permanently remove data.
190+
191+
The `delete` access control function receives a `data` argument that contains the document data being set during the operation:
192+
193+
- **When trashing:** `data.deletedAt` will be set to a timestamp
194+
- **When permanently deleting:** `data` will be `undefined`
195+
196+
This pattern is similar to how `publish` access control works with `data._status`.
197+
198+
#### Example: Allow All Users to Trash, Only Admins to Permanently Delete
199+
200+
```ts
201+
import type { CollectionConfig } from 'payload'
202+
203+
export const Posts: CollectionConfig = {
204+
slug: 'posts',
205+
trash: true,
206+
access: {
207+
delete: ({ req: { user }, data }) => {
208+
// Not logged in - no access
209+
if (!user) {
210+
return false
211+
}
212+
213+
// Admins can do anything (trash or permanently delete)
214+
if (user.roles?.includes('admin')) {
215+
return true
216+
}
217+
218+
// Regular users: check what operation they're attempting
219+
// If data.deletedAt is being set, it's a trash operation - allow it
220+
if (data?.deletedAt) {
221+
return true
222+
}
223+
224+
// Otherwise it's a permanent delete - deny for non-admins
225+
return false
226+
},
227+
},
228+
fields: [
229+
// ...
230+
],
231+
}
232+
```
233+
234+
#### Example: Only Admins Can Delete (Both Trash and Permanent)
235+
236+
```ts
237+
import type { CollectionConfig } from 'payload'
238+
239+
export const SensitiveData: CollectionConfig = {
240+
slug: 'sensitive-data',
241+
trash: true,
242+
access: {
243+
delete: ({ req: { user } }) => {
244+
// Only allow admins to trash or permanently delete
245+
return Boolean(user?.roles?.includes('admin'))
246+
},
247+
},
248+
fields: [
249+
// ...
250+
],
251+
}
252+
```
253+
254+
In the Admin Panel, when a user has permission to trash but not permanently delete:
255+
256+
- The delete button will be visible
257+
- The "Delete permanently" checkbox in the confirmation modal will be hidden
258+
- The user can only move documents to trash
259+
192260
## Versions and Trash
193261

194262
When a document is soft-deleted:

packages/next/src/views/Account/index.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,18 @@ export async function AccountView({ initPageResult, params, searchParams }: Admi
6363
})
6464

6565
// Get permissions
66-
const { docPermissions, hasPublishPermission, hasSavePermission } =
67-
await getDocumentPermissions({
68-
id: user.id,
69-
collectionConfig,
70-
data,
71-
req,
72-
})
66+
const {
67+
docPermissions,
68+
hasDeletePermission,
69+
hasPublishPermission,
70+
hasSavePermission,
71+
hasTrashPermission,
72+
} = await getDocumentPermissions({
73+
id: user.id,
74+
collectionConfig,
75+
data,
76+
req,
77+
})
7378

7479
// Build initial form state from data
7580
const { state: formState } = await buildFormState({
@@ -124,9 +129,11 @@ export async function AccountView({ initPageResult, params, searchParams }: Admi
124129
collectionSlug={userSlug}
125130
currentEditor={currentEditor}
126131
docPermissions={docPermissions}
132+
hasDeletePermission={hasDeletePermission}
127133
hasPublishedDoc={hasPublishedDoc}
128134
hasPublishPermission={hasPublishPermission}
129135
hasSavePermission={hasSavePermission}
136+
hasTrashPermission={hasTrashPermission}
130137
id={user?.id}
131138
initialData={data}
132139
initialState={formState}

packages/next/src/views/Document/getDocumentPermissions.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,17 @@ export const getDocumentPermissions = async (args: {
2424
req: PayloadRequest
2525
}): Promise<{
2626
docPermissions: SanitizedDocumentPermissions
27+
hasDeletePermission: boolean
2728
hasPublishPermission: boolean
2829
hasSavePermission: boolean
30+
hasTrashPermission: boolean
2931
}> => {
3032
const { id, collectionConfig, data = {}, globalConfig, req } = args
3133

3234
let docPermissions: SanitizedDocumentPermissions
3335
let hasPublishPermission = false
36+
let hasTrashPermission = false
37+
let hasDeletePermission = false
3438

3539
if (collectionConfig) {
3640
try {
@@ -61,6 +65,39 @@ export const getDocumentPermissions = async (args: {
6165
})
6266
).update
6367
}
68+
69+
if (collectionConfig.trash) {
70+
const { deletedAt: _, ...dataWithoutDeletedAt } = data || {}
71+
72+
const [trashPermissionResult, deletePermissionResult] = await Promise.all([
73+
docAccessOperation({
74+
id,
75+
collection: {
76+
config: collectionConfig,
77+
},
78+
data: {
79+
...data,
80+
deletedAt: new Date().toISOString(),
81+
},
82+
req,
83+
}),
84+
docAccessOperation({
85+
id,
86+
collection: {
87+
config: collectionConfig,
88+
},
89+
data: dataWithoutDeletedAt,
90+
req,
91+
}),
92+
])
93+
94+
hasTrashPermission = trashPermissionResult.delete
95+
hasDeletePermission = deletePermissionResult.delete
96+
} else {
97+
// When trash is not enabled, delete permission is straightforward
98+
hasDeletePermission = 'delete' in docPermissions ? Boolean(docPermissions.delete) : false
99+
hasTrashPermission = false
100+
}
64101
} catch (err) {
65102
logError({ err, payload: req.payload })
66103
}
@@ -86,6 +123,10 @@ export const getDocumentPermissions = async (args: {
86123
})
87124
).update
88125
}
126+
127+
// Globals don't support trash
128+
hasDeletePermission = false
129+
hasTrashPermission = false
89130
} catch (err) {
90131
logError({ err, payload: req.payload })
91132
}
@@ -104,7 +145,9 @@ export const getDocumentPermissions = async (args: {
104145

105146
return {
106147
docPermissions,
148+
hasDeletePermission,
107149
hasPublishPermission,
108150
hasSavePermission,
151+
hasTrashPermission,
109152
}
110153
}

packages/next/src/views/Document/index.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,13 @@ export const renderDocument = async ({
162162

163163
const [
164164
docPreferences,
165-
{ docPermissions, hasPublishPermission, hasSavePermission },
165+
{
166+
docPermissions,
167+
hasDeletePermission,
168+
hasPublishPermission,
169+
hasSavePermission,
170+
hasTrashPermission,
171+
},
166172
{ currentEditor, isLocked, lastUpdateTime },
167173
entityPreferences,
168174
] = await Promise.all([
@@ -399,9 +405,11 @@ export const renderDocument = async ({
399405
disableActions={disableActions ?? false}
400406
docPermissions={docPermissions}
401407
globalSlug={globalConfig?.slug}
408+
hasDeletePermission={hasDeletePermission}
402409
hasPublishedDoc={hasPublishedDoc}
403410
hasPublishPermission={hasPublishPermission}
404411
hasSavePermission={hasSavePermission}
412+
hasTrashPermission={hasTrashPermission}
405413
id={id}
406414
initialData={doc}
407415
initialState={formState}

packages/next/src/views/List/index.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,13 @@ export const renderListView = async (
354354
})
355355

356356
const hasCreatePermission = permissions?.collections?.[collectionSlug]?.create
357-
const hasDeletePermission = permissions?.collections?.[collectionSlug]?.delete
357+
358+
const { hasDeletePermission, hasTrashPermission } = await getDocumentPermissions({
359+
collectionConfig,
360+
// Empty object serves as base for computing differentiated trash/delete permissions
361+
data: {},
362+
req,
363+
})
358364

359365
// Check if there's a notFound query parameter (document ID that wasn't found)
360366
const notFoundDocId = typeof searchParams?.notFound === 'string' ? searchParams.notFound : null
@@ -379,6 +385,7 @@ export const renderListView = async (
379385
collectionSlug,
380386
hasCreatePermission,
381387
hasDeletePermission,
388+
hasTrashPermission,
382389
newDocumentURL,
383390
},
384391
collectionConfig,
@@ -416,6 +423,7 @@ export const renderListView = async (
416423
enableRowSelections,
417424
hasCreatePermission,
418425
hasDeletePermission,
426+
hasTrashPermission,
419427
listPreferences: collectionPreferences,
420428
newDocumentURL,
421429
queryPreset,

packages/payload/src/admin/views/list.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export type ListViewClientProps = {
4646
enableRowSelections?: boolean
4747
hasCreatePermission: boolean
4848
hasDeletePermission?: boolean
49+
hasTrashPermission?: boolean
4950
/**
5051
* @deprecated
5152
*/
@@ -66,6 +67,7 @@ export type ListViewSlotSharedClientProps = {
6667
collectionSlug: SanitizedCollectionConfig['slug']
6768
hasCreatePermission: boolean
6869
hasDeletePermission?: boolean
70+
hasTrashPermission?: boolean
6971
newDocumentURL: string
7072
}
7173

packages/payload/src/collections/config/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,6 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
722722
* This allows documents to be marked as deleted without being permanently removed.
723723
* The `deletedAt` field will be set to the current date and time when a document is trashed.
724724
*
725-
* @experimental This is a beta feature and its behavior may be refined in future releases.
726725
* @default false
727726
*/
728727
trash?: boolean

packages/payload/src/collections/operations/update.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,11 @@ export const updateOperation = async <
155155

156156
// Enforce delete access if performing a soft-delete (trash)
157157
if (isTrashAttempt && !overrideAccess) {
158-
const deleteAccessResult = await executeAccess({ req }, collectionConfig.access.delete)
158+
// Pass data so access function can check data.deletedAt to know it's a trash attempt
159+
const deleteAccessResult = await executeAccess(
160+
{ data: bulkUpdateData, req },
161+
collectionConfig.access.delete,
162+
)
159163
fullWhere = combineQueries(fullWhere, deleteAccessResult)
160164
}
161165

packages/payload/src/collections/operations/updateByID.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,8 @@ export const updateByIDOperation = async <
137137
data.deletedAt != null
138138

139139
if (isTrashAttempt && !overrideAccess) {
140-
const deleteAccessResult = await executeAccess({ req }, collectionConfig.access.delete)
140+
// Pass data so access function can check data.deletedAt to know it's a trash attempt
141+
const deleteAccessResult = await executeAccess({ data, req }, collectionConfig.access.delete)
141142
fullWhere = combineQueries(fullWhere, deleteAccessResult)
142143
}
143144

0 commit comments

Comments
 (0)