Commit Graph

1997 Commits

Author SHA1 Message Date
German Jablonski 67063ab5e3 fix: unpublish updates existing version instead of creating a new one (#15985)
## Summary

Unpublishing a document currently creates a new version in the versions
table, even though the content hasn't changed. Only `_status`
transitions from `published` to `draft`. This clutters the version
history with redundant entries and has been a source of confusion (see
#10838, #12342, #15125).

This PR makes unpublish update the latest existing version in-place
instead of creating a new one, for both collections and globals.

Closes #10838 (again)

## Related

#10838
#12342
#15125 (Discussion)

## Changes

- `saveVersion` now accepts an `unpublish` flag. When set, it finds the
latest version and updates it in-place (same pattern as the existing
autosave overwrite path) instead of creating a new version.
- Collection and global update operations pass `unpublish:
unpublishAllLocales` to `saveVersion`.

No UI or `next` package changes are needed. The admin UI already sends
`unpublishAllLocales=true` in the query string when the user clicks
"Unpublish." This parameter already flowed through the endpoint handler,
the operation, and into `updateDocument`. The only thing missing was
that `saveVersion` didn't use it. This PR makes `saveVersion` check for
it.

For normal updates (saving drafts, publishing, editing content),
`unpublishAllLocales` is `undefined`, so `!!undefined` evaluates to
`false` and the new code path is skipped entirely.

## Scope and limitations

This PR covers the main "Unpublish" action, which sends
`unpublishAllLocales=true` even for non-localized collections. This is
the case reported in #10838 and #12342.

Single-locale unpublish (the "Unpublish in [locale]" button, available
when `localizeStatus` is enabled) is **not** covered by this PR. That
flow does not send `unpublishAllLocales=true`. Instead, it falls into
the single-locale publish/unpublish code path, which involves data
merging and snapshots. The current code does not distinguish between
"publish locale X" and "unpublish locale X," so fixing it requires a new
flag (e.g. `unpublishLocale`) that the UI would need to send. This
should be addressed in a follow-up PR.

## Testing

Run the new tests with:

```
PAYLOAD_DATABASE=postgres pnpm run test:int versions -- --testNamePattern="Unpublish"
```

Without this PR's changes, 2 of the 3 tests fail:

```
FAIL  should not create a new version when unpublishing a collection document
AssertionError: expected [ { id: 249, parent: 24, …(6) }, …(1) ] to have a length of 1 but got 2

FAIL  should not create a new version when unpublishing a global
AssertionError: expected [ …(2) ] to have a length of 1 but got 2
```

The third test (`should update main table _status to draft when
unpublishing`) passes both before and after, confirming the main table
was already updated correctly.

---------

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
2026-03-20 07:34:40 -04:00
Alessio Gravili a188556e99 chore: bump monorepo Next.js version to 16.2.0 (#15992)
This PR does not bump the Next.js version in the templates yet. First,
let's run our test suite against 16.2.0 and make sure it's fully
compatible.
2026-03-19 11:21:02 -04:00
Patrik 57a0edc6ff fix: use latest draft version data when trashing unpublished documents (#15981)
# Overview

Fixes document data being silently lost when bulk-trashing draft-only
documents on collections with `trash: true` and `versions.drafts`
enabled.

Localized fields are the most visible symptom, but any field data that
exists only in the versions table (i.e. never published) is affected.

## Key Changes

- In the bulk `update` operation, `queryDrafts` (versions table) is now
also used when fetching documents for a trash attempt, not only when
`shouldSaveDraft` is true

## Root Cause

Draft saves skip `updateOne` on the main collection table
(`isSavingDraft === true`), so the main table only reflects the last
published state. When bulk-trashing, the operation was reading
`docWithLocales` from the main table via `find()` — picking up
stale/empty data — and creating a new version from that stale snapshot.

Single-document trash (`updateByID`) was unaffected because it uses
`getLatestCollectionVersion()`, which always reads from the versions
table.

## Fix

```typescript
// Before
if (hasDraftsEnabled(collectionConfig) && shouldSaveDraft) {

// After
if (hasDraftsEnabled(collectionConfig) && (shouldSaveDraft || isTrashAttempt)) {
```

Fixes #15980
2026-03-19 07:17:44 -07:00
Sasha 395e1ed1a4 feat: add disableUnique property to the slug field for better multi tenant plugin support (#15963)
Fixes https://github.com/payloadcms/payload/issues/14938

This PR adds a new property `disableUnique` which is useful when you
want to add a compound unique index to the collection `indexes` config
instead, for example with the multi-tenant plugin where the slug is only
unique per tenant, not globally.
2026-03-17 14:20:38 +00:00
Jessica Rynkar e4742054db fix: stricter input validation (#15868)
Misc input validation improvements, sanitizing path segments in both SQL
and JSON queries, standardizing the processing of column and JSON paths
across different adapters, and making adjustments to traversal and alias
generation to align behavior across components.
2026-03-16 10:48:24 +00:00
Omar Yusuf Abdi fac59c8515 fix: text field validation rejecting localized object values (#15932)
## Summary

Fixes #15828

When using `locale: 'all'` with a required localized text field, the
`text` validator incorrectly rejects the value. This happens because:

1. With `locale: 'all'`, the value passed to the validator is an object
like `{en: "Hello", es: "World"}` (locale unflattening via
`mergeLocaleActions` happens *after* validation in `promise.ts`)
2. The old condition `!(typeof value === 'string' ||
Array.isArray(value))` evaluates to `true` for objects, triggering the
required error
3. The `textarea` validator already handles this correctly by using
`!value` (objects are truthy)

### Fix

Changed the text validator's required check from:
```ts
if (!(typeof value === 'string' || Array.isArray(value)) || value?.length === 0)
```
to:
```ts
if (!value || ((typeof value === 'string' || Array.isArray(value)) && value.length === 0))
```

This aligns with the textarea validator's approach:
- `!value` catches `null`, `undefined`, and empty string `""`
- The second clause catches empty arrays `[]` (truthy but length 0)
- Localized objects like `{en: "Hello"}` pass because `!obj` is `false`

### Changes

- **`packages/payload/src/fields/validations.ts`** — 1-line fix to the
required check condition
- **`packages/payload/src/fields/validations.spec.ts`** — 3 unit tests
(localized object accepted, null rejected, empty string rejected)
- **`test/fields/collections/Text/index.ts`** — Added
`localizedRequiredText` test field
- **`test/fields/int.spec.ts`** — Integration test verifying `locale:
'all'` with required localized text

## Test plan

- [x] Unit tests: 88/88 passed (85 existing + 3 new)
- [x] Integration tests (SQLite): 154/154 passed
- [ ] CI passes on all database adapters
2026-03-12 16:37:33 -07:00
Jarrod Flesch ef507a6907 fix: use Sec-Fetch-Site header for cookie authentication validation (#15751)
## Summary

- Adds `Sec-Fetch-Site` header validation for cookie-based
authentication when `Origin` header is absent
- Accepts cookies for `same-origin` and `same-site` requests
- Rejects cookies for `cross-site` and `none` requests
- Falls back to rejecting cookies when `Sec-Fetch-Site` is absent and
`csrf` is configured (non-browser clients should use `Authorization`
header)

---------

Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
2026-03-12 16:31:36 +00:00
Sasha f30d34f9fc fix: improved request origin retrieval (#15919)
Adds a new function `getRequestOrigin` that properly retrieves the
origin of the request, either from `serverURL` or a valid `host` header
2026-03-11 22:38:33 +00:00
Sasha 6a9e367341 fix: generate:types inlines all blocks, add forceInlineBlocks property to use in plugin mcp (#15892)
Fixes https://github.com/payloadcms/payload/issues/15820 which is a
regression from https://github.com/payloadcms/payload/pull/15675.

This PR adds a new property to use it for plugin mcp internal purposes
instead of modifying the behaviour globally.
2026-03-11 11:39:00 +00:00
Divyam gupta 17aa1b570f fix: update broken custom components docs link in config types jsdoc (#15794)
<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->

### What?

Fix a broken documentation link in the `Config` type JSDoc for admin
custom components.

- Updated:
  - `https://payloadcms.com/docs/admin/custom-components/overview`
- To:
  - `https://payloadcms.com/docs/custom-components/overview`

### Why?

The previous URL was broken/outdated, so developers reading type
definitions were sent to an invalid docs path.

### How?

Changed the `@see` link in:

- `packages/payload/src/config/types.ts`

No runtime behavior or API logic changed; this is a docs-link correction
only.
2026-03-10 15:42:26 -07:00
Sasha dc049fe00f fix: run sanitizeWhereQuery for join query access result (#15891)
This PR properly runs `sanitizeWhereQuery` for joined collections which
is important specifically for virtual relationship fields path
transformation.

Fixes https://github.com/payloadcms/payload/issues/15332
2026-03-10 17:04:33 -04:00
Paul c6054c5601 fix: early return out of me access (#15883)
Closes https://github.com/payloadcms/payload/issues/15822

Simple change, moves up the logic check to before the fetch so it
returns early skipping a database call in some cases
2026-03-10 14:09:29 +00:00
German Jablonski 17a0d196f4 fix: scope orderable join reordering by parent relation (#15842)
## Context
The root problem was scope leakage in join-based ordering. Reorder
operations were effectively treating join items as one shared list, so
moving an item in one parent context could influence ordering in a
different parent context.

## Changes
- Reorder key generation, adjacent-row lookup, and initial missing-key
backfill now run in the same join `on` scope as the target item.
- Fast drag/drop cases that send `pending` now resolve against scoped
context before computing new keys.
- `index.ts` keeps orchestration, and scoped query logic is extracted
into focused helpers to keep behavior changes easier to review.
- Replaced a local where-wrapper with the shared
`combineWhereConstraints` utility.

## Impact
Users can reorder join rows without cross-parent side effects. Items in
unrelated parent relations no longer jump or get new keys from another
parent's ordering context.

---------

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
2026-03-06 11:54:50 -05:00
German Jablonski e89918266d fix: restoreVersion validation for localized required fields (#15821)
## Summary
- fix collection `restoreVersion` to read version/current docs with all
locales before restore processing
- validate restore data in the default locale context while preserving
full locale data for persistence
- add an integration regression test for restoring versions when
required localized fields are empty in a non-default locale

Closes #15698

## Related
payloadcms/payload#15698

## Changes
- update `packages/payload/src/collections/operations/restoreVersion.ts`
- add coverage in `test/versions/int.spec.ts`

## Testing
- [x] `PAYLOAD_DATABASE=sqlite NODE_OPTIONS="--no-deprecation
--no-experimental-strip-types" NODE_NO_WARNINGS=1 DISABLE_LOGGING=true
pnpm exec vitest --project int test/versions/int.spec.ts -t "should
restore published version with correct data|should restore a published
version when required localized fields are empty in a non-default
locale"`

---------

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
2026-03-03 17:14:51 +00:00
Luiz Cláudio f47069900d fix: draft doc validation when duplicating docs (#15816)
### What?
This PR defaults the `draft` value to true when it is not set during a
duplicate operation.

### Why?
It validates the required fields even if the user is trying to duplicate
a draft doc.

The regression was introduced when
[`packages/payload/src/collections/endpoints/duplicate.ts`](https://github.com/payloadcms/payload/blob/v3.77.0/packages/payload/src/collections/endpoints/duplicate.ts)
was refactored to use the shared `parseParams` utility instead of
manually parsing query parameters.

**v3.62.0 (working)** — In [v3.62.0 of
`duplicate.ts`](https://github.com/payloadcms/payload/blob/v3.62.0/packages/payload/src/collections/endpoints/duplicate.ts),
the `draft` parameter was explicitly defaulted to `true`:

```typescript
// draft defaults to true, unless explicitly set requested as false
// to prevent the newly duplicated document from being published
const draft = searchParams.get('draft') !== 'false'
```

**v3.77.0 (broken)** — In [v3.77.0 of
`duplicate.ts`](https://github.com/payloadcms/payload/blob/v3.77.0/packages/payload/src/collections/endpoints/duplicate.ts),
the endpoint uses
[`parseParams`](https://github.com/payloadcms/payload/blob/v3.77.0/packages/payload/src/utilities/parseParams/index.ts):

```typescript
const { depth, draft, populate, select, selectedLocales } = parseParams(req.query)
```

`parseParams` only sets `draft` to `true` if the query string explicitly
contains `?draft=true`. The admin UI's
[`DuplicateDocument`](https://github.com/payloadcms/payload/blob/v3.77.0/packages/ui/src/elements/DuplicateDocument/index.tsx)
component does **not** send `?draft=true` as a query parameter, so
`draft` is `undefined`.

The UI does send `{ _status: 'draft' }` in the request body, but that
does not influence the `draft` flag. In
[`createOperation`](https://github.com/payloadcms/payload/blob/v3.77.0/packages/payload/src/collections/operations/create.ts),
the validation skip logic depends entirely on the `draft` argument:

```typescript
const isSavingDraft = Boolean(draft && hasDraftsEnabled(collectionConfig) && !publishAllLocales)
// ...
skipValidation: isSavingDraft && !hasDraftValidationEnabled(collectionConfig),
```

Since `draft` is `undefined`, `isSavingDraft` is `false`,
`skipValidation` is `false`, and full validation runs — rejecting the
duplicate when any required fields are empty.

### How?
By setting the `draft` value as `true` by default when it is not set
during a duplicate operation:

Fixes #15815

---------

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
2026-03-03 13:06:24 +00:00
Palamar Roman f0498f2c0f feat(richtext-lexical): separate configuration for lexical block icons (#15632)
This PR adds a new images property to BlockConfig, allowing developers
to specify separate images for different UI contexts:
- **images.icon** — small icon displayed in Lexical editor slash menus
and toolbars (20x20px)
- **images.thumbnail** — larger thumbnail shown in the block selection
drawer (3:2 aspect ratio)

## Why

Currently, `imageURL` serves dual purposes:
1. Block selection drawer thumbnails (3:2 aspect ratio, e.g., 480x320px)
2. Lexical editor icons (scaled down to 20x20px)

Citing documentation:

> Display Contexts:
> Block Selection Drawer: Images appear as thumbnails in a responsive
grid when editors add blocks
> Lexical Editor: Images are scaled down to 20x20px icons in menus and
toolbars

This creates problem , where developer needs to choose either good
looking icons (but worse UX on block drawers) or good looking thumnails,
but scaled down images as icons.

## Solution

Add a dedicated `images` property with a fallback chain:
```
Lexical editor icon: images.icon → images.thumbnail → imageURL (deprecated) → default BlockIcon
Block drawer thumbnail: images.thumbnail → imageURL (deprecated) → default placeholder
```
Now you can set different URL for block icons in lexical blocks:
<img width="562" height="370" alt="image"
src="https://github.com/user-attachments/assets/912e1482-0b37-40f7-a144-e21e43fc7aec"
/>

Example:

```ts
  const QuoteBlock: Block = {
    slug: 'quote',
    images: {
      icon: 'https://example.com/icons/quote-20x20.svg',
      thumbnail: { url: 'https://example.com/thumbnails/quote-480x320.jpg', alt: 'Quote block' },
    },
    fields: [...],
  }
  ```

## Backwards Compatibility

**Fully backwards compatible** — imageURL and imageAltText still work (marked as @deprecated), with automatic fallback to the new images property behavior.
2026-03-02 13:16:26 -08:00
Alessio Gravili f7d0d04bfb refactor!: rename widget ComponentPath to Component for consistency (#15780)
- Renames `Widget.ComponentPath` to `Widget.Component` and types it as
`PayloadComponent` instead of `string`
- This aligns dashboard widgets with every other component reference in
Payload (collections, globals, fields, admin components) - none of them
use _path_ in the property name, and all of them are typed as
`PayloadComponent`
- Enables new [typescript
plugin](https://github.com/payloadcms/payload/pull/15779) to work for
widget paths (the plugin uses `PayloadComponent` contextual type
detection - `string`-typed properties were invisible to it)

## Breaking Change

`Widget.ComponentPath` has been renamed to `Widget.Component`.

### Migration

Find and replace in your Payload config:

```diff
 widgets: [
   {
     slug: 'my-widget',
-    ComponentPath: './components/MyWidget#MyWidget',
+    Component: './components/MyWidget#MyWidget',
   },
 ]
```
2026-03-02 15:40:35 +00:00
Alessio Gravili 7639664030 feat: typescript plugin for import paths (#15779)
Adds `@payloadcms/typescript-plugin`, a TypeScript language service
plugin that validates PayloadComponent import paths in the IDE.


https://github.com/user-attachments/assets/2a8a8f73-a8fd-4e79-9dc4-795d817b5b75

It checks that referenced files and exports actually exist, provides
autocomplete for both file paths and export names, and supports
go-to-definition on component path strings. The plugin understands all
of Payload's path conventions including absolute paths relative to
baseDir, relative paths, tsconfig aliases, and package imports. It
auto-detects baseDir by finding the nearest payload config file and can
be overridden via tsconfig plugin options.
2026-02-27 14:15:12 -05:00
Patrik 9f0c101803 fix: getFieldsToSign crashes when user missing group/tab fields (#15775)
### What

Fix crash in JWT generation when user documents are missing group or tab
fields

### Why

When a user document was created before group/tab fields were added to
the schema, logging in would crash with "Cannot read properties of
undefined" because `getFieldsToSign` tried to traverse into undefined
fields.

### How

Added nullish coalescing (`?? {}`) to default missing group/tab data to
empty objects, consistent with how `afterRead/promise.ts` handles this
case.

Fixes #15734
2026-02-27 08:35:16 -08:00
Patrik 7a3f43ff51 fix: prevent silent data overwrites on concurrent edits (#15749)
### What

Adds stale data detection to prevent silent overwrites when multiple
users edit the same document.

### Why

Currently, when two users edit the same document, the last save wins
with no warning. User 1 can make changes, then User 2 saves, overwriting
User 1's work silently.

This is separate from document locking and addresses a specific
scenario:

1. Both users already have the same document open at the same time. 
2. User 1 makes changes and saves. 
3. User 2, still viewing their original version, then decides to make
edits.
4. Without this fix, User 2 would unknowingly overwrite User 1's changes
with no warning.

### How

Now we track the document's `updatedAt` timestamp when it initially
loads. On first edit, check if the database version has a newer
timestamp.

If stale, show a modal with the option to reload (get latest). The check
only happens once per edit session to avoid excessive DB queries.

<img width="693" height="360" alt="Screenshot 2026-02-24 at 1 37 01 PM"
src="https://github.com/user-attachments/assets/1d63973c-fa40-4213-8718-9ea739f267c9"
/>

Fixes #15486
2026-02-26 06:47:33 -08:00
Benedikt Rötsch 6557292b3f fix: preserve block metadata in mergeLocalizedData and filterDataToSelectedLocales (#15715)
## Summary

- Fixes `mergeLocalizedData` dropping `blockType`, `id`, and `blockName`
from non-localized blocks when the existing document has no blocks yet
(e.g. after an initial autosave before blocks were added)
- Fixes the same class of bug in `filterDataToSelectedLocales`
- Not specific to `localizeStatus: true` — affects any collection using
`publishSpecificLocale` with versioned blocks + autosave

Supersedes #15659 — the bug actually exists in **two spots**: both
`filterDataToSelectedLocales` (which #15659 addresses) **and**
`mergeLocalizedData` (which #15659 misses). This PR fixes both, using
direct property assignment instead of object spread for less overhead.

## Root cause

In both `mergeLocalizedData` and `filterDataToSelectedLocales`, the
recursive calls for non-localized blocks only iterate `block.fields`.
But `blockType`, `id`, and `blockName` are internal metadata not present
in `block.fields`. When `existingValue` is `undefined` (main doc has no
blocks because autosave only wrote to the versions table), `blockData`
falls back to `{}`, and the recursive call starts with an empty object —
metadata is lost.

When `existingValue` already has blocks, `blockData` contains
`blockType` from the spread and the bug doesn't trigger.

## Fix

After each recursive `mergeLocalizedData`/`filterDataToSelectedLocales`
call for blocks, explicitly reattach `blockType`, `id`, and `blockName`
from the incoming data. Direct property assignment (no extra object
spread) for minimal overhead.

## Related

- #15655 / #15659 — same bug class in `filterDataToSelectedLocales`
(this PR includes that fix with a more performant approach)
- #15642 / #15658 — sibling bug in `mergeLocalizedData` (unnamed groups
losing data)

## Test plan

- [x] Unit tests for `mergeLocalizedData` — 3 new tests covering empty
existing doc, undefined block field, and happy path
- [x] Unit tests for `filterDataToSelectedLocales` — new spec file with
5 tests covering block metadata preservation
- [x] Integration test — creates doc without blocks, adds blocks,
publishes with `publishSpecificLocale`, verifies `blockType` and `id`
survive
- [x] E2E test — same scenario through the admin UI + API

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: German Jablonski <43938777+GermanJablo@users.noreply.github.com>
2026-02-26 13:23:42 +00:00
The Mavik 4861fa10bc fix: return 400 for malformed JSON request bodies (#15706)
Fixes #15635

Wraps `JSON.parse` in `addDataAndFileToRequest` with error handling to
return 400 Bad Request for malformed JSON bodies instead of an unhandled
500 Internal Server Error.

---------

Co-authored-by: Alessio Gravili <github@gravili.net>
2026-02-25 21:58:29 +00:00
Patrik df17cb18e1 fix: globals not updating updatedAt when saving drafts (#15764)
### What

When saving drafts, version documents for globals weren't getting
updated `updatedAt` timestamps.

### Why

The condition in `saveVersion.ts` only checked `collection?.timestamps`,
which excluded globals even though they always have timestamps.

Broke stale data detection which relies on timestamp comparison to
detect concurrent edits.

### How

Added `|| global` check in `saveVersion.ts` since globals always have
timestamps but were excluded from the original `collection?.timestamps`
condition.
2026-02-25 13:33:20 -08:00
Alessio Gravili c05ace22b3 fix(richtext-lexical): strip server-only properties from blocks in lexical client schema map (#15756)
Fixes #15509

When a block used in `BlocksFeature` contains a nested `blocks` field
with `blockReferences`, the admin panel crashes with "Functions cannot
be passed directly to Client Components". This happens because the
richText schema map conversion in `buildClientFieldSchemaMap` treats all
entries as `Field` objects via an unsafe `as Field` cast. Block entries
(which have `slug` but no `type`) pass through `createClientField` which
doesn't understand them, causing `flattenedFields` - containing
server-only properties - to be copied to the client.

The fix replaces the unsafe cast with type-safe union discrimination on
the `FieldSchemaMap` value type (`Block | Field | Tab | { fields }`)
using `in` narrowing. Block entries are now routed through
`createClientBlocks` which correctly strips server-only properties,
while Field and fields-wrapper entries continue through
`createClientFields` as before.
2026-02-25 16:43:19 +00:00
German Jablonski 0a123b51f2 feat(next, ui): widget fields (#15700)
## Summary

- Adds `fields` support to dashboard widgets, analogous to how Blocks
work — widgets can now declare configurable fields
- Widget data is editable from a new drawer UI (edit icon) when in
dashboard editing mode
- Full type generation: `WidgetInstance<T>` is now generic with typed
`data` and `width` based on the widget's field schema and min/max width
constraints
- `WidgetServerProps` is generic so widget components receive typed
`widgetData`

## Usage

```ts
import { buildConfig } from 'payload'

export default buildConfig({
  admin: {
    dashboard: {
      widgets: [
        {
          slug: 'sales-summary',
          ComponentPath: './components/SalesSummary.tsx#default',
          fields: [
            { name: 'title', type: 'text' },
            {
              name: 'timeframe',
              type: 'select',
              options: ['daily', 'weekly', 'monthly', 'yearly'],
            },
            { name: 'showTrend', type: 'checkbox' },
          ],
          minWidth: 'small',
          maxWidth: 'medium',
        },
      ],
    },
  },
})
```

```tsx
import type { WidgetServerProps } from 'payload'

import type { SalesSummaryWidget } from '../payload-types'

export default async function SalesSummaryWidgetComponent({
  widgetData,
}: WidgetServerProps<SalesSummaryWidget>) {
  const title = widgetData?.title ?? 'Sales Summary'
  const timeframe = widgetData?.timeframe ?? 'monthly'

  return (
    <div className="card">
      <h3>
        {title} ({timeframe})
      </h3>
    </div>
  )
}
```

## Demo



https://github.com/user-attachments/assets/b24d3635-8635-4d5f-84af-a6dcf801aa4f
2026-02-25 13:33:57 +00:00
Paul 8791a726d6 templates: fix cloudflare logger error in with-cloudflare-d1 template (#15752)
This PR fixes the logging errors caused in CF Workers environment by
pino. Closes https://github.com/payloadcms/payload/issues/14849

It also exports `PayloadLogger` as a type to be re-used in a follow up
PR.
2026-02-24 21:38:18 +00:00
Jarrod Flesch 23d52a0d7d fix: return early if pasteURL is not defined (#15748)
Returns a proper error message when pasteURL is not defined.
2026-02-24 18:44:23 +00:00
Jens Becker 2baea2e070 fix: prevent req.file leak between sequential duplicate() calls on upload collections (#15620)
### What?

Prevents `req.file` from leaking between sequential
`payload.duplicate()` calls when a shared `req` object is used for
upload collections.

### Why?

When calling `payload.duplicate()` multiple times on the same `req`
object (e.g. in batch operations within a transaction), only the first
document's file was used for all subsequent duplications.
`generateFileData` writes the processed file back to `req.file`, and on
the next call `let file = req.file` was truthy, so the block that
fetches the correct source file from storage was skipped entirely.

### How?

When `isDuplicating` is true, `req.file` is now ignored so the source
file is always fetched fresh from storage for each duplicate operation.
This is a one-line change in `generateFileData.ts`:

```diff
- let file = req.file
+ let file = isDuplicating ? undefined : req.file
```

An integration test was added that creates two upload documents with
different files, duplicates both using a shared `req`, and asserts each
duplicate derives its filename from the correct source document.

Fixes #15619
2026-02-24 12:40:39 -05:00
Jessica Rynkar 45bd2f1786 fix: sanitize filenames in storage adapters (#15746)
### What
Adds filename + prefix sanitization to all storage adapters (S3, GCS,
Azure, R2).

### Why
Storage adapter uploads pass filenames directly to
`path.posix.join(prefix, filename)` without sanitization. Regular
uploads don't have this issue because they go through
`generateFileData`, which already sanitizes filenames via the
`sanitize-filename` package. This brings storage adapter filename
handling in line with the rest of the upload pipeline.

### How
Exported `sanitizeFilename` from `payload` to reuse in the storage
adapters.

#### Testing
Added tests to `test/storage-s3/clientUploads.int.spec.ts`
2026-02-24 17:34:48 +00:00
Jessica Rynkar 08226db60c fix: throw error for unknown query operators (#15739)
### What
Ensures unknown query operators are properly rejected during query
validation.

### Why
Previously, unrecognized operators in where clauses were silently
ignored. This could lead to unexpected behavior. Query validation should
fail-closed and only accept known operators.

### How
Added an `else` in validateQueryPaths.ts to push an error for any
operator not in the valid operator set.

Test added to `joins > int`
2026-02-24 14:54:50 +00:00
Jarrod Flesch 38b8c682a4 fix: preserve locale data in unnamed groups with localizeStatus (#15658) 2026-02-20 16:20:54 -05:00
Kendell a66e844a07 fix(plugin-mcp): use inline block schemas in JSON output (#15675)
Blocks were previously emitted as `$ref` references in the JSON Schema.
This caused MCP tools to reject block field values during schema
validation.

Fixes #15399
2026-02-19 10:50:59 -05:00
Paul 2347cd9e9d 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>
2026-02-19 14:47:41 +00:00
Patrik 8ce389e994 refactor: extract regex escape pattern into shared utility (#15676)
### What

Extracted duplicate regex escape logic into a shared `escapeRegExp`
utility.

### Why

The same regex escape pattern `.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&')`
was duplicated across multiple locations.

### How

- Created `packages/payload/src/utilities/escapeRegExp.ts`
- Exported from payload package
- Replaced 5 inline occurrences with utility calls
2026-02-18 12:09:17 -08:00
Sasha 0935824011 feat(db-*): add customID arg to db.create (#15653)
This PR adds `customID` argument to `payload.db.create` which can be
used to ensure that a document is created with the provided ID, for
example:
```ts
payload.db.create({ collection: 'posts', customID: 'ce98d6c4-c3ab-45de-9dfc-bf33d94cc941', data: { } })
```
This does not require to have a custom ID field in `posts` collection,
as the following:
```ts
payload.db.create({ collection: 'posts', data: { customID: 'ce98d6c4-c3ab-45de-9dfc-bf33d94cc941' } })
```
Can be used only when you define a custom ID field
2026-02-17 20:45:13 +02:00
Philipp Schneider 9a38469b59 feat: pass local API depth through to req.query.depth for consistency (#15023)
Motivation: Consistency of the local API with the `req` parameter of
requests made via the Rest / GraphQL API.

Before this change, if hooks relied on `req.query.depth`, `req: {query:
{depth: x}}` would have to be manually passed to local API calls such as
`payload.find({})`, in addition to `depth: x`. This was easy to forget,
in which case the local API calls would result in different outcomes
than if the same operation was triggered via Rest / GraphQL. This was
confusing.

---------

Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
2026-02-17 14:01:33 +00:00
Jessica Rynkar 1cc3bb93bc fix: populate previousValue correctly in afterChange hooks for nested lexical fields (#15623)
## What
Fixes `previousValue` being `undefined` in `afterChange` hook for nested
Lexical fields.

## Why
We had this issue previously where the wrong `previousValue` data was
being accessed with nested fields, this introduced the fix of
`previousValData` which properly scopes to sibling-level or full
document data.

When the previous fix was done, the `previousValue` in the rich text
case here was missed.

## How
Changed the `richText` case in
`packages/payload/src/fields/hooks/afterChange/promise.ts` to use the
same `previousValData` pattern as other field types:

```diff
- previousValue: previousDoc?.[field.name],
+ previousValue: getNestedValue(previousValData, pathSegments) ?? previousValData?.[field.name],
```

### Testing
Added to `Lexical > lexical.int.spec.ts`
2026-02-13 23:24:50 +00:00
Kendell 6937eec1f6 fix: use optional chaining for adminThumbnail size lookup to prevent crash (#15586)
Fixes issue with thumbnail lookups for documents without sizes.

Introduced: https://github.com/payloadcms/payload/pull/15232
2026-02-10 15:50:16 -05:00
Jessica Rynkar a313627a12 fix: non-image files should not recieve 0 bytes with useTempFiles (#15538)
## What?

Fixes non-image files (PDF, TXT, MP3, MP4, etc.) being saved with 0
bytes when `useTempFiles: true` is enabled in the upload config.

## Why?

With `useTempFiles: true`, upload data is written to disk instead of
memory, leaving `file.data` empty. Image uploads work because Sharp
reads from `file.tempFilePath` and outputs a buffer, but non-image files
skip this and relied on `file.data`, resulting in 0-byte files being
saved.

## How?

In `generateFileData.ts` we now conditionally determine the
`bufferToSave` instead of relying on just `file.data`.

#### Testing
Test added to `payload-cloud > int`

Fixes #11832
2026-02-10 08:36:42 -05:00
Jessica Rynkar 8283c25f02 fix: add CSP headers to SVG uploads to prevent XSS (#15506)
### What?
Adds `Content-Security-Policy` headers to all `SVG` uploads, in Payload
core and storage adapters, to prevent 'SVG smuggling' (executable code
secretly embedded in the file).

### Why?
We received a report via email raising the concern that SVG files
containing embedded JavaScript could be uploaded and executed in the
admin panel.

Common approaches in similar frameworks are:
- Blocking SVG uploads entirely, which is highly restrictive
- Leaving enforcement to the user, requires full manual configuration

Payload already includes SVG validation via `validateSvg()` which strips
known malicious patterns. This change adds the CSP header as an
additional layer of defense. It isn’t strictly required, but it is a
simple addition that provides extra protection and has no real downside,
beyond this, handling and validating SVGs remains the user’s
responsibility.

**Note**: Users who need to serve interactive SVGs can override this
behavior using the `modifyResponseHeaders` option in their upload
collection config.

### How?
- Detects SVGs and conditionally adds CSP header in `uploads/getFile.ts`
- Same logic added to all storage adapter `staticHandler.ts` files

#### Testing
Added to `uploads > int`

#### Acknowledgement
Reported by: Dominic Breuker @ gematik
2026-02-10 08:36:05 -05:00
Paul 73a96501b7 fix(plugin-import-export): errors when import/export files were stored in a storage adapter such as S3 (#15441)
Relies on https://github.com/payloadcms/payload/pull/15405 first

When using a storage adapter such as S3 to handle files there were
errors in the way the files would be fetched for imports.

This PR fixes that, adding test coverage and changes the job behaviour
so that it only stores the import doc ID and not the full file data -
this avoids many problems down the line and optimises memory and storage
usage.

Fixes https://github.com/payloadcms/payload/issues/15247

Previously attempting to use an import with the jobs queue with S3
storage would result in this error:

```text
[11:46:25] ERROR: Failed to queue import job for document 698628f19aaca7d4f4f18c6e
    err: {
      "type": "Error",
      "message": "ENOENT: no such file or directory, open '/posts-with-s3-import/2026-02-06 114542.csv'",
      "stack":
          Error: ENOENT: no such file or directory, open '/posts-with-s3-import/2026-02-06 114542.csv'
```

Also:
- Exports `getExternalFile` utility from `payload/internals`
2026-02-06 19:51:37 +00:00
Alessio Gravili 61584898e3 fix(next): suppress webpack "Critical dependency" warning in dynamicImport (#15534)
The Vitest-only branch in `dynamicImport` uses a bare
`import(importPath)` which webpack still statically analyzes, even
though it's behind a `process.env.VITEST` condition. This could cause a
`"Critical dependency: the request of a dependency is an expression"`
warning in Next.js projects. Adding `/* webpackIgnore: true */` should
fix this.

Additionally, this hides another sass deprecation warning
2026-02-05 19:29:30 +00:00
sanbrien 5ca9bd464f fix: handle absolute paths correctly with tempFileDir upload option (#14436)
### What?

This PR fixes `tempFileDir` configuration not respecting absolute paths
when `useTempFiles` is enabled for uploads.

### Why?

1. **Absolute paths ignored**: Setting `tempFileDir: '/tmp'` would still
create temp files under the working directory (e.g.,
`/Users/project/tmp/...`) instead of `/tmp/...`

2. **Path duplication**: The default `tempFileDir` could result in
duplicated working directory segments in the path

### How?
Uses `path.resolve()` to handle both cases:
- Absolute paths (e.g., `/tmp`) are returned as-is
- Relative paths (e.g., `tmp`) are resolved against `process.cwd()`

Fixes #12910

---------

Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
2026-02-05 17:08:36 +00:00
Neha Prasad fa1cd62a52 fix: ungenerated image sizes should not store original URL (#15454)
### What?

Fixes image sizes storing the original document URL in the database when
the size could not be generated (should be `null` instead).

### Why?

When an uploaded image is smaller than a defined image size (or cannot
be resized, e.g., SVG), Payload correctly sets `filename`, `width`,
`height`, `mimeType`, and `filesize` to `null`. However, the `url` field
was incorrectly falling back to the original image URL.

For background, there was a previous issue where the URL for generated
sizes was always being set to the main URL. This was resolved, but still
left the main URL as a fallback - really this should have been
completely removed.

### How?

Removed the fallback to the original filename in the `beforeChange` hook
for image size URL fields. Now when a size cannot be generated, the URL
is `null` (consistent with the other size fields).

Fixes:  #15450

---------

Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
2026-02-05 13:43:34 +00:00
Jarrod Flesch f23a1dffe8 feat: adds beforeNav and afterNav component slots (#15493)
Allows custom components to be slotted in before the nav, and not
specifically beforeNavLinks. `beforeNavLinks` semantically means before
the links themselves, but for a PR like
https://github.com/payloadcms/payload/pull/15470, we will want to be
able to place things outside of the nav links section. Think about the
multi-tenant selector, you would not want that visible in just the first
tab, but outside of the tabs - i.e. before the nav.
2026-02-03 13:31:09 -05:00
Jarrod Flesch 8422668f58 chore: replace magic strings with centralized constants (#15475)
Replaces hardcoded preference key strings with centralized constants to
prevent typos and improve maintainability. Creates PREFERENCE_KEYS
object in packages/payload/src/preferences/keys.ts and updates all
usages throughout the codebase.

I have another PR that sets preferences and figured it would be nice if
we had a centralized place for the pref constants.

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 10:01:35 -05:00
Jessica Rynkar 1041bb646b fix: add safety check to redirects from external file URL uploads (#15458)
### What
Fixes an `uploads` related security issue where redirects are able
bypass URL validation in external file uploads.

### Why
When uploading files via an external URL, HTTP redirects (3xx responses)
were automatically followed. This meant an potential attacker could
provide a public URL that redirected to internal services, bypassing the
security checks that only ran on the initial URL.

### How
- Added `redirect: 'manual'` to `safeFetch()` to prevent automatic
redirect following
- Modified `getExternalFile()` to manually handle redirects with
re-validation at each step
- Limited redirects to maximum of 3x to prevent infinite loops
- Tests added to `uploads` test suite


#### Acknowledgement
Big thank you to `r3dbrothers1` for the responsible disclosure.
2026-02-03 14:39:09 +00:00
Jarrod Flesch 6c07f3b946 fix(next): export SlugField from the client dir not rsc (#15461)
Fixes https://github.com/payloadcms/payload/issues/15451.

Changes introduced in https://github.com/payloadcms/payload/pull/15392
moved the `SlugField` from client to server exports, throwing the
following runtime error when navigating to the edit view:

```
useServerFunctions must be used within a ServerFunctionsProvider
```

---------

Co-authored-by: Jake <jacobsfletch@gmail.com>
2026-02-02 14:30:11 +00:00
Alessio Gravili 9c8be5cc96 perf(next): avoid re-calculating permissions in some server functions, pass missing args (#15428)
Requires https://github.com/payloadcms/payload/pull/15427 to be merged
first.

This PR avoids re-calculating permissions in certain server functions,
since through #15427, they are now available in server functions due to
an `initReq` that we already ran.

It also passes some missing properties to `serverProps` and render
handles that are now available.
2026-01-30 17:01:11 -05:00
Alessio Gravili d6aa6ccb55 fix: add collection property to auth documents, fixing multi tenancy access control issue (#15404)
This PR ensures that all documents returned from auth-enabled
collections via local API operations include the `collection` property,
which is essential for multi-tenant access control to function
correctly.

## Motivation

A common pattern in Payload applications is to fetch a user from the
database and pass it to subsequent operations:

```typescript
const sean = (await payload.find({
  collection: 'users',
  where: { email: { equals: 'sean@payloadcms.com' } }
})).docs[0]

const result = await payload.find({
  collection: 'diaries',
  overrideAccess: false,
  user: sean, // pass fetched user
})

// result.docs[0]: { title: "sean's diary" }
// result.docs[1]: { title: "jarrod's journal" } // Oh no! This is private!
```

This introduces a security vulnerability. The local API expects
`user.collection` to be present, but user documents returned by
`find`/`findByID` did not include this property. Since the `user`
parameter is loosely typed, TypeScript does not catch this error.

In the `@payloadcms/plugin-multi-tenant` plugin, `useTenantAccess`
checks `req.user.collection` to apply tenant-based access control. This
is not an issue within plugin-multi-tenant, because the Payload types
mark `req.user.collection` as required - thus it's reasonable to expect
this property to be there. When `collection` is `undefined`, the
condition `args.req.user.collection === adminUsersSlug` always evaluates
to `false`, bypassing tenant access restrictions.

## Changes

The `collection` property is now injected into auth collection documents
immediately after data is fetched from the database in all collection
operations (`find`, `findByID`, `create`, `update`, `delete`, etc.).
This makes it available throughout all operation hooks.

Type generation has been updated to include `collection` directly in
auth collection schemas, producing cleaner types where `User` contains
`collection: 'users'` without intersection types. Input types have been
updated to make `collection` optional since it's auto-populated.

As a fallback, `createLocalReq` now sets `user.collection` to the
default admin user collection if missing - this is an additional
safeguard. A TODO has been added to throw an error instead in version
4.0. We cannot throw an error now, as this would be a breaking change.

[[Link to internal Slack
discussion]](https://payloadcms.slack.com/archives/C049BR3QBHC/p1769593583792739)
2026-01-30 14:05:01 -05:00