Compare commits

..

59 Commits

Author SHA1 Message Date
Jacob Fletcher
a6f6f4ae56 keeps old autosave 2025-08-12 08:05:13 -04:00
Jacob Fletcher
0ffe20d35c fix 2025-08-12 08:00:59 -04:00
Jacob Fletcher
f29407bb4e chore: temp revert 2bc9a2d 2025-08-12 07:59:59 -04:00
Jacob Fletcher
2bc9a2def4 fix(templates): only generate slug from title on demand (#12956)
Currently, the slug field is generated from the title field
indefinitely, even after the document has been created and the initial
slug has been assigned. This should only occur on create, however, as it
currently does, or when the user explicitly requests it.

Given that slugs often determine the URL structure of the webpage that
the document corresponds to, they should rarely change after being
published, and when they do, would require HTTP redirects, etc. to do
right in a production environment.

But this is also a problem with Live Preview which relies on a constant
iframe src. If your Live Preview URL includes the slug as a route param,
which is often the case, then changing the slug will result in a broken
connection as the queried document can no longer be found. The current
workaround is to save the document and refresh the page.

Now, the slug is only generated on initial create, or when the user
explicitly clicks the new "generate" button above the slug field. In the
future we can evaluate supporting dynamic Live Preview URLs.

Regenerating this URL on every change would put additional load on the
client as it would have to reestablish connection every time it changes,
but it should be supported still. See #13055.

Discord discussion here:
https://discord.com/channels/967097582721572934/1102950643259424828/1387737976892686346

Related: #10536
2025-08-11 17:12:43 -04:00
Jacob Fletcher
1d81b0c6dd fix(ui): autosave hooks are not reflected in form state (#13416)
Fixes #10515. Needed for #12956.

Hooks run within autosave are not reflected in form state.

Similar to #10268, but for autosave events.

For example, if you are using a computed value, like this:

```ts
[
  // ...
  {
    name: 'title',
    type: 'text',
  },
  {
    name: 'computedTitle',
    type: 'text',
    hooks: {
      beforeChange: [({ data }) => data?.title],
    },
  },
]
```

In the example above, when an autosave event is triggered after changing
the `title` field, we expect the `computedTitle` field to match. But
although this takes place on the database level, the UI does not reflect
this change unless you refresh the page or navigate back and forth.

Here's an example:

Before:


https://github.com/user-attachments/assets/c8c68a78-9957-45a8-a710-84d954d15bcc

After:


https://github.com/user-attachments/assets/16cb87a5-83ca-4891-b01f-f5c4b0a34362

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210561273449855
2025-08-11 16:59:03 -04:00
German Jablonski
9c8f3202e4 fix(richtext-lexical, plugin-multi-tenant): text editor exposes documents from other tenants (#13229)
## What

Before this PR, an internal link in the Lexical editor could reference a
document from a different tenant than the active one.

Reproduction:
1. `pnpm dev plugin-multi-tenant`
2. Log in with `dev@payloadcms.com` and password `test`
3. Go to `http://localhost:3000/admin/collections/food-items` and switch
between the `Blue Dog` and `Steel Cat` tenants to see which food items
each tenant has.
4. Go to http://localhost:3000/admin/collections/food-items/create, and
in the new richtext field enter an internal link
5. In the relationship select menu, you will see the 6 food items at
once (3 of each of those tenants). In the relationship select menu, you
would previously see all 6 food items at once (3 from each of those
tenants). Now, you'll only see the 3 from the active tenant.

The new test verifies that this is fixed.

## How

`baseListFilter` is used, but now it's called `baseFilter` for obvious
reasons: it doesn't just filter the List View. Having two different
properties where the same function was supposed to be placed wasn't
feasible. `baseListFilter` is still supported for backwards
compatibility. It's used as a fallback if `baseFilter` isn't defined,
and it's documented as deprecated.

`baseFilter` is injected into `filterOptions` of the internal link field
in the Lexical Editor.
2025-08-07 11:24:15 -04:00
Rodrigo Antunes
161769e50c chore(templates): fix typo in e2e tests (headging -> heading) (#13391)
### What?

Small typo in templates e2e tests: `headging`
2025-08-07 15:10:45 +00:00
Sasha
c9a1590fc4 fix(ui): search in select fields with filterOptions (#13397)
Fixes https://github.com/payloadcms/payload/issues/13236
2025-08-07 09:57:56 -04:00
Alessio Gravili
e870be094e docs: add dependency troubleshooting docs (#13385)
Dependency mismanagement is the #1 cause of issues people have with
payload. This PR adds a details docs section explaining why those issues
occur and how to fix them.

**[👀 Click here for docs
preview](https://payloadcms.com/docs/dynamic/troubleshooting/troubleshooting?branch=docs/dependencies)**
2025-08-06 11:19:32 -04:00
Jacob Fletcher
d4f198651c fix(next): group by boolean values (#13382)
When grouping by a checkbox field, boolean values are not translated,
causing labels to render incorrectly, and falsey values to render
without a heading.


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210979856538211
2025-08-05 20:07:29 -04:00
Yi Chi
5d8f8dc0a5 fix(translations): update traditional chinese (zh-TW) localization (#13370)
A comprehensive revision has been made to correct the majority of
localization translation errors. Previous versions were often
grammatically incorrect and awkward. This update includes a one-time
overhaul to improve grammar, vocabulary, and fix a few terms that were
written in Simplified Chinese.

Since a large number of translated terms have been corrected, it is
recommended to use GitHub's Files changed feature to review the diffs
directly.

This Pull Request only modifies translation content; no other code
changes have been made.


<!--

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 #

-->
2025-08-05 20:12:58 +00:00
Jacob Fletcher
7344d64be3 fix(next): group by dates with null values (#13381)
When grouping by a date field and its value is null, the list view
crashes.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210979856538208
2025-08-05 15:54:55 -04:00
Sam
2211f3dd1c fix: use thumbnailUrl for upload documents in folder view (#13368)
### What?
Fix the folder view for upload documents only using
`formatFolderOrDocumentItem()` function and only if the upload is an
image, even when there's a `thumbnailURL` available.

### Why?
Folder view for upload collections (especially those with sharp resizing
disabled) renders different thumbnails between the folder view and list
view. With sharp resizing disabled and an `adminThumbnail` fn provided,
the list view will correctly render optimised images, while the folder
view renders full source images - resulting in a huge discrepancy in
loaded image sizes.

### How?
We're passing the `value.thumbnailURL` **before** the
`formatFolderOrDocumentItem()` call rather than passing it directly as a
function parameter to cover cases where non-image uploads have a
`thumbnailURL` defined.

Fixes #13246
2025-08-05 13:39:59 -04:00
Elliot DeNolf
ac40185158 ci: update release-commenter permissions (#13373)
Permissions for release commenter needed to be explicit.

Docs from upstream:
https://github.com/apexskier/github-release-commenter/pull/549
2025-08-05 10:00:42 -04:00
Elliot DeNolf
d622d3c5e7 chore(release): v3.50.0 [skip ci] 2025-08-05 09:33:56 -04:00
Jessica Rynkar
b74f4fb9b2 fix(ui): fallback to default locale checkbox passes wrong value (#12396)
### What?
Allows document to successfully be saved when `fallback to default
locale` checked without throwing an error.

### Why?
The `fallback to default locale` checkbox allows users to successfully
save a document in the admin panel while using fallback data for
required fields, this has been broken since the release of `v3`.

Without the checkbox override, the user would be prevented from saving
the document in the UI because the field is required and will throw an
error.

The logic of using fallback data is not affected by this checkbox - it
is purely to allow saving the document in the UI.

### How?
The `fallback` checkbox used to have an `onChange` function that
replaces the field value with null, allowing it to get processed through
the standard localization logic and get replaced by fallback data.
However, this `onChange` was removed at some point and the field was
passing the actual checkbox value `true`/`false` which then breaks the
form and prevent it from saving.

This fallback checkbox is only displayed when `fallback: true` is set in
the localization config.
This PR also updated the checkbox to only be displayed when `required:
true` - when it's the field is not `required` this checkbox serves no
purpose.

Also adds tests to `localization/e2e`.

Fixes #11245

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2025-08-05 09:29:10 -04:00
Patrik
8401b2166d fix: deletedAt in TypeWithID not accepting null from generated types (#13363)
### What?

Updated `TypeWithID` so `deletedAt` can accept `null`.

### Why?

Generated collection types for trash use:

```
deletedAt?: string | null
```

`TypeWithID` previously only allowed `string | undefined`, so assigning
documents with `deletedAt: null` caused TypeScript errors.

Aligning the types fixes this mismatch and ensures `TypeWithID` is
compatible with the generated types.

### How?

Modified the `TypeWithID` definition to:

```
export type TypeWithID = {
  deletedAt?: string | null
  docId?: any
  id: number | string
}
```

This makes `deletedAt` effectively `string | null | undefined`, matching
generated types and eliminating type errors.

Fixes #13341
2025-08-05 09:01:01 -04:00
Jarrod Flesch
20b4de94ee fix(plugin-multi-tenant): constrain results to assigned tenants when present (#13365)
Extension of https://github.com/payloadcms/payload/pull/13213

This PR correctly filters tenants, users and documents based on the
users assigned tenants if any are set. If a user is assigned tenants
then list results should only show documents with those tenants (when
selector is not set). Previously you could construct access results that
allows them to see them, but in the confines of the admin panel they
should not see them. If you wanted a user to be able to see a "public"
tenant while inside the admin panel they either need to be added to the
tenant or have no tenants at all.

Note that this is for filtering only, access control still controls what
documents a user has _access_ to a document. The filters are and always
have been a way to filter out results in the list view.
2025-08-05 09:00:36 -04:00
Jarrod Flesch
43b4b22af9 fix: svg uploads allowed when glob (#13356)
Builds on top of https://github.com/payloadcms/payload/pull/13276 and
allows images/* to also parse and accept svg files correctly.
2025-08-04 23:47:37 -04:00
Jarrod Flesch
3b9dba8641 chore: adds session helper exports (#13367)
Creates/exports `addSessionToUser` helper and also exports the
`removeExpiredSessions` helper.
2025-08-04 22:53:36 -04:00
Alessio Gravili
1d70d4d36c fix(next): version view did not properly handle field-level permissions (#13336)
Field-level permissions were not handled correctly at all. If you had a
field set with access control, this would mean that nested fields would
incorrectly be omitted from the version view.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210932060696919
2025-08-02 12:01:48 -07:00
Jens Becker
1b31c74d32 perf(ui): only select the useAsTitle field when fetching the document data of a relationship value (#13228)
### What?
Optimize the relationship value loading by selecting only the
`useAsTitle` field when fetching document data via the REST API.

### Why?
Previously, all fields were fetched via a POST request when loading the
document data of a relationship value, causing unnecessary data transfer
and slower performance. Only the `useAsTitle` field is needed to display
the related document’s title in the relationship UI field.

### How?
Applied a select to the REST API POST request, similar to how the
options list is loaded, limiting the response to the `useAsTitle` field
only.
2025-08-01 16:18:21 -04:00
Sasha
f432cc1956 feat(graphql): allow to pass count: true to a join query (#13351)
Fixes https://github.com/payloadcms/payload/issues/13077
2025-08-01 18:05:54 +03:00
Jarrod Flesch
2903486974 fix(ui): group/array error paths persisting when valid (#13347)
Fields such as groups and arrays would not always reset errorPaths when
there were no more errors. The server and client state was not being
merged safely and the client state was always persisting when the server
sent back no errorPaths, i.e. itterable fields with fully valid
children. This change ensures errorPaths is defaulted to an empty array
if it is not present on the incoming field.

Likely a regression from
https://github.com/payloadcms/payload/pull/9388.

Adds e2e test.
2025-08-01 16:04:51 +01:00
Jarrod Flesch
b965db881e fix(ui): add hidden sidebar fields css specificity (#13344)
Adds specificity to sidebar no fields css styling.
2025-07-31 08:48:38 -04:00
Jarrod Flesch
1b93c4becc fix(ui): hide sidebar when no fields rendered (#13340) 2025-07-30 23:59:46 -04:00
Jarrod Flesch
9031f3bf23 feat: add hooks to restoreVersion collection operation (#13333)
Adds missing hooks to the restoreVersion operation.
- beforeOperation
- beforeValidate - Fields
- beforeValidate - Collection
- beforeChange - Collection
- beforeChange - Fields
- afterOperation
2025-07-30 21:25:53 -04:00
Muhammad Nizar
df91321f4a feat(translations): add Indonesian translations (#13323)
### What?
Translated payload to Indonesian

### Why?
I needed to have payload in Indonesian

### How?
By following the
[readme](https://github.com/payloadcms/payload/blob/main/packages/translations/README.md)
2025-07-30 21:35:22 +00:00
Patrik
11755089f8 feat: adds trash support to the count operation (#13304)
### What?

- Updated the `countOperation` to respect the `trash` argument.

### Why?

- Previously, `count` would incorrectly include trashed documents even
when `trash` was not specified.
- This change aligns `count` behavior with `find` and other operations,
providing accurate counts for normal and trashed documents.

### How?

- Applied `appendNonTrashedFilter` in `countOperation` to automatically
exclude soft-deleted docs when `trash: false` (default).
- Added `trash` argument support in Local API, REST API (`/count`
endpoints), and GraphQL (`count<Collection>` queries).
2025-07-30 14:11:11 -07:00
Patrik
a8b6983ab5 test: adds e2e tests for auth enabled collections with trash enabled (#13317)
### What?
- Added new end-to-end tests covering trash functionality for
auth-enabled collections (e.g., `users`).
- Implemented test cases for:
  - Display of the trash tab in the list view.
  - Trashing a user and verifying its appearance in the trash view.
  - Accessing the trashed user edit view.
  - Ensuring all auth fields are properly disabled in trashed state.
  - Restoring a trashed user and verifying its status.

### Why?
- To ensure that the trash (soft-delete) feature works consistently for
collections with `auth: true`.
- To prevent regressions in user management flows, especially around
disabling and restoring trashed users.

### How?
- Added a new `Auth enabled collection` test suite in the E2E `Trash`
tests.
2025-07-30 14:11:02 -07:00
Jarrod Flesch
f2d4004237 fix(ui): incorrect padding when using rtl (#13338)
Fixes inconsistent padding in the doc view for RTL viewing.

###  Before

#### Desktop
<img width="1331" height="310" alt="CleanShot 2025-07-30 at 16 37 30"
src="https://github.com/user-attachments/assets/48d3e127-09dd-4356-99ae-16fe47817937"
/>

#### Mobile
<img width="619" height="328" alt="CleanShot 2025-07-30 at 16 37 52"
src="https://github.com/user-attachments/assets/36169ca5-c1a2-4175-a6e1-f0a4784d5e9e"
/>


###  After

#### Desktop
<img width="1675" height="291" alt="CleanShot 2025-07-30 at 16 39 18"
src="https://github.com/user-attachments/assets/1da78a8a-b236-4f95-9eb2-8b5055b676ae"
/>

#### Mobile
<img width="531" height="309" alt="CleanShot 2025-07-30 at 16 39 30"
src="https://github.com/user-attachments/assets/af858bfc-6d75-4139-ada1-4f8100744bfb"
/>
2025-07-30 21:03:03 +00:00
Alessio Gravili
8a489410ad fix(next): incorrect version view stepnav value (#13305)
Previously, the version view stepnav incorrectly displayed the version
ID instead of the parent document ID in the navigation. It also
incorrectly pulled the field value if `useAsTitle` was set, displayed
the `[undefined]` instead.

**Edit View:**
<img width="2218" height="244" alt="Screenshot 2025-07-28 at 16 55
26@2x"
src="https://github.com/user-attachments/assets/7b144480-ec5a-4592-b603-09e1e35bd558"
/>

## Version View - Before:

<img width="2280" height="378" alt="Screenshot 2025-07-28 at 16 56
02@2x"
src="https://github.com/user-attachments/assets/8b79bab3-a79b-4930-ade1-2da450f00fc7"
/>


## Version View - After:

**Version View:**
<img width="2222" height="358" alt="Screenshot 2025-07-28 at 16 55
46@2x"
src="https://github.com/user-attachments/assets/bb0fffbb-c3e6-419f-ad72-5731e85059cc"
/>


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210907527783756
2025-07-30 20:59:48 +00:00
Alessio Gravili
095e7d904f fix(next): safely access relationTo value from relationship field (#13337)
Fixes https://github.com/payloadcms/payload/issues/13330
2025-07-30 20:54:04 +00:00
Patrik
c48b57fdbf fix(next): incorrect doc link in trash view with groupBy enabled (#13332)
### What?

Fixes an issue where document links in the trash view were incorrectly
generated when group-by was enabled for a collection. Previously, links
in grouped tables would omit the `/trash` segment, causing navigation to
crash while trying to route to the default edit view instead of the
trashed document view.

### Why?

When viewing a collection in group-by mode, document rows are rendered
in grouped tables via the `handleGroupBy` logic. However, these tables
were unaware of whether the view was operating in trash mode, so the
generated row links did not include the necessary `/trash` segment. This
broke navigation when trying to view or edit trashed documents.

### How?

- Threaded the `viewType` prop through `renderListView` into the
`handleGroupBy` utility.
- Passed `viewType` into each `renderTable` call within `handleGroupBy`,
ensuring proper link generation.
- `renderTable` already supports `viewType` and appends `/trash` to edit
links when it's set to 'trash'.
2025-07-30 13:17:03 -07:00
Sasha
b26a73be4a fix: querying hasMany: true select fields inside polymorphic joins (#13334)
This PR fixes queries like this:

```ts
const findFolder = await payload.find({
  collection: 'payload-folders',
  where: {
    id: {
      equals: folderDoc.id,
    },
  },
  joins: {
    documentsAndFolders: {
      limit: 100_000,
      sort: 'name',
      where: {
        and: [
          {
            relationTo: {
              equals: 'payload-folders',
            },
          },
          {
            folderType: {
              in: ['folderPoly1'], // previously this didn't work
            },
          },
        ],
      },
    },
  },
})
```

Additionally, this PR potentially fixes querying JSON fields by the top
level path, for example if your JSON field has a value like: `[1, 2]`,
previously `where: { json: { equals: 1 } }` didn't work, however with a
value like `{ nested: [1, 2] }` and a query `where: { 'json.nested': {
equals: 1 } }`it did.

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2025-07-30 15:30:20 -04:00
Alessio Gravili
3114b89d4c perf: 23% faster job queue system on postgres/sqlite (#13187)
Previously, a single run of the simplest job queue workflow (1 single
task, no db calls by user code in the task - we're just testing db
system overhead) would result in **22 db roundtrips** on drizzle. This
PR reduces it to **17 db roundtrips** by doing the following:

- Modifies db.updateJobs to use the new optimized upsertRow function if
the update is simple
- Do not unnecessarily pass the job log to the final job update when the
workflow completes => allows using the optimized upsertRow function, as
only the main table is involved

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210888186878606
2025-07-30 16:23:43 +03:00
Evelyn Hathaway
227a20e94b fix(richtext-lexical): recursively unwrap generic Slate nodes in Lexical migration converter (#13202)
## What?

The Slate to Lexical migration script assumes that the depth of Slate
nodes matches the depth of the Lexical schema, which isn't necessarily
true. This pull request fixes this assumption by first checking for
children and unwrapping the text nodes.

## Why?

During my migration, I ran into a lot of copy + pasted rich text with
list items with untyped nodes with `children`. The existing migration
script assumed that since list items can't have paragraphs, all untyped
nodes inside must be text nodes.

The result of the migration script was a lot of invalid text nodes with
`text: undefined` and all of the content in the `children` being
silently lost. Beyond the silent loss, the invalid text nodes caused the
Lexical editor to unmount with an error about accessing `0 of
undefined`, so those documents couldn't be edited.

This additionally makes the migration script more closely align with the
[recursive serialization logic recommendation from the Payload Slate
Rich Text
documentation](https://payloadcms.com/docs/rich-text/slate#generating-html).

## Visualization

### Slate

```txt
Slate rich text content
┣━┳━ Unordered list
┋ ┣━┳━ List item
┋ ┋ ┗━┳━ Generic (paragraph-like, untyped with children)
┋ ┋   ┣━━━ Text (untyped) `Hello `
┋ ┋   ┗━━━ Text (untyped) `World!
[...]
```

### Lexical Before PR

```txt
Lexical rich text content (invalid)
┣━┳━ Unordered list
┋ ┣━┳━ List item
┋ ┋ ┗━━━ Invalid text (assumed the generic node was text, stopped processing children, cannot restore lost text without a restoring backup with Slate and rerunning the script after this MR)
[...]
```

### Lexical After PR

```txt
Lexical rich text content
┣━┳━ Unordered list
┋ ┣━┳━ List item
┋ ┋ ┣━━━ Text `Hello `
┋ ┋ ┗━━━ Text `World!
[...]
```

---------

Co-authored-by: German Jablonski <43938777+GermanJablo@users.noreply.github.com>
2025-07-30 13:16:18 +00:00
Jarrod Flesch
a22f27de1c test: stabilize frequent fails (#13318)
Adjusts tests that "flake" frequently.
2025-07-30 05:52:01 -07:00
Patrik
e7124f6176 fix(next): cannot filter trash (#13320)
### What?

- Updated `TrashView` to pass `trash: true` as a dedicated prop instead
of embedding it in the `query` object.
- Modified `renderListView` to correctly merge `trash` and `where`
queries by using both `queryFromArgs` and `queryFromReq`.
- Ensured filtering (via `where`) works correctly in the trash view.

### Why?

Previously, the `trash: true` flag was injected into the `query` object,
and `renderListView` only used `queryFromArgs`.
This caused the `where` clause from filters (added by the
`WhereBuilder`) to be overridden, breaking filtering in the trash view.

### How?

- Introduced an explicit `trash` prop in `renderListView` arguments.
- Updated `TrashView` to pass `trash: true` separately.
- Updated `renderListView` to apply the `trash` filter in addition to
any `where` conditions.
2025-07-29 14:29:04 -07:00
Elliot DeNolf
183f313387 chore(release): v3.49.1 [skip ci] 2025-07-29 16:38:50 -04:00
contip
b1fa76e397 fix: keep apiKey encrypted in refresh operation (#13063) (#13177)
### What?
Prevents decrypted apiKey from being saved back to database on the auth
refresh operation.

### Why?
References issue #13063: refreshing a token for a logged-in user
decrypted `apiKey` and wrote it back in plaintext, corrupting the user
record.

### How?
The user is now fetched with `db.findOne` instead of `findByID`,
preserving the encryption of the key when saved back to the database
using `db.updateOne`. The user record is then re-fetched using
`findByID`, allowing for the decrypted key to be provided in the
response.

### Tests
*  keeps apiKey encrypted in DB after refresh
*  returns user with decrypted apiKey after refresh

Fixes #13063
2025-07-29 16:27:45 -04:00
German Jablonski
08942494e3 fix: filters cookies with the payload- prefix in getExternalFile by default (#13215)
### What

- filters cookies with the `payload-` prefix in `getExternalFile` by
default (if `externalFileHeaderFilter` is not used).
- Document in `externalFileHeaderFilter`, that the user should handle
the removing of the payload cookie.

### Why

In the Payload application, the `getExternalFile` function sends the
user's cookies to an external server when fetching media, inadvertently
exposing the user's session to that third-party service.




```ts
const headers = uploadConfig.externalFileHeaderFilter
  ? uploadConfig.externalFileHeaderFilter(Object.fromEntries(new Headers(req.headers)))
  : { cookie: req.headers?.get('cookie') };

const res = await fetch(fileURL, {
  credentials: 'include',
  headers,
  method: 'GET',
});
```
Although the
[externalFileHeaderFilter](https://payloadcms.com/docs/upload/overview#collection-upload-options)
function can strip sensitive cookies from the request, the default
config includes the session cookie, violating the secure-by-default
principle.

### How

- If `externalFileHeaderFilter` is not defined, any cookie beginning
with `payload-` is filtered.
- Added 2 tests: both for the case where `externalFileHeaderFilter` is
defined and for the case where it is not.





---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210561338171125
2025-07-29 16:21:50 -04:00
Adebayo Ajayi
da8bf69054 fix(translations): improve swedish translations (#13272)
### What?
Update Swedish translation, removing minor inconsistencies and opting
for more natural sounding translations
### Why?
The current Swedish translation contained some minor grammatical issues
and inconsistencies that make the UI feel less natural to Swedish users.
### How?
- Fixed "e-post" hyphenation consistency
- Changed "Alla platser" → "Alla språk" (locales should be "languages")
- Improved action verbs: "Tydlig" → "Rensa", "Stänga" → "Stäng"
- Made "Kollapsa" → "Fäll ihop" more natural
- Standardized preview terminology: "Live förhandsgranskning" →
"förhandsgranskning"
- Fixed terminology: "fältdatabas" → "fältdata" (fältdatabas mean field
database while fältdata means field data)
- Changed "Programinställningar" → "Systeminställningar" (more
appropriate for software)
- Fixed punctuation: em dash → comma in "sorryNotFound"
- Improved "Visa endast läsning" → "Visa som skrivskyddad"
(grammatically correct)

Fixes #
2025-07-29 18:37:12 +00:00
brutumfulmen97
26d9daeccf fix(translations): missing closing brace in rs latin translation for lastSavedAgo (#13172)
Fixed missing closing brace in the translations package for the language
rs latin.
<img width="885" height="200" alt="image"
src="https://github.com/user-attachments/assets/12d00305-6cc9-46ce-87e8-2c66f9d9e63c"
/>
Here is the code diff.

Co-authored-by: Vlatko Percic <vlatko@studiopresent.com>
2025-07-29 18:20:29 +00:00
Alejandro Martinez
fc5944840e docs: remove asterisk from optional url property in live preview docs (#13108)
### What?

url option is not required for Link Preview, so I removed the asterisk.

### Why?

Here is the type definition

[Type Definition
Link](c6105f1e0d/packages/payload/src/config/types.ts (L159))
2025-07-29 14:16:19 -04:00
Aayush Rajagopalan
9e04dbb1ca docs: typo in vercel-content-link.mdx (#13170)
### Fixes:
'title,' -> 'title',
2025-07-29 18:07:15 +00:00
Sean Zubrickas
72954ce9f2 docs: fixes typo in ternary operator for live preview docs (#13163)
Fixes ternary operator in live preview docs.
2025-07-29 13:56:31 -04:00
Jacob Fletcher
e50220374e fix(next): group by null relationship crashes list view (#13315)
When grouping by a relationship field and it's value is `null`, the list
view crashes.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210916642997992
2025-07-29 11:55:02 -04:00
Jacob Fletcher
61ee8fadca fix(ui): autosave-enabled document drawers close unexpectedly within the join field (#13298)
Fixes #12975.

When editing autosave-enabled documents through the join field, the
document drawer closes unexpectedly on every autosave interval, making
it nearly impossible to use.

This is because as of #12842, the underlying relationship table
re-renders on every autosave event, remounting the drawer each time. The
fix is to lift the drawer out of table's rendering tree and into the
join field itself. This way all rows share the same drawer, whose
rendering lifecycle has been completely decoupled from the table's
state.

Note: this is very similar to how relationship fields achieve similar
functionality.

This PR also adds jsdocs to the `useDocumentDrawer` hook and strengthens
its types.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210906078627353
2025-07-29 11:49:15 -04:00
Jarrod Flesch
8d84352ee9 fix(next): catch list filter errors, prevent list view crash (#13297)
Catches list filter errors and prevents the list view from crashing when
attempting to search on fields the user does not have access to. Instead
just shows the default "no results found" message.
2025-07-29 11:30:07 -04:00
Elliot DeNolf
4beb27b9ad ci: show path value in audit-dependencies script [skip ci] (#13314)
Improve audit-dependencies script to show the vulnerable package path:

```diff
   {
     "package": "form-data",
     "vulnerable": "<2.5.4",
-    "fixed_in": ">=2.5.4"
+    "fixed_in": ">=2.5.4",
+    "findings": [
+      {
+        "version": "2.5.2",
+        "paths": [
+          "packages/storage-gcs > @google-cloud/storage@7.14.0 > retry-request@7.0.2 > @types/request@2.48.12 > form-data@2.5.2"
+        ]
+      }
+    ]
   }
 ]
```
2025-07-29 11:08:39 -04:00
Jacob Fletcher
c5c8c13057 fix(next): pass req through document tab conditions and custom server components (#13302)
Custom document tab components (server components) do not receive the
`user` prop, as the types suggest. This makes it difficult to wire up
conditional rendering based on the user. This is because tab conditions
don't receive a user argument either, forcing you to render the default
tab component yourself—but a custom component should not be needed for
this in the first place.

Now they both receive `req` alongside `user`, which is more closely
aligned with custom field components.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210906078627357
2025-07-28 23:35:38 -04:00
Jacob Fletcher
a888d5cc53 chore(ui): var name typo in relationship field (#13295)
Fixes typo in variable name within the relationship field component.

`disableFormModication` → `disableFormModification`
2025-07-28 23:34:32 -04:00
Jarrod Flesch
72349245ca test: fix flaky sorting test (#13303)
Ensures the browser uses fresh data after seeding by refreshing the
route and navigating when done.
2025-07-29 03:25:09 +00:00
Alessio Gravili
4fde0f23ce fix: use atomic operation for incrementing login attempts (#13204)
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210561338171141
2025-07-28 16:08:10 -07:00
Patrik
aff2ce1b9b fix(next): unable to view trashed documents when group-by is enabled (#13300)
### What?

- Fixed an issue where group-by enabled collections with `trash: true`
were not showing trashed documents in the collection’s trash view.
- Ensured that the `trash` query argument is properly passed to the
`findDistinct` call within `handleGroupBy`, allowing trashed documents
to be included in grouped list views.

### Why?

Previously, when viewing the trash view of a collection with both
**group-by** and **trash** enabled, trashed documents would not appear.
This was caused by the `trash` argument not being forwarded to
`findDistinct` in `handleGroupBy`, which resulted in empty or incorrect
group-by results.

### How?

- Passed the `trash` flag through all relevant `findDistinct` and `find`
calls in `handleGroupBy`.
2025-07-28 11:29:04 -07:00
Alessio Gravili
5c94d2dc71 feat: support next.js 15.4.4 (#13280)
- bumps next.js from 15.3.2 to 15.4.4 in monorepo and templates. It's
important to run our tests against the latest Next.js version to
guarantee full compatibility.
- bumps playwright because of peer dependency conflict with next 15.4.4
- bumps react types because why not

https://nextjs.org/blog/next-15-4

As part of this upgrade, the functionality added by
https://github.com/payloadcms/payload/pull/11658 broke. This PR fixes it
by creating a wrapper around `React.isValidElemen`t that works for
Next.js 15.4.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210803039809808
2025-07-28 16:23:43 +00:00
Jarrod Flesch
b1aac19668 chore(next): cleanup unused code (#13292)
Looks like a merge resolution kept unused code. The same condition is
added a couple lines below this removal.
2025-07-28 13:43:51 +00:00
Sean Zubrickas
d093bb1f00 fix: refactors toast error rendering (#13252)
Fixes #13191

- Render a single html element for single error messages
- Preserve ul structure for multiple errors
- Updates tests to check for both cases
2025-07-28 05:59:25 -07:00
259 changed files with 5914 additions and 2376 deletions

View File

@@ -13,7 +13,8 @@ echo "${audit_json}" | jq --arg severity "${severity}" '
{
package: .value.module_name,
vulnerable: .value.vulnerable_versions,
fixed_in: .value.patched_versions
fixed_in: .value.patched_versions,
findings: .value.findings
}
)
' >$output_file
@@ -23,7 +24,11 @@ audit_length=$(jq 'length' $output_file)
if [[ "${audit_length}" -gt "0" ]]; then
echo "Actionable vulnerabilities found in the following packages:"
jq -r '.[] | "\u001b[1m\(.package)\u001b[0m vulnerable in \u001b[31m\(.vulnerable)\u001b[0m fixed in \u001b[32m\(.fixed_in)\u001b[0m"' $output_file | while read -r line; do echo -e "$line"; done
echo ""
echo "Output written to ${output_file}"
cat $output_file
echo ""
echo "This script can be rerun with: './.github/workflows/audit-dependencies.sh $severity'"
exit 1
else
echo "No actionable vulnerabilities"

View File

@@ -46,7 +46,7 @@ jobs:
"type": "section",
"text": {
"type": "mrkdwn",
"text": "🚨 Actionable vulnerabilities found: <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Details>"
"text": "🚨 Actionable vulnerabilities found: <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Script Run Details>"
}
},
]

View File

@@ -17,6 +17,9 @@ env:
jobs:
post_release:
permissions:
issues: write
pull-requests: write
runs-on: ubuntu-24.04
if: ${{ github.event_name != 'workflow_dispatch' }}
steps:

View File

@@ -739,7 +739,7 @@ The `useDocumentInfo` hook provides information about the current document being
| **`lastUpdateTime`** | Timestamp of the last update to the document. |
| **`mostRecentVersionIsAutosaved`** | Whether the most recent version is an autosaved version. |
| **`preferencesKey`** | The `preferences` key to use when interacting with document-level user preferences. [More details](./preferences). |
| **`savedDocumentData`** | The saved data of the document. |
| **`data`** | The saved data of the document. |
| **`setDocFieldPreferences`** | Method to set preferences for a specific field. [More details](./preferences). |
| **`setDocumentTitle`** | Method to set the document title. |
| **`setHasPublishedDoc`** | Method to update whether the document has been published. |

View File

@@ -142,7 +142,7 @@ The following options are available:
| `components` | Swap in your own React components to be used within this Collection. [More details](#custom-components). |
| `listSearchableFields` | Specify which fields should be searched in the List search view. [More details](#list-searchable-fields). |
| `pagination` | Set pagination-specific options for this Collection. [More details](#pagination). |
| `baseListFilter` | You can define a default base filter for this collection's List view, which will be merged into any filters that the user performs. |
| `baseFilter` | Defines a default base filter which will be applied to the List View (along with any other filters applied by the user) and internal links in Lexical Editor, |
<Banner type="warning">
**Note:** If you set `useAsTitle` to a relationship or join field, it will use

View File

@@ -296,11 +296,16 @@ query {
sort: "createdAt"
limit: 5
where: { author: { equals: "66e3431a3f23e684075aaeb9" } }
"""
Optionally pass count: true if you want to retrieve totalDocs
"""
count: true -- s
) {
docs {
title
}
hasNextPage
totalDocs
}
}
}

View File

@@ -34,20 +34,20 @@ npm i @payloadcms/plugin-csm
Then in the `plugins` array of your Payload Config, call the plugin and enable any collections that require Content Source Maps.
```ts
import { buildConfig } from "payload/config"
import contentSourceMaps from "@payloadcms/plugin-csm"
import { buildConfig } from 'payload/config'
import contentSourceMaps from '@payloadcms/plugin-csm'
const config = buildConfig({
collections: [
{
slug: "pages",
slug: 'pages',
fields: [
{
name: 'slug',
type: 'text',
},
{
name: 'title,'
name: 'title',
type: 'text',
},
],
@@ -55,7 +55,7 @@ const config = buildConfig({
],
plugins: [
contentSourceMaps({
collections: ["pages"],
collections: ['pages'],
}),
],
})

View File

@@ -45,13 +45,11 @@ The following options are available:
| Path | Description |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`url`** \* | String, or function that returns a string, pointing to your front-end application. This value is used as the iframe `src`. [More details](#url). |
| **`url`** | String, or function that returns a string, pointing to your front-end application. This value is used as the iframe `src`. [More details](#url). |
| **`breakpoints`** | Array of breakpoints to be used as “device sizes” in the preview window. Each item appears as an option in the toolbar. [More details](#breakpoints). |
| **`collections`** | Array of collection slugs to enable Live Preview on. |
| **`globals`** | Array of global slugs to enable Live Preview on. |
_\* An asterisk denotes that a property is required._
### URL
The `url` property resolves to a string that points to your front-end application. This value is used as the `src` attribute of the iframe rendering your front-end. Once loaded, the Admin Panel will communicate directly with your app through `window.postMessage` events.
@@ -88,17 +86,16 @@ const config = buildConfig({
// ...
livePreview: {
// highlight-start
url: ({
data,
collectionConfig,
locale
}) => `${data.tenant.url}${ // Multi-tenant top-level domain
collectionConfig.slug === 'posts' ? `/posts/${data.slug}` : `${data.slug !== 'home' : `/${data.slug}` : ''}`
}${locale ? `?locale=${locale?.code}` : ''}`, // Localization query param
url: ({ data, collectionConfig, locale }) =>
`${data.tenant.url}${
collectionConfig.slug === 'posts'
? `/posts/${data.slug}`
: `${data.slug !== 'home' ? `/${data.slug}` : ''}`
}${locale ? `?locale=${locale?.code}` : ''}`, // Localization query param
collections: ['pages'],
},
// highlight-end
}
},
})
```

View File

@@ -76,7 +76,19 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
isGlobal?: boolean
/**
* Set to `false` if you want to manually apply
* the baseListFilter
* the baseFilter
*
* @default true
*/
useBaseFilter?: boolean
/**
* @deprecated Use `useBaseFilter` instead. If both are defined,
* `useBaseFilter` will take precedence. This property remains only
* for backward compatibility and may be removed in a future version.
*
* Originally, `baseListFilter` was intended to filter only the List View
* in the admin panel. However, base filtering is often required in other areas
* such as internal link relationships in the Lexical editor.
*
* @default true
*/
@@ -203,12 +215,12 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
*/
useTenantsCollectionAccess?: boolean
/**
* Opt out including the baseListFilter to filter
* Opt out including the baseFilter to filter
* tenants by selected tenant
*/
useTenantsListFilter?: boolean
/**
* Opt out including the baseListFilter to filter
* Opt out including the baseFilter to filter
* users by selected tenant
*/
useUsersTenantFilter?: boolean

View File

@@ -6,9 +6,112 @@ desc: Troubleshooting Common Issues in Payload
keywords: admin, components, custom, customize, documentation, Content Management System, cms, headless, javascript, node, react, nextjs, troubleshooting
---
## Common Issues
## Dependency mismatches
### "Unauthorized, you must be logged in to make this request" when attempting to log in
All `payload` and `@payloadcms/*` packages must be on exactly the same version and installed only once.
When two copies—or two different versions—of any of these packages (or of `react` / `react-dom`) appear in your dependency graph, you can see puzzling runtime errors. The most frequent is a broken React context:
```bash
TypeError: Cannot destructure property 'config' of...
```
This happens because one package imports a hook (most commonly `useConfig`) from _version A_ while the context provider comes from _version B_. The fix is always the same: make sure every Payload-related and React package resolves to the same module.
### Confirm whether duplicates exist
The first thing to do is to confirm whether duplicative dependencies do in fact exist.
There are two ways to do this:
1. Using pnpm's built-in inspection tool
```bash
pnpm why @payloadcms/ui
```
This prints the dependency tree and shows which versions are being installed. If you see more than one distinct version—or the same version listed under different paths—you have duplication.
2. Manual check (works with any package manager)
```bash
find node_modules -name package.json \
-exec grep -H '"name": "@payloadcms/ui"' {} \;
```
Most of these hits are likely symlinks created by pnpm. Edit the matching package.json files (temporarily add a comment or change a description) to confirm whether they point to the same physical folder or to multiple copies.
Perform the same two checks for react and react-dom; a second copy of React can cause identical symptoms.
#### If no duplicates are found
`@payloadcms/ui` intentionally contains two bundles of itself, so you may see dual paths even when everything is correct. Inside the Payload Admin UI you must import only:
- `@payloadcms/ui`
- `@payloadcms/ui/rsc`
- `@payloadcms/ui/shared`
Any other deep import such as `@payloadcms/ui/elements/Button` should **only** be used in your own frontend, outside of the Payload Admin Panel. Those deep entries are published un-bundled to help you tree-shake and ship a smaller client bundle if you only need a few components from `@payloadcms/ui`.
### Fixing depedendency issues
These steps assume `pnpm`, which the Payload team recommends and uses internally. The principles apply to other package managers like npm and yarn as well. Do note that yarn 1.x is not supported by Payload.
1. Pin every critical package to an exact version
In package.json remove `^` or `~` from all versions of:
- `payload`
- `@payloadcms/*`
- `react`
- `react-dom`
Prefixes allow your package manager to float to a newer minor/patch release, causing mismatches.
2. Delete node_modules
Old packages often linger even after you change versions or removed them from your package.json. Deleting node_modules ensures a clean slate.
3. Re-install dependencies
```bash
pnpm install
```
#### If the error persists
1. Clean the global store (pnpm only)
```bash
pnpm store prune
```
2. Delete the lockfile
Depending on your package manager, this could be `pnpm-lock.yaml`, `package-lock.json`, or `yarn.lock`.
Make sure you delete the lockfile **and** the node_modules folder at the same time, then run `pnpm install`. This forces a fresh, consistent resolution for all packages. It will also update all packages with dynamic versions to the latest version.
While it's best practice to manage dependencies in such a way where the lockfile can easily be re-generated (often this is the easiest way to resolve dependency issues), this may break your project if you have not tested the latest versions of your dependencies.
If you are using a version control system, make sure to commit your lockfile after this step.
3. Deduplicate anything that slipped through
```bash
pnpm dedupe
```
**Still stuck?**
- Switch to `pnpm` if you are on npm. Its symlinked store helps reducing accidental duplication.
- Inspect the lockfile directly for peer-dependency violations.
- Check project-level .npmrc / .pnpmfile.cjs overrides.
- Run [Syncpack](https://www.npmjs.com/package/syncpack) to enforce identical versions of every `@payloadcms/*`, `react`, and `react-dom` reference.
Absolute last resort: add Webpack aliases so that all imports of a given package resolve to the same path (e.g. `resolve.alias['react'] = path.resolve('./node_modules/react')`). Keep this only until you can fix the underlying version skew.
## "Unauthorized, you must be logged in to make this request" when attempting to log in
This means that your auth cookie is not being set or accepted correctly upon logging in. To resolve check the following settings in your Payload Config:

View File

@@ -90,33 +90,33 @@ export const Media: CollectionConfig = {
_An asterisk denotes that an option is required._
| Option | Description |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. |
| **`constructorOptions`** | An object passed to the the Sharp image library that accepts any Constructor options and applies them to the upload file. [More](https://sharp.pixelplumbing.com/api-constructor/) |
| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) |
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). |
| **`externalFileHeaderFilter`** | Accepts existing headers and returns the headers after filtering or modifying. |
| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. |
| **`filenameCompoundIndex`** | Field slugs to use for a compound index instead of the default filename index. |
| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the [Admin Panel](../admin/overview). The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) |
| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) |
| **`handlers`** | Array of Request handlers to execute when fetching a file, if a handler returns a Response it will be sent to the client. Otherwise Payload will retrieve and send back the file. |
| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) |
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) |
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. |
| **`allowRestrictedFileTypes`** | Set to `true` to allow restricted file types. If your Collection has defined [mimeTypes](#mimetypes), restricted file verification will be skipped. Defaults to `false`. [More](#restricted-file-types) |
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
| **`hideFileInputOnCreate`** | Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. |
| **`hideRemoveFile`** | Set to `true` to prevent the admin UI having a way to remove an existing file while editing. |
| **`modifyResponseHeaders`** | Accepts an object with existing `headers` and allows you to manipulate the response headers for media files. [More](#modifying-response-headers) |
| Option | Description |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. |
| **`constructorOptions`** | An object passed to the the Sharp image library that accepts any Constructor options and applies them to the upload file. [More](https://sharp.pixelplumbing.com/api-constructor/) |
| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) |
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). |
| **`externalFileHeaderFilter`** | Accepts existing headers and returns the headers after filtering or modifying. If using this option, you should handle the removal of any sensitive cookies (like payload-prefixed cookies) to prevent leaking session information to external services. By default, Payload automatically filters out payload-prefixed cookies when this option is not defined. |
| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. |
| **`filenameCompoundIndex`** | Field slugs to use for a compound index instead of the default filename index. |
| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the [Admin Panel](../admin/overview). The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) |
| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) |
| **`handlers`** | Array of Request handlers to execute when fetching a file, if a handler returns a Response it will be sent to the client. Otherwise Payload will retrieve and send back the file. |
| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) |
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) |
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. |
| **`allowRestrictedFileTypes`** | Set to `true` to allow restricted file types. If your Collection has defined [mimeTypes](#mimetypes), restricted file verification will be skipped. Defaults to `false`. [More](#restricted-file-types) |
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
| **`hideFileInputOnCreate`** | Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. |
| **`hideRemoveFile`** | Set to `true` to prevent the admin UI having a way to remove an existing file while editing. |
| **`modifyResponseHeaders`** | Accepts an object with existing `headers` and allows you to manipulate the response headers for media files. [More](#modifying-response-headers) |
### Payload-wide Upload Options

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.49.0",
"version": "3.50.0",
"private": true,
"type": "module",
"workspaces": [
@@ -132,12 +132,12 @@
"devDependencies": {
"@jest/globals": "29.7.0",
"@libsql/client": "0.14.0",
"@next/bundle-analyzer": "15.3.2",
"@next/bundle-analyzer": "15.4.4",
"@payloadcms/db-postgres": "workspace:*",
"@payloadcms/eslint-config": "workspace:*",
"@payloadcms/eslint-plugin": "workspace:*",
"@payloadcms/live-preview-react": "workspace:*",
"@playwright/test": "1.50.0",
"@playwright/test": "1.54.1",
"@sentry/nextjs": "^8.33.1",
"@sentry/node": "^8.33.1",
"@swc-node/register": "1.10.10",
@@ -147,8 +147,8 @@
"@types/jest": "29.5.12",
"@types/minimist": "1.2.5",
"@types/node": "22.15.30",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/shelljs": "0.8.15",
"chalk": "^4.1.2",
"comment-json": "^4.2.3",
@@ -168,12 +168,12 @@
"lint-staged": "15.2.7",
"minimist": "1.2.8",
"mongodb-memory-server": "10.1.4",
"next": "15.3.2",
"next": "15.4.4",
"open": "^10.1.0",
"p-limit": "^5.0.0",
"pg": "8.16.3",
"playwright": "1.50.0",
"playwright-core": "1.50.0",
"playwright": "1.54.1",
"playwright-core": "1.54.1",
"prettier": "3.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/admin-bar",
"version": "3.49.0",
"version": "3.50.0",
"description": "An admin bar for React apps using Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -42,8 +42,8 @@
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.49.0",
"version": "3.50.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.49.0",
"version": "3.50.0",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.49.0",
"version": "3.50.0",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.49.0",
"version": "3.50.0",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -60,6 +60,10 @@ const createConstraint = ({
formattedOperator = '='
}
if (pathSegments.length === 1) {
return `EXISTS (SELECT 1 FROM json_each("${pathSegments[0]}") AS ${newAlias} WHERE ${newAlias}.value ${formattedOperator} '${formattedValue}')`
}
return `EXISTS (
SELECT 1
FROM json_each(${alias}.value -> '${pathSegments[0]}') AS ${newAlias}
@@ -68,21 +72,38 @@ const createConstraint = ({
}
export const createJSONQuery = ({
column,
operator,
pathSegments,
rawColumn,
table,
treatAsArray,
treatRootAsArray,
value,
}: CreateJSONQueryArgs): string => {
if ((operator === 'in' || operator === 'not_in') && Array.isArray(value)) {
let sql = ''
for (const [i, v] of value.entries()) {
sql = `${sql}${createJSONQuery({ column, operator: operator === 'in' ? 'equals' : 'not_equals', pathSegments, rawColumn, table, treatAsArray, treatRootAsArray, value: v })} ${i === value.length - 1 ? '' : ` ${operator === 'in' ? 'OR' : 'AND'} `}`
}
return sql
}
if (treatAsArray?.includes(pathSegments[1]!) && table) {
return fromArray({
operator,
pathSegments,
table,
treatAsArray,
value,
value: value as CreateConstraintArgs['value'],
})
}
return createConstraint({ alias: table, operator, pathSegments, treatAsArray, value })
return createConstraint({
alias: table,
operator,
pathSegments,
treatAsArray,
value: value as CreateConstraintArgs['value'],
})
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-vercel-postgres",
"version": "3.49.0",
"version": "3.50.0",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
"version": "3.49.0",
"version": "3.50.0",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,12 +1,14 @@
import type { SQL } from 'drizzle-orm'
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
import type { SQLiteSelect, SQLiteSelectBase } from 'drizzle-orm/sqlite-core'
import { and, asc, count, desc, eq, or, sql } from 'drizzle-orm'
import { and, asc, count, desc, eq, getTableName, or, sql } from 'drizzle-orm'
import {
appendVersionToQueryKey,
buildVersionCollectionFields,
combineQueries,
type FlattenedField,
getFieldByPath,
getQueryDraftsSort,
type JoinQuery,
type SelectMode,
@@ -31,7 +33,7 @@ import {
resolveBlockTableName,
} from '../utilities/validateExistingBlockIsIdentical.js'
const flattenAllWherePaths = (where: Where, paths: string[]) => {
const flattenAllWherePaths = (where: Where, paths: { path: string; ref: any }[]) => {
for (const k in where) {
if (['AND', 'OR'].includes(k.toUpperCase())) {
if (Array.isArray(where[k])) {
@@ -41,7 +43,7 @@ const flattenAllWherePaths = (where: Where, paths: string[]) => {
}
} else {
// TODO: explore how to support arrays/relationship querying.
paths.push(k.split('.').join('_'))
paths.push({ path: k.split('.').join('_'), ref: where })
}
}
}
@@ -59,7 +61,11 @@ const buildSQLWhere = (where: Where, alias: string) => {
}
} else {
const payloadOperator = Object.keys(where[k])[0]
const value = where[k][payloadOperator]
if (payloadOperator === '$raw') {
return sql.raw(value)
}
return operatorMap[payloadOperator](sql.raw(`"${alias}"."${k.split('.').join('_')}"`), value)
}
@@ -472,7 +478,7 @@ export const traverseFields = ({
const sortPath = sanitizedSort.split('.').join('_')
const wherePaths: string[] = []
const wherePaths: { path: string; ref: any }[] = []
if (where) {
flattenAllWherePaths(where, wherePaths)
@@ -492,9 +498,50 @@ export const traverseFields = ({
sortPath: sql`${sortColumn ? sortColumn : null}`.as('sortPath'),
}
const collectionQueryWhere: any[] = []
// Select for WHERE and Fallback NULL
for (const path of wherePaths) {
if (adapter.tables[joinCollectionTableName][path]) {
for (const { path, ref } of wherePaths) {
const collectioConfig = adapter.payload.collections[collection].config
const field = getFieldByPath({ fields: collectioConfig.flattenedFields, path })
if (field && field.field.type === 'select' && field.field.hasMany) {
let tableName = adapter.tableNameMap.get(
`${toSnakeCase(collection)}_${toSnakeCase(path)}`,
)
let parentTable = getTableName(table)
if (adapter.schemaName) {
tableName = `"${adapter.schemaName}"."${tableName}"`
parentTable = `"${adapter.schemaName}"."${parentTable}"`
}
if (adapter.name === 'postgres') {
selectFields[path] = sql
.raw(
`(select jsonb_agg(${tableName}.value) from ${tableName} where ${tableName}.parent_id = ${parentTable}.id)`,
)
.as(path)
} else {
selectFields[path] = sql
.raw(
`(select json_group_array(${tableName}.value) from ${tableName} where ${tableName}.parent_id = ${parentTable}.id)`,
)
.as(path)
}
const constraint = ref[path]
const operator = Object.keys(constraint)[0]
const value: any = Object.values(constraint)[0]
const query = adapter.createJSONQuery({
column: `"${path}"`,
operator,
pathSegments: [field.field.name],
table: parentTable,
value,
})
ref[path] = { $raw: query }
} else if (adapter.tables[joinCollectionTableName][path]) {
selectFields[path] = sql`${adapter.tables[joinCollectionTableName][path]}`.as(path)
// Allow to filter by collectionSlug
} else if (path !== 'relationTo') {
@@ -502,7 +549,10 @@ export const traverseFields = ({
}
}
const query = db.select(selectFields).from(adapter.tables[joinCollectionTableName])
let query: any = db.select(selectFields).from(adapter.tables[joinCollectionTableName])
if (collectionQueryWhere.length) {
query = query.where(and(...collectionQueryWhere))
}
if (currentQuery === null) {
currentQuery = query as unknown as SQLSelect
} else {

View File

@@ -28,6 +28,8 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat
})
.join('.')
const fullPath = pathSegments.length === 1 ? '$[*]' : `$.${jsonPaths}`
let sql = ''
if (['in', 'not_in'].includes(operator) && Array.isArray(value)) {
@@ -35,13 +37,13 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat
sql = `${sql}${createJSONQuery({ column, operator: operator === 'in' ? 'equals' : 'not_equals', pathSegments, value: item })}${i === value.length - 1 ? '' : ` ${operator === 'in' ? 'OR' : 'AND'} `}`
})
} else if (operator === 'exists') {
sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '$.${jsonPaths}')`
sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '${fullPath}')`
} else if (['not_like'].includes(operator)) {
const mappedOperator = operatorMap[operator]
sql = `NOT jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${mappedOperator.substring(1)} ${sanitizeValue(value, operator)})')`
sql = `NOT jsonb_path_exists(${columnName}, '${fullPath} ? (@ ${mappedOperator.substring(1)} ${sanitizeValue(value, operator)})')`
} else {
sql = `jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')`
sql = `jsonb_path_exists(${columnName}, '${fullPath} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')`
}
return sql

View File

@@ -161,10 +161,11 @@ export type CreateJSONQueryArgs = {
column?: Column | string
operator: string
pathSegments: string[]
rawColumn?: SQL<unknown>
table?: string
treatAsArray?: string[]
treatRootAsArray?: boolean
value: boolean | number | string
value: boolean | number | number[] | string | string[]
}
/**

View File

@@ -6,6 +6,7 @@ import type { DrizzleAdapter } from './types.js'
import { findMany } from './find/findMany.js'
import { upsertRow } from './upsertRow/index.js'
import { shouldUseOptimizedUpsertRow } from './upsertRow/shouldUseOptimizedUpsertRow.js'
import { getTransaction } from './utilities/getTransaction.js'
export const updateJobs: UpdateJobs = async function updateMany(
@@ -23,6 +24,27 @@ export const updateJobs: UpdateJobs = async function updateMany(
const tableName = this.tableNameMap.get(toSnakeCase(collection.slug))
const sort = sortArg !== undefined && sortArg !== null ? sortArg : collection.defaultSort
const useOptimizedUpsertRow = shouldUseOptimizedUpsertRow({
data,
fields: collection.flattenedFields,
})
if (useOptimizedUpsertRow && id) {
const result = await upsertRow({
id,
adapter: this,
data,
db,
fields: collection.flattenedFields,
ignoreResult: returning === false,
operation: 'update',
req,
tableName,
})
return returning === false ? null : [result]
}
const jobs = await findMany({
adapter: this,
collectionSlug: 'payload-jobs',
@@ -42,10 +64,12 @@ export const updateJobs: UpdateJobs = async function updateMany(
// TODO: We need to batch this to reduce the amount of db calls. This can get very slow if we are updating a lot of rows.
for (const job of jobs.docs) {
const updateData = {
...job,
...data,
}
const updateData = useOptimizedUpsertRow
? data
: {
...job,
...data,
}
const result = await upsertRow({
id: job.id,

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
"version": "3.49.0",
"version": "3.50.0",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-resend",
"version": "3.49.0",
"version": "3.50.0",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.49.0",
"version": "3.50.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -9,6 +9,7 @@ export type Resolver = (
args: {
data: Record<string, unknown>
locale?: string
trash?: boolean
where?: Where
},
context: {
@@ -30,6 +31,7 @@ export function countResolver(collection: Collection): Resolver {
const options = {
collection,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
where: args.where,
}

View File

@@ -379,9 +379,11 @@ export const fieldToSchemaMap: FieldToSchemaMap = {
),
},
hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) },
totalDocs: { type: GraphQLInt },
},
}),
args: {
count: { type: GraphQLBoolean },
limit: {
type: GraphQLInt,
},
@@ -402,7 +404,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = {
},
async resolve(parent, args, context: Context) {
const { collection } = field
const { limit, page, sort, where } = args
const { count = false, limit, page, sort, where } = args
const { req } = context
const draft = Boolean(args.draft ?? context.req.query?.draft)
@@ -429,7 +431,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = {
throw new Error('GraphQL with array of join.field.collection is not implemented')
}
const { docs } = await req.payload.find({
const { docs, totalDocs } = await req.payload.find({
collection,
depth: 0,
draft,
@@ -439,7 +441,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = {
locale: req.locale,
overrideAccess: false,
page,
pagination: false,
pagination: count ? true : false,
req,
sort,
where: fullWhere,
@@ -454,6 +456,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = {
return {
docs: shouldSlice ? docs.slice(0, -1) : docs,
hasNextPage: limit === 0 ? false : limit < docs.length,
...(count ? { totalDocs } : {}),
}
},
}

View File

@@ -239,6 +239,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
}),
args: {
draft: { type: GraphQLBoolean },
trash: { type: GraphQLBoolean },
where: { type: collection.graphQL.whereInputType },
...(config.localization
? {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.49.0",
"version": "3.50.0",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {
@@ -46,8 +46,8 @@
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-vue",
"version": "3.49.0",
"version": "3.50.0",
"description": "The official Vue SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "3.49.0",
"version": "3.50.0",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.49.0",
"version": "3.50.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -117,11 +117,11 @@
"@babel/preset-env": "7.27.2",
"@babel/preset-react": "7.27.1",
"@babel/preset-typescript": "7.27.1",
"@next/eslint-plugin-next": "15.3.2",
"@next/eslint-plugin-next": "15.4.4",
"@payloadcms/eslint-config": "workspace:*",
"@types/busboy": "1.5.4",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/uuid": "10.0.0",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"esbuild": "0.25.5",

View File

@@ -1,4 +1,11 @@
import type { DocumentTabConfig, DocumentTabServerProps, ServerProps } from 'payload'
import type {
DocumentTabConfig,
DocumentTabServerPropsOnly,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
} from 'payload'
import type React from 'react'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
@@ -9,27 +16,24 @@ import './index.scss'
export const baseClass = 'doc-tab'
export const DocumentTab: React.FC<
{ readonly Pill_Component?: React.FC } & DocumentTabConfig & DocumentTabServerProps
> = (props) => {
export const DefaultDocumentTab: React.FC<{
apiURL?: string
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
path?: string
permissions?: SanitizedPermissions
req: PayloadRequest
tabConfig: { readonly Pill_Component?: React.FC } & DocumentTabConfig
}> = (props) => {
const {
apiURL,
collectionConfig,
globalConfig,
href: tabHref,
i18n,
isActive: tabIsActive,
label,
newTab,
payload,
permissions,
Pill,
Pill_Component,
req,
tabConfig: { href: tabHref, isActive: tabIsActive, label, newTab, Pill, Pill_Component },
} = props
const { config } = payload
const { routes } = config
let href = typeof tabHref === 'string' ? tabHref : ''
let isActive = typeof tabIsActive === 'boolean' ? tabIsActive : false
@@ -38,7 +42,7 @@ export const DocumentTab: React.FC<
apiURL,
collection: collectionConfig,
global: globalConfig,
routes,
routes: req.payload.config.routes,
})
}
@@ -51,13 +55,13 @@ export const DocumentTab: React.FC<
const labelToRender =
typeof label === 'function'
? label({
t: i18n.t,
t: req.i18n.t,
})
: label
return (
<DocumentTabLink
adminRoute={routes.admin}
adminRoute={req.payload.config.routes.admin}
ariaLabel={labelToRender}
baseClass={baseClass}
href={href}
@@ -72,12 +76,14 @@ export const DocumentTab: React.FC<
{RenderServerComponent({
Component: Pill,
Fallback: Pill_Component,
importMap: payload.importMap,
importMap: req.payload.importMap,
serverProps: {
i18n,
payload,
i18n: req.i18n,
payload: req.payload,
permissions,
} satisfies ServerProps,
req,
user: req.user,
} satisfies DocumentTabServerPropsOnly,
})}
</Fragment>
) : null}

View File

@@ -1,8 +1,7 @@
import type { I18n } from '@payloadcms/translations'
import type {
DocumentTabClientProps,
DocumentTabServerPropsOnly,
Payload,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
@@ -12,7 +11,7 @@ import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerCompo
import React from 'react'
import { ShouldRenderTabs } from './ShouldRenderTabs.js'
import { DocumentTab } from './Tab/index.js'
import { DefaultDocumentTab } from './Tab/index.js'
import { getTabs } from './tabs/index.js'
import './index.scss'
@@ -21,12 +20,10 @@ const baseClass = 'doc-tabs'
export const DocumentTabs: React.FC<{
collectionConfig: SanitizedCollectionConfig
globalConfig: SanitizedGlobalConfig
i18n: I18n
payload: Payload
permissions: SanitizedPermissions
}> = (props) => {
const { collectionConfig, globalConfig, i18n, payload, permissions } = props
const { config } = payload
req: PayloadRequest
}> = ({ collectionConfig, globalConfig, permissions, req }) => {
const { config } = req.payload
const tabs = getTabs({
collectionConfig,
@@ -38,42 +35,46 @@ export const DocumentTabs: React.FC<{
<div className={baseClass}>
<div className={`${baseClass}__tabs-container`}>
<ul className={`${baseClass}__tabs`}>
{tabs?.map(({ tab, viewPath }, index) => {
const { condition } = tab || {}
{tabs?.map(({ tab: tabConfig, viewPath }, index) => {
const { condition } = tabConfig || {}
const meetsCondition =
!condition || condition({ collectionConfig, config, globalConfig, permissions })
!condition ||
condition({ collectionConfig, config, globalConfig, permissions, req })
if (!meetsCondition) {
return null
}
if (tab?.Component) {
if (tabConfig?.Component) {
return RenderServerComponent({
clientProps: {
path: viewPath,
} satisfies DocumentTabClientProps,
Component: tab.Component,
importMap: payload.importMap,
Component: tabConfig.Component,
importMap: req.payload.importMap,
key: `tab-${index}`,
serverProps: {
collectionConfig,
globalConfig,
i18n,
payload,
i18n: req.i18n,
payload: req.payload,
permissions,
req,
user: req.user,
} satisfies DocumentTabServerPropsOnly,
})
}
return (
<DocumentTab
<DefaultDocumentTab
collectionConfig={collectionConfig}
globalConfig={globalConfig}
key={`tab-${index}`}
path={viewPath}
{...{
...props,
...tab,
}}
permissions={permissions}
req={req}
tabConfig={tabConfig}
/>
)
})}

View File

@@ -1,6 +1,6 @@
import type { I18n } from '@payloadcms/translations'
import type {
Payload,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
@@ -18,11 +18,10 @@ export const DocumentHeader: React.FC<{
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
hideTabs?: boolean
i18n: I18n
payload: Payload
permissions: SanitizedPermissions
req: PayloadRequest
}> = (props) => {
const { collectionConfig, globalConfig, hideTabs, i18n, payload, permissions } = props
const { collectionConfig, globalConfig, hideTabs, permissions, req } = props
return (
<Gutter className={baseClass}>
@@ -31,9 +30,8 @@ export const DocumentHeader: React.FC<{
<DocumentTabs
collectionConfig={collectionConfig}
globalConfig={globalConfig}
i18n={i18n}
payload={payload}
permissions={permissions}
req={req}
/>
)}
</Gutter>

View File

@@ -137,9 +137,8 @@ export async function Account({ initPageResult, params, searchParams }: AdminVie
<DocumentHeader
collectionConfig={collectionConfig}
hideTabs
i18n={i18n}
payload={payload}
permissions={permissions}
req={req}
/>
<HydrateAuthProvider permissions={permissions} />
{RenderServerComponent({

View File

@@ -19,17 +19,14 @@ type RenderTrashViewArgs = {
redirectAfterRestore?: boolean
} & AdminViewServerProps
export const TrashView: React.FC<
{ query?: any } & Omit<RenderTrashViewArgs, 'enableRowSelections'>
> = async (args) => {
export const TrashView: React.FC<Omit<RenderTrashViewArgs, 'enableRowSelections'>> = async (
args,
) => {
try {
const { List: TrashList } = await renderListView({
...args,
enableRowSelections: true,
query: {
...(args.query || {}),
trash: true, // force trash view
},
trash: true,
viewType: 'trash',
})

View File

@@ -110,17 +110,18 @@ export const renderDocument = async ({
// Fetch the doc required for the view
let doc =
initialData ||
(await getDocumentData({
id: idFromArgs,
collectionSlug,
globalSlug,
locale,
payload,
req,
segments,
user,
}))
!idFromArgs && !globalSlug
? initialData || null
: await getDocumentData({
id: idFromArgs,
collectionSlug,
globalSlug,
locale,
payload,
req,
segments,
user,
})
if (isEditing && !doc) {
// If it's a collection document that doesn't exist, redirect to collection list
@@ -416,9 +417,8 @@ export const renderDocument = async ({
<DocumentHeader
collectionConfig={collectionConfig}
globalConfig={globalConfig}
i18n={i18n}
payload={payload}
permissions={permissions}
req={req}
/>
)}
<HydrateAuthProvider permissions={permissions} />

View File

@@ -5,6 +5,7 @@ import type {
PaginatedDocs,
PayloadRequest,
SanitizedCollectionConfig,
ViewTypes,
Where,
} from 'payload'
@@ -22,7 +23,9 @@ export const handleGroupBy = async ({
enableRowSelections,
query,
req,
trash = false,
user,
viewType,
where: whereWithMergedSearch,
}: {
clientConfig: ClientConfig
@@ -34,7 +37,9 @@ export const handleGroupBy = async ({
enableRowSelections?: boolean
query?: ListQuery
req: PayloadRequest
trash?: boolean
user: any
viewType?: ViewTypes
where: Where
}): Promise<{
columnState: Column[]
@@ -88,6 +93,7 @@ export const handleGroupBy = async ({
populate,
req,
sort: query?.groupBy,
trash,
where: whereWithMergedSearch,
})
@@ -127,6 +133,7 @@ export const handleGroupBy = async ({
// Note: if we wanted to enable table-by-table sorting, we could use this:
// sort: query?.queryByGroup?.[valueOrRelationshipID]?.sort,
sort: query?.sort,
trash,
user,
where: {
...(whereWithMergedSearch || {}),
@@ -136,10 +143,11 @@ export const handleGroupBy = async ({
},
})
let heading = valueOrRelationshipID || req.i18n.t('general:noValue')
let heading = valueOrRelationshipID
if (
groupByField?.type === 'relationship' &&
potentiallyPopulatedRelationship &&
typeof potentiallyPopulatedRelationship === 'object'
) {
heading =
@@ -147,14 +155,24 @@ export const handleGroupBy = async ({
valueOrRelationshipID
}
if (groupByField.type === 'date') {
if (groupByField.type === 'date' && valueOrRelationshipID) {
heading = formatDate({
date: String(heading),
date: String(valueOrRelationshipID),
i18n: req.i18n,
pattern: clientConfig.admin.dateFormat,
})
}
if (groupByField.type === 'checkbox') {
if (valueOrRelationshipID === true) {
heading = req.i18n.t('general:true')
}
if (valueOrRelationshipID === false) {
heading = req.i18n.t('general:false')
}
}
if (groupData.docs && groupData.docs.length > 0) {
const { columnState: newColumnState, Table: NewTable } = renderTable({
clientCollectionConfig,
@@ -166,13 +184,14 @@ export const handleGroupBy = async ({
enableRowSelections,
groupByFieldPath,
groupByValue: valueOrRelationshipID,
heading,
heading: heading || req.i18n.t('general:noValue'),
i18n: req.i18n,
key: `table-${valueOrRelationshipID}`,
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
payload: req.payload,
query,
useAsTitle: collectionConfig.admin.useAsTitle,
viewType,
})
// Only need to set `columnState` once, using the first table's column state

View File

@@ -1,20 +1,19 @@
import type {
AdminViewServerProps,
CollectionPreferences,
Column,
ColumnPreference,
ListQuery,
ListViewClientProps,
ListViewServerPropsOnly,
PaginatedDocs,
QueryPreset,
SanitizedCollectionPermission,
} from 'payload'
import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { renderFilters, renderTable, upsertPreferences } from '@payloadcms/ui/rsc'
import { notFound } from 'next/navigation.js'
import {
type AdminViewServerProps,
type CollectionPreferences,
type Column,
type ColumnPreference,
type ListQuery,
type ListViewClientProps,
type ListViewServerPropsOnly,
type PaginatedDocs,
type QueryPreset,
type SanitizedCollectionPermission,
} from 'payload'
import {
combineWhereConstraints,
formatAdminURL,
@@ -41,6 +40,10 @@ type RenderListViewArgs = {
query: ListQuery
redirectAfterDelete?: boolean
redirectAfterDuplicate?: boolean
/**
* @experimental This prop is subject to change in future releases.
*/
trash?: boolean
} & AdminViewServerProps
/**
@@ -67,6 +70,7 @@ export const renderListView = async (
params,
query: queryFromArgs,
searchParams,
trash,
viewType,
} = args
@@ -134,46 +138,25 @@ export const renderListView = async (
throw new Error('not-found')
}
let baseListFilter = undefined
if (typeof collectionConfig.admin?.baseListFilter === 'function') {
baseListFilter = await collectionConfig.admin.baseListFilter({
limit: query.limit,
page: query.page,
req,
sort: query.sort,
})
}
let whereCondition = mergeListSearchAndWhere({
collectionConfig,
search: typeof query?.search === 'string' ? query.search : undefined,
where: combineWhereConstraints([query?.where, baseListFilter]),
const baseFilterConstraint = await (
collectionConfig.admin?.baseFilter ?? collectionConfig.admin?.baseListFilter
)?.({
limit: query.limit,
page: query.page,
req,
sort: query.sort,
})
if (query?.trash === true) {
whereCondition = {
and: [
whereCondition,
{
deletedAt: {
exists: true,
},
},
],
}
}
let queryPreset: QueryPreset | undefined
let queryPresetPermissions: SanitizedCollectionPermission | undefined
let whereWithMergedSearch = mergeListSearchAndWhere({
collectionConfig,
search: typeof query?.search === 'string' ? query.search : undefined,
where: combineWhereConstraints([query?.where, baseListFilter]),
where: combineWhereConstraints([query?.where, baseFilterConstraint]),
})
if (query?.trash === true) {
if (trash === true) {
whereWithMergedSearch = {
and: [
whereWithMergedSearch,
@@ -209,56 +192,81 @@ export const renderListView = async (
}
}
let data: PaginatedDocs | undefined
let Table: React.ReactNode | React.ReactNode[] = null
let columnState: Column[] = []
let data: PaginatedDocs = {
// no results default
docs: [],
hasNextPage: false,
hasPrevPage: false,
limit: query.limit,
nextPage: null,
page: 1,
pagingCounter: 0,
prevPage: null,
totalDocs: 0,
totalPages: 0,
}
if (collectionConfig.admin.groupBy && query.groupBy) {
;({ columnState, data, Table } = await handleGroupBy({
clientConfig,
collectionConfig,
collectionSlug,
columns: collectionPreferences?.columns,
customCellProps,
drawerSlug,
enableRowSelections,
query,
req,
user,
where: whereWithMergedSearch,
}))
} else {
data = await req.payload.find({
collection: collectionSlug,
depth: 0,
draft: true,
fallbackLocale: false,
includeLockStatus: true,
limit: query?.limit ? Number(query.limit) : undefined,
locale: req.locale,
overrideAccess: false,
page: query?.page ? Number(query.page) : undefined,
req,
sort: query?.sort,
trash: query?.trash === true,
user,
where: whereWithMergedSearch,
})
;({ columnState, Table } = renderTable({
clientCollectionConfig: clientConfig.collections.find((c) => c.slug === collectionSlug),
collectionConfig,
columns: collectionPreferences?.columns,
customCellProps,
data,
drawerSlug,
enableRowSelections,
i18n: req.i18n,
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
payload: req.payload,
query,
useAsTitle: collectionConfig.admin.useAsTitle,
viewType,
}))
try {
if (collectionConfig.admin.groupBy && query.groupBy) {
;({ columnState, data, Table } = await handleGroupBy({
clientConfig,
collectionConfig,
collectionSlug,
columns: collectionPreferences?.columns,
customCellProps,
drawerSlug,
enableRowSelections,
query,
req,
trash,
user,
viewType,
where: whereWithMergedSearch,
}))
} else {
data = await req.payload.find({
collection: collectionSlug,
depth: 0,
draft: true,
fallbackLocale: false,
includeLockStatus: true,
limit: query?.limit ? Number(query.limit) : undefined,
locale: req.locale,
overrideAccess: false,
page: query?.page ? Number(query.page) : undefined,
req,
sort: query?.sort,
trash,
user,
where: whereWithMergedSearch,
})
;({ columnState, Table } = renderTable({
clientCollectionConfig: clientConfig.collections.find((c) => c.slug === collectionSlug),
collectionConfig,
columns: collectionPreferences?.columns,
customCellProps,
data,
drawerSlug,
enableRowSelections,
i18n: req.i18n,
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
payload: req.payload,
query,
useAsTitle: collectionConfig.admin.useAsTitle,
viewType,
}))
}
} catch (err) {
if (err.name !== 'QueryError') {
// QueryErrors are expected when a user filters by a field they do not have access to
req.payload.logger.error({
err,
msg: `There was an error fetching the list view data for collection ${collectionSlug}`,
})
throw err
}
}
const renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap)

View File

@@ -15,7 +15,7 @@ export const SetStepNav: React.FC<{
readonly isTrashed?: boolean
versionToCreatedAtFormatted?: string
versionToID?: string
versionToUseAsTitle?: string
versionToUseAsTitle?: Record<string, string> | string
}> = ({
id,
collectionConfig,
@@ -54,7 +54,7 @@ export const SetStepNav: React.FC<{
? versionToUseAsTitle?.[locale.code] || docLabel
: versionToUseAsTitle
} else if (useAsTitle === 'id') {
docLabel = versionToID
docLabel = String(id)
}
const docBasePath: `/${string}` = isTrashed

View File

@@ -17,7 +17,13 @@ import {
type SanitizedFieldPermissions,
type VersionField,
} from 'payload'
import { fieldIsID, fieldShouldBeLocalized, getUniqueListBy, tabHasName } from 'payload/shared'
import {
fieldIsID,
fieldShouldBeLocalized,
getFieldPermissions,
getUniqueListBy,
tabHasName,
} from 'payload/shared'
import { diffComponents } from './fields/index.js'
import { getFieldPathsModified } from './utilities/getFieldPathsModified.js'
@@ -223,21 +229,16 @@ const buildVersionField = ({
BuildVersionFieldsArgs,
'fields' | 'parentIndexPath' | 'versionFromSiblingData' | 'versionToSiblingData'
>): BaseVersionField | null => {
const fieldName: null | string = 'name' in field ? field.name : null
const { permissions, read: hasReadPermission } = getFieldPermissions({
field,
operation: 'read',
parentName: parentPath?.includes('.')
? parentPath.split('.')[parentPath.split('.').length - 1]
: parentPath,
permissions: fieldPermissions,
})
const hasPermission =
fieldPermissions === true ||
!fieldName ||
fieldPermissions?.[fieldName] === true ||
fieldPermissions?.[fieldName]?.read
const subFieldPermissions =
fieldPermissions === true ||
!fieldName ||
fieldPermissions?.[fieldName] === true ||
fieldPermissions?.[fieldName]?.fields
if (!hasPermission) {
if (!hasReadPermission) {
return null
}
@@ -292,13 +293,29 @@ const buildVersionField = ({
parentPath,
parentSchemaPath,
})
let tabPermissions: typeof fieldPermissions = undefined
if (typeof permissions === 'boolean') {
tabPermissions = permissions
} else if (permissions && typeof permissions === 'object') {
if ('name' in tab) {
tabPermissions =
typeof permissions.fields?.[tab.name] === 'object'
? permissions.fields?.[tab.name].fields
: permissions.fields?.[tab.name]
} else {
tabPermissions = permissions.fields
}
}
const tabVersion = {
name: 'name' in tab ? tab.name : null,
fields: buildVersionFields({
clientSchemaMap,
customDiffComponents,
entitySlug,
fieldPermissions,
fieldPermissions: tabPermissions,
fields: tab.fields,
i18n,
modifiedOnly,
@@ -324,6 +341,13 @@ const buildVersionField = ({
}
} // At this point, we are dealing with a `row`, `collapsible`, etc
else if ('fields' in field) {
let subfieldPermissions: typeof fieldPermissions = undefined
if (typeof permissions === 'boolean') {
subfieldPermissions = permissions
} else if (permissions && typeof permissions === 'object') {
subfieldPermissions = permissions.fields
}
if (field.type === 'array' && (valueTo || valueFrom)) {
const maxLength = Math.max(
Array.isArray(valueTo) ? valueTo.length : 0,
@@ -339,7 +363,7 @@ const buildVersionField = ({
clientSchemaMap,
customDiffComponents,
entitySlug,
fieldPermissions,
fieldPermissions: subfieldPermissions,
fields: field.fields,
i18n,
modifiedOnly,
@@ -363,7 +387,7 @@ const buildVersionField = ({
clientSchemaMap,
customDiffComponents,
entitySlug,
fieldPermissions,
fieldPermissions: subfieldPermissions,
fields: field.fields,
i18n,
modifiedOnly,
@@ -421,11 +445,24 @@ const buildVersionField = ({
}
}
let blockPermissions: typeof fieldPermissions = undefined
if (permissions === true) {
blockPermissions = true
} else {
const permissionsBlockSpecific = permissions?.blocks?.[blockSlugToMatch]
if (permissionsBlockSpecific === true) {
blockPermissions = true
} else {
blockPermissions = permissionsBlockSpecific?.fields
}
}
baseVersionField.rows[i] = buildVersionFields({
clientSchemaMap,
customDiffComponents,
entitySlug,
fieldPermissions,
fieldPermissions: blockPermissions,
fields,
i18n,
modifiedOnly,
@@ -459,7 +496,7 @@ const buildVersionField = ({
*/
diffMethod: 'diffWordsWithSpace',
field: clientField,
fieldPermissions: subFieldPermissions,
fieldPermissions: typeof permissions === 'object' ? permissions.fields : permissions,
parentIsLocalized,
nestingLevel: nestingLevel ? nestingLevel : undefined,

View File

@@ -18,12 +18,12 @@ export const generateLabelFromValue = ({
value: PopulatedRelationshipValue
}): string => {
let relatedDoc: TypeWithID
let relationTo: string = field.relationTo as string
let valueToReturn: string = ''
const relationTo: string = 'relationTo' in value ? value.relationTo : (field.relationTo as string)
if (typeof value === 'object' && 'relationTo' in value) {
relatedDoc = value.value
relationTo = value.relationTo
} else {
// Non-polymorphic relationship
relatedDoc = value

View File

@@ -411,6 +411,11 @@ export async function VersionView(props: DocumentViewServerProps) {
})
}
const useAsTitleFieldName = collectionConfig?.admin?.useAsTitle || 'id'
const versionToUseAsTitle =
useAsTitleFieldName === 'id'
? String(versionTo.parent)
: versionTo.version?.[useAsTitleFieldName]
return (
<DefaultVersionView
canUpdate={docPermissions?.update}
@@ -425,7 +430,7 @@ export async function VersionView(props: DocumentViewServerProps) {
VersionToCreatedAtLabel={formatPill({ doc: versionTo, labelStyle: 'pill' })}
versionToID={versionTo.id}
versionToStatus={versionTo.version?._status}
versionToUseAsTitle={versionTo[collectionConfig?.admin?.useAsTitle || 'id']}
versionToUseAsTitle={versionToUseAsTitle}
/>
)
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/payload-cloud",
"version": "3.49.0",
"version": "3.50.0",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "3.49.0",
"version": "3.50.0",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",

View File

@@ -68,6 +68,9 @@ export type FieldPaths = {
path: string
}
/**
* TODO: This should be renamed to `FieldComponentServerProps` or similar
*/
export type ServerComponentProps = {
clientField: ClientFieldWithOptionalType
clientFieldSchemaMap: ClientFieldSchemaMap

View File

@@ -2,6 +2,7 @@ import type { SanitizedPermissions } from '../../auth/types.js'
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { PayloadComponent, SanitizedConfig, ServerProps } from '../../config/types.js'
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
import type { PayloadRequest } from '../../types/index.js'
import type { Data, DocumentSlots, FormState } from '../types.js'
import type { InitPageResult, ViewTypes } from './index.js'
@@ -50,6 +51,7 @@ export type DocumentTabServerPropsOnly = {
readonly collectionConfig?: SanitizedCollectionConfig
readonly globalConfig?: SanitizedGlobalConfig
readonly permissions: SanitizedPermissions
readonly req: PayloadRequest
} & ServerProps
export type DocumentTabClientProps = {
@@ -60,9 +62,13 @@ export type DocumentTabServerProps = DocumentTabClientProps & DocumentTabServerP
export type DocumentTabCondition = (args: {
collectionConfig: SanitizedCollectionConfig
/**
* @deprecated: Use `req.payload.config` instead. This will be removed in v4.
*/
config: SanitizedConfig
globalConfig: SanitizedGlobalConfig
permissions: SanitizedPermissions
req: PayloadRequest
}) => boolean
// Everything is optional because we merge in the defaults

View File

@@ -1,6 +1,6 @@
export const isUserLocked = (date: number): boolean => {
export const isUserLocked = (date: Date): boolean => {
if (!date) {
return false
}
return date > Date.now()
return date.getTime() > Date.now()
}

View File

@@ -1,5 +1,3 @@
import { v4 as uuid } from 'uuid'
import type {
AuthOperationsFromCollectionSlug,
Collection,
@@ -24,7 +22,7 @@ import { getFieldsToSign } from '../getFieldsToSign.js'
import { getLoginOptions } from '../getLoginOptions.js'
import { isUserLocked } from '../isUserLocked.js'
import { jwtSign } from '../jwt.js'
import { removeExpiredSessions } from '../removeExpiredSessions.js'
import { addSessionToUser } from '../sessions.js'
import { authenticateLocalStrategy } from '../strategies/local/authenticate.js'
import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts.js'
import { resetLoginAttempts } from '../strategies/local/resetLoginAttempts.js'
@@ -50,6 +48,11 @@ type CheckLoginPermissionArgs = {
user: any
}
/**
* Throws an error if the user is locked or does not exist.
* This does not check the login attempts, only the lock status. Whoever increments login attempts
* is responsible for locking the user properly, not whoever checks the login permission.
*/
export const checkLoginPermission = ({
loggingInWithUsername,
req,
@@ -59,7 +62,7 @@ export const checkLoginPermission = ({
throw new AuthenticationError(req.t, Boolean(loggingInWithUsername))
}
if (isUserLocked(new Date(user.lockUntil).getTime())) {
if (isUserLocked(new Date(user.lockUntil))) {
throw new LockedAuth(req.t)
}
}
@@ -206,11 +209,11 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
where: whereConstraint,
})
let user = await payload.db.findOne<any>({
let user = (await payload.db.findOne<TypedUser>({
collection: collectionConfig.slug,
req,
where: whereConstraint,
})
})) as TypedUser
checkLoginPermission({
loggingInWithUsername: Boolean(canLoginWithUsername && sanitizedUsername),
@@ -230,9 +233,16 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
if (maxLoginAttemptsEnabled) {
await incrementLoginAttempts({
collection: collectionConfig,
doc: user,
payload: req.payload,
req,
user,
})
// Re-check login permissions and max attempts after incrementing attempts, in case parallel updates occurred
checkLoginPermission({
loggingInWithUsername: Boolean(canLoginWithUsername && sanitizedUsername),
req,
user,
})
}
@@ -243,40 +253,45 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
throw new UnverifiedEmail({ t: req.t })
}
/*
* Correct password accepted - recheck that the account didn't
* get locked by parallel bad attempts in the meantime.
*/
if (maxLoginAttemptsEnabled) {
const { lockUntil, loginAttempts } = (await payload.db.findOne<TypedUser>({
collection: collectionConfig.slug,
req,
select: {
lockUntil: true,
loginAttempts: true,
},
where: { id: { equals: user.id } },
}))!
user.lockUntil = lockUntil
user.loginAttempts = loginAttempts
checkLoginPermission({
req,
user,
})
}
const fieldsToSignArgs: Parameters<typeof getFieldsToSign>[0] = {
collectionConfig,
email: sanitizedEmail!,
user,
}
if (collectionConfig.auth.useSessions) {
// Add session to user
const newSessionID = uuid()
const now = new Date()
const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000
const expiresAt = new Date(now.getTime() + tokenExpInMs)
const { sid } = await addSessionToUser({
collectionConfig,
payload,
req,
user,
})
const session = { id: newSessionID, createdAt: now, expiresAt }
if (!user.sessions?.length) {
user.sessions = [session]
} else {
user.sessions = removeExpiredSessions(user.sessions)
user.sessions.push(session)
}
await payload.db.updateOne({
id: user.id,
collection: collectionConfig.slug,
data: user,
req,
returning: false,
})
user.collection = collectionConfig.slug
user._strategy = 'local-jwt'
fieldsToSignArgs.sid = newSessionID
if (sid) {
fieldsToSignArgs.sid = sid
}
const fieldsToSign = getFieldsToSign(fieldsToSignArgs)

View File

@@ -1,5 +1,4 @@
import url from 'url'
import { v4 as uuid } from 'uuid'
import type { Collection } from '../../collections/config/types.js'
import type { Document, PayloadRequest } from '../../types/index.js'
@@ -11,7 +10,7 @@ import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { getFieldsToSign } from '../getFieldsToSign.js'
import { jwtSign } from '../jwt.js'
import { removeExpiredSessions } from '../removeExpiredSessions.js'
import { removeExpiredSessions } from '../sessions.js'
export type Result = {
exp: number
@@ -74,11 +73,10 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
const parsedURL = url.parse(args.req.url!)
const isGraphQL = parsedURL.pathname === config.routes.graphQL
const user = await args.req.payload.findByID({
id: args.req.user.id,
collection: args.req.user.collection,
depth: isGraphQL ? 0 : args.collection.config.auth.depth,
req: args.req,
let user = await req.payload.db.findOne<any>({
collection: collectionConfig.slug,
req,
where: { id: { equals: args.req.user.id } },
})
const sid = args.req.user._sid
@@ -88,7 +86,7 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
throw new Forbidden(args.req.t)
}
const existingSession = user.sessions.find(({ id }) => id === sid)
const existingSession = user.sessions.find(({ id }: { id: number }) => id === sid)
const now = new Date()
const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000
@@ -106,6 +104,13 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
})
}
user = await req.payload.findByID({
id: user.id,
collection: collectionConfig.slug,
depth: isGraphQL ? 0 : args.collection.config.auth.depth,
req: args.req,
})
if (user) {
user.collection = args.req.user.collection
user._strategy = args.req.user._strategy

View File

@@ -1,10 +0,0 @@
import type { UserSession } from './types.js'
export const removeExpiredSessions = (sessions: UserSession[]) => {
const now = new Date()
return sessions.filter(({ expiresAt }) => {
const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt)
return expiry > now
})
}

View File

@@ -0,0 +1,67 @@
import { v4 as uuid } from 'uuid'
import type { SanitizedCollectionConfig } from '../collections/config/types.js'
import type { TypedUser } from '../index.js'
import type { Payload, PayloadRequest } from '../types/index.js'
import type { UserSession } from './types.js'
/**
* Removes expired sessions from an array of sessions
*/
export const removeExpiredSessions = (sessions: UserSession[]) => {
const now = new Date()
return sessions.filter(({ expiresAt }) => {
const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt)
return expiry > now
})
}
/**
* Adds a session to the user and removes expired sessions
* @returns The session ID (sid) if sessions are used
*/
export const addSessionToUser = async ({
collectionConfig,
payload,
req,
user,
}: {
collectionConfig: SanitizedCollectionConfig
payload: Payload
req: PayloadRequest
user: TypedUser
}): Promise<{ sid?: string }> => {
let sid: string | undefined
if (collectionConfig.auth.useSessions) {
// Add session to user
sid = uuid()
const now = new Date()
const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000
const expiresAt = new Date(now.getTime() + tokenExpInMs)
const session = { id: sid, createdAt: now, expiresAt }
if (!user.sessions?.length) {
user.sessions = [session]
} else {
user.sessions = removeExpiredSessions(user.sessions)
user.sessions.push(session)
}
await payload.db.updateOne({
id: user.id,
collection: collectionConfig.slug,
data: user,
req,
returning: false,
})
user.collection = collectionConfig.slug
user._strategy = 'local-jwt'
}
return {
sid,
}
}

View File

@@ -1,59 +1,154 @@
import type { SanitizedCollectionConfig, TypeWithID } from '../../../collections/config/types.js'
import type { JsonObject, Payload } from '../../../index.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { PayloadRequest } from '../../../types/index.js'
import { type JsonObject, type Payload, type TypedUser } from '../../../index.js'
import { isUserLocked } from '../../isUserLocked.js'
type Args = {
collection: SanitizedCollectionConfig
doc: Record<string, unknown> & TypeWithID
payload: Payload
req: PayloadRequest
user: TypedUser
}
// Note: this function does not use req in most updates, as we want those to be visible in parallel requests that are on a different
// transaction. At the same time, we want updates from parallel requests to be visible here.
export const incrementLoginAttempts = async ({
collection,
doc,
payload,
req,
user,
}: Args): Promise<void> => {
const {
auth: { lockTime, maxLoginAttempts },
} = collection
if ('lockUntil' in doc && typeof doc.lockUntil === 'string') {
const lockUntil = new Date(doc.lockUntil).getTime()
const currentTime = Date.now()
let updatedLockUntil: null | string = null
let updatedLoginAttempts: null | number = null
if (user.lockUntil && !isUserLocked(new Date(user.lockUntil))) {
// Expired lock, restart count at 1
if (lockUntil < Date.now()) {
await payload.update({
id: doc.id,
collection: collection.slug,
data: {
lockUntil: null,
loginAttempts: 1,
},
depth: 0,
req,
})
const updatedUser = await payload.db.updateOne({
id: user.id,
collection: collection.slug,
data: {
lockUntil: null,
loginAttempts: 1,
},
req,
select: {
lockUntil: true,
loginAttempts: true,
},
})
updatedLockUntil = updatedUser.lockUntil
updatedLoginAttempts = updatedUser.loginAttempts
user.lockUntil = updatedLockUntil
} else {
const data: JsonObject = {
loginAttempts: {
$inc: 1,
},
}
return
const willReachMaxAttempts =
typeof user.loginAttempts === 'number' && user.loginAttempts + 1 >= maxLoginAttempts
// Lock the account if at max attempts and not already locked
if (willReachMaxAttempts) {
const lockUntil = new Date(currentTime + lockTime).toISOString()
data.lockUntil = lockUntil
}
const updatedUser = await payload.db.updateOne({
id: user.id,
collection: collection.slug,
data,
select: {
lockUntil: true,
loginAttempts: true,
},
})
updatedLockUntil = updatedUser.lockUntil
updatedLoginAttempts = updatedUser.loginAttempts
}
const data: JsonObject = {
loginAttempts: Number(doc.loginAttempts) + 1,
if (updatedLoginAttempts === null) {
throw new Error('Failed to update login attempts or lockUntil for user')
}
// Lock the account if at max attempts and not already locked
if (typeof doc.loginAttempts === 'number' && doc.loginAttempts + 1 >= maxLoginAttempts) {
const lockUntil = new Date(Date.now() + lockTime).toISOString()
data.lockUntil = lockUntil
}
// Check updated latest lockUntil and loginAttempts in case there were parallel updates
const reachedMaxAttemptsForCurrentUser =
typeof updatedLoginAttempts === 'number' && updatedLoginAttempts - 1 >= maxLoginAttempts
await payload.update({
id: doc.id,
collection: collection.slug,
data,
depth: 0,
req,
})
const reachedMaxAttemptsForNextUser =
typeof updatedLoginAttempts === 'number' && updatedLoginAttempts >= maxLoginAttempts
if (reachedMaxAttemptsForCurrentUser) {
user.lockUntil = updatedLockUntil
}
user.loginAttempts = updatedLoginAttempts - 1 // -1, as the updated increment is applied for the *next* login attempt, not the current one
if (
reachedMaxAttemptsForNextUser &&
(!updatedLockUntil || !isUserLocked(new Date(updatedLockUntil)))
) {
// If lockUntil reached max login attempts due to multiple parallel attempts but user was not locked yet,
const newLockUntil = new Date(currentTime + lockTime).toISOString()
await payload.db.updateOne({
id: user.id,
collection: collection.slug,
data: {
lockUntil: newLockUntil,
},
returning: false,
})
if (reachedMaxAttemptsForCurrentUser) {
user.lockUntil = newLockUntil
}
if (collection.auth.useSessions) {
// Remove all active sessions that have been created in a 20 second window. This protects
// against brute force attacks - example: 99 incorrect, 1 correct parallel login attempts.
// The correct login attempt will be finished first, as it's faster due to not having to perform
// an additional db update here.
// However, this request (the incorrect login attempt request) can kill the successful login attempt here.
// Fetch user sessions separately (do not do this in the updateOne select in order to preserve the returning: true db call optimization)
const currentUser = await payload.db.findOne<TypedUser>({
collection: collection.slug,
select: {
sessions: true,
},
where: {
id: {
equals: user.id,
},
},
})
if (currentUser?.sessions?.length) {
// Does not hurt also removing expired sessions
currentUser.sessions = currentUser.sessions.filter((session) => {
const sessionCreatedAt = new Date(session.createdAt)
const twentySecondsAgo = new Date(currentTime - 20000)
// Remove sessions created within the last 20 seconds
return sessionCreatedAt <= twentySecondsAgo
})
user.sessions = currentUser.sessions
await payload.db.updateOne({
id: user.id,
collection: collection.slug,
data: user,
returning: false,
})
}
}
}
}

View File

@@ -21,15 +21,14 @@ export const resetLoginAttempts = async ({
) {
return
}
await payload.update({
await payload.db.updateOne({
id: doc.id,
collection: collection.slug,
data: {
lockUntil: null,
loginAttempts: 0,
},
depth: 0,
overrideAccess: true,
req,
returning: false,
})
}

View File

@@ -29,7 +29,7 @@ export type ServerOnlyCollectionProperties = keyof Pick<
export type ServerOnlyCollectionAdminProperties = keyof Pick<
SanitizedCollectionConfig['admin'],
'baseListFilter' | 'components' | 'hidden'
'baseFilter' | 'baseListFilter' | 'components' | 'hidden'
>
export type ServerOnlyUploadProperties = keyof Pick<
@@ -94,6 +94,7 @@ const serverOnlyUploadProperties: Partial<ServerOnlyUploadProperties>[] = [
const serverOnlyCollectionAdminProperties: Partial<ServerOnlyCollectionAdminProperties>[] = [
'hidden',
'baseFilter',
'baseListFilter',
'components',
// 'preview' is handled separately

View File

@@ -85,6 +85,7 @@ export type HookOperationType =
| 'readDistinct'
| 'refresh'
| 'resetPassword'
| 'restoreVersion'
| 'update'
type CreateOrUpdateOperation = Extract<HookOperationType, 'create' | 'update'>
@@ -269,7 +270,7 @@ export type EnableFoldersOptions = {
debug?: boolean
}
export type BaseListFilter = (args: {
export type BaseFilter = (args: {
limit: number
locale?: TypedLocale
page: number
@@ -277,7 +278,31 @@ export type BaseListFilter = (args: {
sort: string
}) => null | Promise<null | Where> | Where
/**
* @deprecated Use `BaseFilter` instead.
*/
export type BaseListFilter = BaseFilter
export type CollectionAdminOptions = {
/**
* Defines a default base filter which will be applied in the following parts of the admin panel:
* - List View
* - Relationship fields for internal links within the Lexical editor
*
* This is especially useful for plugins like multi-tenant. For example,
* a user may have access to multiple tenants, but should only see content
* related to the currently active or selected tenant in those places.
*/
baseFilter?: BaseFilter
/**
* @deprecated Use `baseFilter` instead. If both are defined,
* `baseFilter` will take precedence. This property remains only
* for backward compatibility and may be removed in a future version.
*
* Originally, `baseListFilter` was intended to filter only the List View
* in the admin panel. However, base filtering is often required in other areas
* such as internal link relationships in the Lexical editor.
*/
baseListFilter?: BaseListFilter
/**
* Custom admin components
@@ -687,7 +712,7 @@ export type AuthCollection = {
}
export type TypeWithID = {
deletedAt?: string
deletedAt?: null | string
docId?: any
id: number | string
}
@@ -695,7 +720,7 @@ export type TypeWithID = {
export type TypeWithTimestamps = {
[key: string]: unknown
createdAt: string
deletedAt?: string
deletedAt?: null | string
id: number | string
updatedAt: string
}

View File

@@ -8,13 +8,15 @@ import { countOperation } from '../operations/count.js'
export const countHandler: PayloadHandler = async (req) => {
const collection = getRequestCollection(req)
const { where } = req.query as {
const { trash, where } = req.query as {
trash?: string
where?: Where
}
const result = await countOperation({
collection,
req,
trash: trash === 'true',
where,
})

View File

@@ -11,13 +11,14 @@ import { findDistinctOperation } from '../operations/findDistinct.js'
export const findDistinctHandler: PayloadHandler = async (req) => {
const collection = getRequestCollection(req)
const { depth, field, limit, page, sort, where } = req.query as {
const { depth, field, limit, page, sort, trash, where } = req.query as {
depth?: string
field?: string
limit?: string
page?: string
sort?: string
sortOrder?: string
trash?: string
where?: Where
}
@@ -33,6 +34,7 @@ export const findDistinctHandler: PayloadHandler = async (req) => {
page: isNumber(page) ? Number(page) : undefined,
req,
sort: typeof sort === 'string' ? sort.split(',') : undefined,
trash: trash === 'true',
where,
})

View File

@@ -9,7 +9,7 @@ import { docAccessHandler } from './docAccess.js'
import { duplicateHandler } from './duplicate.js'
import { findHandler } from './find.js'
import { findByIDHandler } from './findByID.js'
import { findDistinctHandler } from './findDistinct.js'
// import { findDistinctHandler } from './findDistinct.js'
import { findVersionByIDHandler } from './findVersionByID.js'
import { findVersionsHandler } from './findVersions.js'
import { previewHandler } from './preview.js'

View File

@@ -7,6 +7,7 @@ import { executeAccess } from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js'
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { buildAfterOperation } from './utils.js'
@@ -15,6 +16,7 @@ export type Arguments = {
disableErrors?: boolean
overrideAccess?: boolean
req?: PayloadRequest
trash?: boolean
where?: Where
}
@@ -47,6 +49,7 @@ export const countOperation = async <TSlug extends CollectionSlug>(
disableErrors,
overrideAccess,
req,
trash = false,
where,
} = args
@@ -71,9 +74,16 @@ export const countOperation = async <TSlug extends CollectionSlug>(
let result: { totalDocs: number }
const fullWhere = combineQueries(where!, accessResult!)
let fullWhere = combineQueries(where!, accessResult!)
sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere })
// Exclude trashed documents when trash: false
fullWhere = appendNonTrashedFilter({
enableTrash: collectionConfig.trash,
trash,
where: fullWhere,
})
await validateQueryPaths({
collectionConfig,
overrideAccess: overrideAccess!,

View File

@@ -291,6 +291,7 @@ export const createOperation = async <
autosave,
collection: collectionConfig,
docWithLocales: result,
operation: 'create',
payload,
publishSpecificLocale,
req,

View File

@@ -12,6 +12,7 @@ import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js'
import { APIError } from '../../errors/APIError.js'
import { Forbidden } from '../../errors/Forbidden.js'
import { relationshipPopulationPromise } from '../../fields/hooks/afterRead/relationshipPopulationPromise.js'
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
import { getFieldByPath } from '../../utilities/getFieldByPath.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { buildAfterOperation } from './utils.js'
@@ -29,6 +30,7 @@ export type Arguments = {
req?: PayloadRequest
showHiddenFields?: boolean
sort?: Sort
trash?: boolean
where?: Where
}
export const findDistinctOperation = async (
@@ -60,6 +62,7 @@ export const findDistinctOperation = async (
overrideAccess,
populate,
showHiddenFields = false,
trash = false,
where,
} = args
@@ -96,9 +99,16 @@ export const findDistinctOperation = async (
// Find Distinct
// /////////////////////////////////////
const fullWhere = combineQueries(where!, accessResult!)
let fullWhere = combineQueries(where!, accessResult!)
sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere })
// Exclude trashed documents when trash: false
fullWhere = appendNonTrashedFilter({
enableTrash: collectionConfig.trash,
trash,
where: fullWhere,
})
await validateQueryPaths({
collectionConfig,
overrideAccess: overrideAccess!,

View File

@@ -41,6 +41,15 @@ export type Options<TSlug extends CollectionSlug> = {
* Recommended to pass when using the Local API from hooks, as usually you want to execute the operation within the current transaction.
*/
req?: Partial<PayloadRequest>
/**
* When set to `true`, the query will include both normal and trashed documents.
* To query only trashed documents, pass `trash: true` and combine with a `where` clause filtering by `deletedAt`.
* By default (`false`), the query will only include normal documents and exclude those with a `deletedAt` field.
*
* This argument has no effect unless `trash` is enabled on the collection.
* @default false
*/
trash?: boolean
/**
* If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks.
*/
@@ -55,7 +64,13 @@ export async function countLocal<TSlug extends CollectionSlug>(
payload: Payload,
options: Options<TSlug>,
): Promise<{ totalDocs: number }> {
const { collection: collectionSlug, disableErrors, overrideAccess = true, where } = options
const {
collection: collectionSlug,
disableErrors,
overrideAccess = true,
trash = false,
where,
} = options
const collection = payload.collections[collectionSlug]
@@ -70,6 +85,7 @@ export async function countLocal<TSlug extends CollectionSlug>(
disableErrors,
overrideAccess,
req: await createLocalReq(options as CreateLocalReqOptions, payload),
trash,
where,
})
}

View File

@@ -83,6 +83,15 @@ export type Options<
* @example ['group', '-createdAt'] // sort by 2 fields, ASC group and DESC createdAt
*/
sort?: Sort
/**
* When set to `true`, the query will include both normal and trashed documents.
* To query only trashed documents, pass `trash: true` and combine with a `where` clause filtering by `deletedAt`.
* By default (`false`), the query will only include normal documents and exclude those with a `deletedAt` field.
*
* This argument has no effect unless `trash` is enabled on the collection.
* @default false
*/
trash?: boolean
/**
* If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks.
*/
@@ -111,6 +120,7 @@ export async function findDistinct<
populate,
showHiddenFields,
sort,
trash = false,
where,
} = options
const collection = payload.collections[collectionSlug]
@@ -133,6 +143,7 @@ export async function findDistinct<
req: await createLocalReq(options as CreateLocalReqOptions, payload),
showHiddenFields,
sort,
trash,
where,
}) as Promise<PaginatedDistinctDocs<Record<TField, DataFromCollectionSlug<TSlug>[TField]>>>
}

View File

@@ -10,15 +10,23 @@ import { combineQueries } from '../../database/combineQueries.js'
import { APIError, Forbidden, NotFound } from '../../errors/index.js'
import { afterChange } from '../../fields/hooks/afterChange/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { beforeChange } from '../../fields/hooks/beforeChange/index.js'
import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { sanitizeSelect } from '../../utilities/sanitizeSelect.js'
import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion.js'
import { saveVersion } from '../../versions/saveVersion.js'
import { buildAfterOperation } from './utils.js'
export type Arguments = {
collection: Collection
currentDepth?: number
depth?: number
disableErrors?: boolean
disableTransaction?: boolean
draft?: boolean
id: number | string
overrideAccess?: boolean
@@ -35,7 +43,7 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
id,
collection: { config: collectionConfig },
depth,
draft,
draft: draftArg = false,
overrideAccess = false,
populate,
req,
@@ -45,6 +53,25 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
} = args
try {
const shouldCommit = !args.disableTransaction && (await initTransaction(args.req))
// /////////////////////////////////////
// beforeOperation - Collection
// /////////////////////////////////////
if (args.collection.config.hooks?.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'restoreVersion',
req: args.req,
})) || args
}
}
if (!id) {
throw new APIError('Missing ID of version to restore.', httpStatus.BAD_REQUEST)
}
@@ -68,7 +95,7 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
throw new NotFound(req.t)
}
const parentDocID = rawVersion.parent
const { parent: parentDocID, version: versionToRestoreWithLocales } = rawVersion
// /////////////////////////////////////
// Access
@@ -90,6 +117,7 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
where: combineQueries({ id: { equals: parentDocID } }, accessResults),
}
// Get the document from the non versioned collection
const doc = await req.payload.db.findOne(findOneArgs)
if (!doc && !hasWherePolicy) {
@@ -109,7 +137,6 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
// /////////////////////////////////////
// fetch previousDoc
// /////////////////////////////////////
const prevDocWithLocales = await getLatestCollectionVersion({
id: parentDocID,
config: collectionConfig,
@@ -118,6 +145,109 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
req,
})
// originalDoc with hoisted localized data
const originalDoc = await afterRead({
collection: collectionConfig,
context: req.context,
depth: 0,
doc: deepCopyObjectSimple(prevDocWithLocales),
draft: draftArg,
fallbackLocale: null,
global: null,
locale: locale!,
overrideAccess: true,
req,
showHiddenFields: true,
})
// version data with hoisted localized data
const prevVersionDoc = await afterRead({
collection: collectionConfig,
context: req.context,
depth: 0,
doc: deepCopyObjectSimple(versionToRestoreWithLocales),
draft: draftArg,
fallbackLocale: null,
global: null,
locale: locale!,
overrideAccess: true,
req,
showHiddenFields: true,
})
let data = deepCopyObjectSimple(prevVersionDoc)
// /////////////////////////////////////
// beforeValidate - Fields
// /////////////////////////////////////
data = await beforeValidate({
id: parentDocID,
collection: collectionConfig,
context: req.context,
data,
doc: originalDoc,
global: null,
operation: 'update',
overrideAccess,
req,
})
// /////////////////////////////////////
// beforeValidate - Collection
// /////////////////////////////////////
if (collectionConfig.hooks?.beforeValidate?.length) {
for (const hook of collectionConfig.hooks.beforeValidate) {
data =
(await hook({
collection: collectionConfig,
context: req.context,
data,
operation: 'update',
originalDoc,
req,
})) || data
}
}
// /////////////////////////////////////
// beforeChange - Collection
// /////////////////////////////////////
if (collectionConfig.hooks?.beforeChange?.length) {
for (const hook of collectionConfig.hooks.beforeChange) {
data =
(await hook({
collection: collectionConfig,
context: req.context,
data,
operation: 'update',
originalDoc,
req,
})) || data
}
}
// /////////////////////////////////////
// beforeChange - Fields
// /////////////////////////////////////
let result = await beforeChange({
id: parentDocID,
collection: collectionConfig,
context: req.context,
data: { ...data, id: parentDocID },
doc: originalDoc,
docWithLocales: versionToRestoreWithLocales,
global: null,
operation: 'update',
overrideAccess,
req,
skipValidation:
draftArg && collectionConfig.versions.drafts && !collectionConfig.versions.drafts.validate,
})
// /////////////////////////////////////
// Update
// /////////////////////////////////////
@@ -128,10 +258,10 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
select: incomingSelect,
})
let result = await req.payload.db.updateOne({
result = await req.payload.db.updateOne({
id: parentDocID,
collection: collectionConfig.slug,
data: rawVersion.version,
data: result,
req,
select,
})
@@ -140,18 +270,16 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
// Save `previousDoc` as a version after restoring
// /////////////////////////////////////
const prevVersion = { ...prevDocWithLocales }
delete prevVersion.id
await payload.db.createVersion({
result = await saveVersion({
id: parentDocID,
autosave: false,
collectionSlug: collectionConfig.slug,
createdAt: prevVersion.createdAt,
parent: parentDocID,
collection: collectionConfig,
docWithLocales: result,
draft: draftArg,
operation: 'restoreVersion',
payload,
req,
updatedAt: new Date().toISOString(),
versionData: draft ? { ...rawVersion.version, _status: 'draft' } : rawVersion.version,
select,
})
// /////////////////////////////////////
@@ -225,6 +353,21 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
}
}
// /////////////////////////////////////
// afterOperation - Collection
// /////////////////////////////////////
result = await buildAfterOperation({
args,
collection: collectionConfig,
operation: 'restoreVersion',
result,
})
if (shouldCommit) {
await commitTransaction(req)
}
return result
} catch (error: unknown) {
await killTransaction(req)

View File

@@ -314,6 +314,7 @@ export const updateDocument = async <
collection: collectionConfig,
docWithLocales: result,
draft: shouldSaveDraft,
operation: 'update',
payload,
publishSpecificLocale,
req,

View File

@@ -2,7 +2,7 @@ import type { forgotPasswordOperation } from '../../auth/operations/forgotPasswo
import type { loginOperation } from '../../auth/operations/login.js'
import type { refreshOperation } from '../../auth/operations/refresh.js'
import type { resetPasswordOperation } from '../../auth/operations/resetPassword.js'
import type { CollectionSlug } from '../../index.js'
import type { CollectionSlug, restoreVersionOperation } from '../../index.js'
import type { PayloadRequest } from '../../types/index.js'
import type { SanitizedCollectionConfig, SelectFromCollectionSlug } from '../config/types.js'
import type { countOperation } from './count.js'
@@ -36,6 +36,7 @@ export type AfterOperationMap<TOperationGeneric extends CollectionSlug> = {
login: typeof loginOperation<TOperationGeneric>
refresh: typeof refreshOperation
resetPassword: typeof resetPasswordOperation<TOperationGeneric>
restoreVersion: typeof restoreVersionOperation
update: typeof updateOperation<TOperationGeneric, SelectFromCollectionSlug<TOperationGeneric>>
updateByID: typeof updateByIDOperation<
TOperationGeneric,
@@ -108,6 +109,11 @@ export type AfterOperationArg<TOperationGeneric extends CollectionSlug> = {
operation: 'resetPassword'
result: Awaited<ReturnType<AfterOperationMap<TOperationGeneric>['resetPassword']>>
}
| {
args: Parameters<AfterOperationMap<TOperationGeneric>['restoreVersion']>[0]
operation: 'restoreVersion'
result: Awaited<ReturnType<AfterOperationMap<TOperationGeneric>['restoreVersion']>>
}
| {
args: Parameters<AfterOperationMap<TOperationGeneric>['update']>[0]
operation: 'update'

View File

@@ -160,32 +160,29 @@ export async function validateSearchParam({
let fieldAccess: any
if (versionFields) {
fieldAccess = policies[entityType]![entitySlug]!
fieldAccess = policies[entityType]![entitySlug]!.fields
if (segments[0] === 'parent' || segments[0] === 'version') {
if (segments[0] === 'parent' || segments[0] === 'version' || segments[0] === 'snapshot') {
segments.shift()
}
} else {
fieldAccess = policies[entityType]![entitySlug]!.fields
}
segments.forEach((segment) => {
if (fieldAccess[segment]) {
if ('fields' in fieldAccess[segment]) {
fieldAccess = fieldAccess[segment].fields
} else if (
'blocks' in fieldAccess[segment] ||
'blockReferences' in fieldAccess[segment]
) {
fieldAccess = fieldAccess[segment]
} else {
fieldAccess = fieldAccess[segment]
if (segments.length) {
segments.forEach((segment) => {
if (fieldAccess[segment]) {
if ('fields' in fieldAccess[segment]) {
fieldAccess = fieldAccess[segment].fields
} else {
fieldAccess = fieldAccess[segment]
}
}
}
})
})
if (!fieldAccess?.read?.permission) {
errors.push({ path: fieldPath })
if (!fieldAccess?.read?.permission) {
errors.push({ path: fieldPath })
}
}
}

View File

@@ -0,0 +1 @@
export { id } from '@payloadcms/translations/languages/id'

View File

@@ -6,6 +6,7 @@ export {
parseCookies,
} from '../auth/cookies.js'
export { getLoginOptions } from '../auth/getLoginOptions.js'
export { addSessionToUser, removeExpiredSessions } from '../auth/sessions.js'
export { getFromImportMap } from '../bin/generateImportMap/utilities/getFromImportMap.js'
export { parsePayloadComponent } from '../bin/generateImportMap/utilities/parsePayloadComponent.js'
export { defaults as collectionDefaults } from '../collections/config/defaults.js'

View File

@@ -111,13 +111,14 @@ export const promise = async ({
parentSchemaPath,
})
const fieldAffectsDataResult = fieldAffectsData(field)
const pathSegments = path ? path.split('.') : []
const schemaPathSegments = schemaPath ? schemaPath.split('.') : []
const indexPathSegments = indexPath ? indexPath.split('-').filter(Boolean)?.map(Number) : []
let removedFieldValue = false
if (
fieldAffectsData(field) &&
fieldAffectsDataResult &&
field.hidden &&
typeof siblingDoc[field.name!] !== 'undefined' &&
!showHiddenFields
@@ -139,16 +140,17 @@ export const promise = async ({
}
}
const shouldHoistLocalizedValue =
const shouldHoistLocalizedValue: boolean = Boolean(
flattenLocales &&
fieldAffectsData(field) &&
typeof siblingDoc[field.name!] === 'object' &&
siblingDoc[field.name!] !== null &&
fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! }) &&
locale !== 'all' &&
req.payload.config.localization
fieldAffectsDataResult &&
typeof siblingDoc[field.name!] === 'object' &&
siblingDoc[field.name!] !== null &&
fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! }) &&
locale !== 'all' &&
req.payload.config.localization,
)
if (shouldHoistLocalizedValue) {
if (fieldAffectsDataResult && shouldHoistLocalizedValue) {
// replace actual value with localized value before sanitizing
// { [locale]: fields } -> fields
const value = siblingDoc[field.name!][locale!]
@@ -187,7 +189,7 @@ export const promise = async ({
case 'group': {
// Fill groups with empty objects so fields with hooks within groups can populate
// themselves virtually as necessary
if (fieldAffectsData(field) && typeof siblingDoc[field.name] === 'undefined') {
if (fieldAffectsDataResult && typeof siblingDoc[field.name] === 'undefined') {
siblingDoc[field.name] = {}
}
@@ -234,7 +236,7 @@ export const promise = async ({
}
}
if (fieldAffectsData(field)) {
if (fieldAffectsDataResult) {
// Execute hooks
if (triggerHooks && field.hooks?.afterRead) {
for (const hook of field.hooks.afterRead) {
@@ -400,7 +402,7 @@ export const promise = async ({
}
}
if (Array.isArray(rows)) {
if (Array.isArray(rows) && rows.length > 0) {
rows.forEach((row, rowIndex) => {
traverseFields({
blockData,
@@ -468,6 +470,8 @@ export const promise = async ({
})
}
})
} else if (shouldHoistLocalizedValue && (!rows || rows.length === 0)) {
siblingDoc[field.name] = null
} else if (field.hidden !== true || showHiddenFields === true) {
siblingDoc[field.name] = []
}
@@ -477,7 +481,7 @@ export const promise = async ({
case 'blocks': {
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
if (Array.isArray(rows) && rows.length > 0) {
rows.forEach((row, rowIndex) => {
const blockTypeToMatch = (row as JsonObject).blockType
@@ -573,6 +577,8 @@ export const promise = async ({
})
}
})
} else if (shouldHoistLocalizedValue && (!rows || rows.length === 0)) {
siblingDoc[field.name] = null
} else if (field.hidden !== true || showHiddenFields === true) {
siblingDoc[field.name] = []
}
@@ -617,7 +623,7 @@ export const promise = async ({
}
case 'group': {
if (fieldAffectsData(field)) {
if (fieldAffectsDataResult) {
let groupDoc = siblingDoc[field.name] as JsonObject
if (typeof siblingDoc[field.name] !== 'object') {

View File

@@ -28,20 +28,20 @@ export async function buildFolderWhereConstraints({
}),
]
if (typeof collectionConfig.admin?.baseListFilter === 'function') {
const baseListFilterConstraint = await collectionConfig.admin.baseListFilter({
limit: 0,
locale: localeCode,
page: 1,
req,
sort:
sort ||
(typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : 'id'),
})
const baseFilterConstraint = await (
collectionConfig.admin?.baseFilter ?? collectionConfig.admin?.baseListFilter
)?.({
limit: 0,
locale: localeCode,
page: 1,
req,
sort:
sort ||
(typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : 'id'),
})
if (baseListFilterConstraint) {
constraints.push(baseListFilterConstraint)
}
if (baseFilterConstraint) {
constraints.push(baseFilterConstraint)
}
if (folderID) {

View File

@@ -30,15 +30,17 @@ export function formatFolderOrDocumentItem({
if (isUpload) {
itemValue.filename = value.filename
itemValue.mimeType = value.mimeType
itemValue.url = isImage(value.mimeType)
? getBestFitFromSizes({
sizes: value.sizes,
targetSizeMax: 520,
targetSizeMin: 300,
url: value.url,
width: value.width,
})
: undefined
itemValue.url =
value.thumbnailURL ||
(isImage(value.mimeType)
? getBestFitFromSizes({
sizes: value.sizes,
targetSizeMax: 520,
targetSizeMin: 300,
url: value.url,
width: value.width,
})
: undefined)
}
return {

View File

@@ -282,6 +282,7 @@ export const updateOperation = async <
docWithLocales: result,
draft: shouldSaveDraft,
global: globalConfig,
operation: 'update',
payload,
publishSpecificLocale,
req,

View File

@@ -1168,6 +1168,7 @@ export type {
AfterRefreshHook as CollectionAfterRefreshHook,
AuthCollection,
AuthOperationsFromCollectionSlug,
BaseFilter,
BaseListFilter,
BeforeChangeHook as CollectionBeforeChangeHook,
BeforeDeleteHook as CollectionBeforeDeleteHook,

View File

@@ -89,9 +89,11 @@ export const runJob = async ({
}
// Workflow has completed successfully
// Do not update the job log here, as that would result in unnecessary db calls when using postgres.
// Solely updating simple fields here will result in optimized db calls.
// Job log modifications are already updated at the end of the runTask function.
await updateJob({
completedAt: getCurrentDate().toISOString(),
log: job.log,
processing: false,
totalTried: (job.totalTried ?? 0) + 1,
})

View File

@@ -75,7 +75,9 @@ export const checkFileRestrictions = async ({
// Handle SVG files that are detected as XML due to <?xml declarations
if (
detected?.mime === 'application/xml' &&
configMimeTypes.some((type) => type.includes('svg')) &&
configMimeTypes.some(
(type) => type.includes('image/') && (type.includes('svg') || type === 'image/*'),
) &&
detectSvgFromXml(file.data)
) {
detected = { ext: 'svg' as any, mime: 'image/svg+xml' as any }

View File

@@ -22,7 +22,14 @@ export const getExternalFile = async ({ data, req, uploadConfig }: Args): Promis
const headers = uploadConfig.externalFileHeaderFilter
? uploadConfig.externalFileHeaderFilter(Object.fromEntries(new Headers(req.headers)))
: { cookie: req.headers.get('cookie')! }
: {
cookie:
req.headers
.get('cookie')
?.split(';')
.filter((cookie) => !cookie.trim().startsWith(req.payload.config.cookiePrefix))
.join(';') || '',
}
// Check if URL is allowed because of skipSafeFetch allowList
const skipSafeFetch: boolean =

View File

@@ -173,7 +173,12 @@ export type UploadConfig = {
*/
displayPreview?: boolean
/**
* Ability to filter/modify Request Headers when fetching a file.
*
* Accepts existing headers and returns the headers after filtering or modifying.
* If using this option, you should handle the removal of any sensitive cookies
* (like payload-prefixed cookies) to prevent leaking session information to external
* services. By default, Payload automatically filters out payload-prefixed cookies
* when this option is NOT defined.
*
* Useful for adding custom headers to fetch from external providers.
* @default undefined

View File

@@ -16,6 +16,7 @@ type Args = {
draft?: boolean
global?: SanitizedGlobalConfig
id?: number | string
operation?: 'create' | 'restoreVersion' | 'update'
payload: Payload
publishSpecificLocale?: string
req?: PayloadRequest
@@ -30,6 +31,7 @@ export const saveVersion = async ({
docWithLocales: doc,
draft,
global,
operation,
payload,
publishSpecificLocale,
req,
@@ -126,7 +128,7 @@ export const saveVersion = async ({
const createVersionArgs = {
autosave: Boolean(autosave),
collectionSlug: undefined as string | undefined,
createdAt: now,
createdAt: operation === 'restoreVersion' ? versionData.createdAt : now,
globalSlug: undefined as string | undefined,
parent: collection ? id : undefined,
publishedLocale: publishSpecificLocale || undefined,

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud-storage",
"version": "3.49.0",
"version": "3.50.0",
"description": "The official cloud storage plugin for Payload CMS",
"homepage": "https://payloadcms.com",
"repository": {
@@ -65,8 +65,8 @@
},
"devDependencies": {
"@types/find-node-modules": "^2.1.2",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
"version": "3.49.0",
"version": "3.50.0",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",
@@ -68,8 +68,8 @@
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/escape-html": "^1.0.4",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"payload": "workspace:*"

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-import-export",
"version": "3.49.0",
"version": "3.50.0",
"description": "Import-Export plugin for Payload",
"keywords": [
"payload",

View File

@@ -2,21 +2,21 @@ import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.j
export const zhTwTranslations: PluginDefaultTranslationsObject = {
'plugin-import-export': {
allLocales: '所有地區',
allLocales: '所有語言地區',
exportDocumentLabel: '匯出 {{label}}',
exportOptions: '匯出選項',
'field-depth-label': '深度',
'field-depth-label': '層級深度',
'field-drafts-label': '包含草稿',
'field-fields-label': '欄位',
'field-format-label': '匯出格式',
'field-limit-label': '筆數限',
'field-locale-label': '語言/地區',
'field-limit-label': '筆數限',
'field-locale-label': '語言地區',
'field-name-label': '檔案名稱',
'field-selectionToUse-label': '選擇範圍',
'field-selectionToUse-label': '使用的選取範圍',
'field-sort-label': '排序方式',
'selectionToUse-allDocuments': '使用所有文件',
'selectionToUse-currentFilters': '使用目前篩選條件',
'selectionToUse-currentSelection': '使用目前選',
'selectionToUse-currentSelection': '使用目前選取內容',
totalDocumentsCount: '共 {{count}} 筆文件',
},
}

View File

@@ -36,11 +36,11 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
*/
isGlobal?: boolean
/**
* Set to `false` if you want to manually apply the baseListFilter
* Set to `false` if you want to manually apply the baseFilter
*
* @default true
*/
useBaseListFilter?: boolean
useBaseFilter?: boolean
/**
* Set to `false` if you want to handle collection access manually without the multi-tenant constraints applied
*

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-multi-tenant",
"version": "3.49.0",
"version": "3.50.0",
"description": "Multi Tenant plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,4 +1,4 @@
export { filterDocumentsBySelectedTenant as getTenantListFilter } from '../list-filters/filterDocumentsBySelectedTenant.js'
export { filterDocumentsByTenants as getTenantListFilter } from '../filters/filterDocumentsByTenants.js'
export { getGlobalViewRedirect } from '../utilities/getGlobalViewRedirect.js'
export { getTenantAccess } from '../utilities/getTenantAccess.js'
export { getTenantFromCookie } from '../utilities/getTenantFromCookie.js'

View File

@@ -0,0 +1,52 @@
import type { PayloadRequest, Where } from 'payload'
import { defaults } from '../defaults.js'
import { getCollectionIDType } from '../utilities/getCollectionIDType.js'
import { getTenantFromCookie } from '../utilities/getTenantFromCookie.js'
import { getUserTenantIDs } from '../utilities/getUserTenantIDs.js'
type Args = {
filterFieldName: string
req: PayloadRequest
tenantsArrayFieldName?: string
tenantsArrayTenantFieldName?: string
tenantsCollectionSlug: string
}
export const filterDocumentsByTenants = ({
filterFieldName,
req,
tenantsArrayFieldName = defaults.tenantsArrayFieldName,
tenantsArrayTenantFieldName = defaults.tenantsArrayTenantFieldName,
tenantsCollectionSlug,
}: Args): null | Where => {
const idType = getCollectionIDType({
collectionSlug: tenantsCollectionSlug,
payload: req.payload,
})
// scope results to selected tenant
const selectedTenant = getTenantFromCookie(req.headers, idType)
if (selectedTenant) {
return {
[filterFieldName]: {
in: [selectedTenant],
},
}
}
// scope to user assigned tenants
const userAssignedTenants = getUserTenantIDs(req.user, {
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
})
if (userAssignedTenants.length > 0) {
return {
[filterFieldName]: {
in: userAssignedTenants,
},
}
}
// no tenant selected and no user tenants, return null to allow access control to handle it
return null
}

View File

@@ -10,14 +10,12 @@ import { defaults } from './defaults.js'
import { getTenantOptionsEndpoint } from './endpoints/getTenantOptionsEndpoint.js'
import { tenantField } from './fields/tenantField/index.js'
import { tenantsArrayField } from './fields/tenantsArrayField/index.js'
import { filterDocumentsByTenants } from './filters/filterDocumentsByTenants.js'
import { addTenantCleanup } from './hooks/afterTenantDelete.js'
import { filterDocumentsBySelectedTenant } from './list-filters/filterDocumentsBySelectedTenant.js'
import { filterTenantsBySelectedTenant } from './list-filters/filterTenantsBySelectedTenant.js'
import { filterUsersBySelectedTenant } from './list-filters/filterUsersBySelectedTenant.js'
import { translations } from './translations/index.js'
import { addCollectionAccess } from './utilities/addCollectionAccess.js'
import { addFilterOptionsToFields } from './utilities/addFilterOptionsToFields.js'
import { combineListFilters } from './utilities/combineListFilters.js'
import { combineFilters } from './utilities/combineFilters.js'
export const multiTenantPlugin =
<ConfigType>(pluginConfig: MultiTenantPluginConfig<ConfigType>) =>
@@ -145,10 +143,13 @@ export const multiTenantPlugin =
adminUsersCollection.admin = {}
}
adminUsersCollection.admin.baseListFilter = combineListFilters({
baseListFilter: adminUsersCollection.admin?.baseListFilter,
const baseFilter =
adminUsersCollection.admin?.baseFilter ?? adminUsersCollection.admin?.baseListFilter
adminUsersCollection.admin.baseFilter = combineFilters({
baseFilter,
customFilter: (args) =>
filterUsersBySelectedTenant({
filterDocumentsByTenants({
filterFieldName: `${tenantsArrayFieldName}.${tenantsArrayTenantFieldName}`,
req: args.req,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
@@ -208,11 +209,15 @@ export const multiTenantPlugin =
collection.admin = {}
}
collection.admin.baseListFilter = combineListFilters({
baseListFilter: collection.admin?.baseListFilter,
const baseFilter = collection.admin?.baseFilter ?? collection.admin?.baseListFilter
collection.admin.baseFilter = combineFilters({
baseFilter,
customFilter: (args) =>
filterTenantsBySelectedTenant({
filterDocumentsByTenants({
filterFieldName: 'id',
req: args.req,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
}),
})
@@ -294,7 +299,9 @@ export const multiTenantPlugin =
}),
)
if (pluginConfig.collections[collection.slug]?.useBaseListFilter !== false) {
const { useBaseFilter, useBaseListFilter } = pluginConfig.collections[collection.slug] || {}
if (useBaseFilter ?? useBaseListFilter ?? true) {
/**
* Add list filter to enabled collections
* - filters results by selected tenant
@@ -303,12 +310,15 @@ export const multiTenantPlugin =
collection.admin = {}
}
collection.admin.baseListFilter = combineListFilters({
baseListFilter: collection.admin?.baseListFilter,
const baseFilter = collection.admin?.baseFilter ?? collection.admin?.baseListFilter
collection.admin.baseFilter = combineFilters({
baseFilter,
customFilter: (args) =>
filterDocumentsBySelectedTenant({
filterDocumentsByTenants({
filterFieldName: tenantFieldName,
req: args.req,
tenantFieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
}),
})

View File

@@ -1,31 +0,0 @@
import type { PayloadRequest, Where } from 'payload'
import { getCollectionIDType } from '../utilities/getCollectionIDType.js'
import { getTenantFromCookie } from '../utilities/getTenantFromCookie.js'
type Args = {
req: PayloadRequest
tenantFieldName: string
tenantsCollectionSlug: string
}
export const filterDocumentsBySelectedTenant = ({
req,
tenantFieldName,
tenantsCollectionSlug,
}: Args): null | Where => {
const idType = getCollectionIDType({
collectionSlug: tenantsCollectionSlug,
payload: req.payload,
})
const selectedTenant = getTenantFromCookie(req.headers, idType)
if (selectedTenant) {
return {
[tenantFieldName]: {
equals: selectedTenant,
},
}
}
return {}
}

View File

@@ -1,29 +0,0 @@
import type { PayloadRequest, Where } from 'payload'
import { getCollectionIDType } from '../utilities/getCollectionIDType.js'
import { getTenantFromCookie } from '../utilities/getTenantFromCookie.js'
type Args = {
req: PayloadRequest
tenantsCollectionSlug: string
}
export const filterTenantsBySelectedTenant = ({
req,
tenantsCollectionSlug,
}: Args): null | Where => {
const idType = getCollectionIDType({
collectionSlug: tenantsCollectionSlug,
payload: req.payload,
})
const selectedTenant = getTenantFromCookie(req.headers, idType)
if (selectedTenant) {
return {
id: {
equals: selectedTenant,
},
}
}
return {}
}

View File

@@ -1,36 +0,0 @@
import type { PayloadRequest, Where } from 'payload'
import { getCollectionIDType } from '../utilities/getCollectionIDType.js'
import { getTenantFromCookie } from '../utilities/getTenantFromCookie.js'
type Args = {
req: PayloadRequest
tenantsArrayFieldName: string
tenantsArrayTenantFieldName: string
tenantsCollectionSlug: string
}
/**
* Filter the list of users by the selected tenant
*/
export const filterUsersBySelectedTenant = ({
req,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
}: Args): null | Where => {
const idType = getCollectionIDType({
collectionSlug: tenantsCollectionSlug,
payload: req.payload,
})
const selectedTenant = getTenantFromCookie(req.headers, idType)
if (selectedTenant) {
return {
[`${tenantsArrayFieldName}.${tenantsArrayTenantFieldName}`]: {
in: [selectedTenant],
},
}
}
return {}
}

View File

@@ -3,9 +3,9 @@ import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.j
export const zhTwTranslations: PluginDefaultTranslationsObject = {
'plugin-multi-tenant': {
'confirm-tenant-switch--body':
'您即將將所有權從 <0>{{fromTenant}}</0> 轉移至 <0>{{toTenant}}</0>',
'confirm-tenant-switch--heading': '確認{{tenantLabel}}更改',
'field-assignedTentant-label': '指的租戶',
'您即將變更擁有者,從 <0>{{fromTenant}}</0> 切換為 <0>{{toTenant}}</0>',
'confirm-tenant-switch--heading': '確認變更 {{tenantLabel}}',
'field-assignedTentant-label': '指的租戶',
},
}

View File

@@ -30,7 +30,19 @@ export type MultiTenantPluginConfig<ConfigTypes = unknown> = {
*/
isGlobal?: boolean
/**
* Set to `false` if you want to manually apply the baseListFilter
* Set to `false` if you want to manually apply the baseFilter
*
* @default true
*/
useBaseFilter?: boolean
/**
* @deprecated Use `useBaseFilter` instead. If both are defined,
* `useBaseFilter` will take precedence. This property remains only
* for backward compatibility and may be removed in a future version.
*
* Originally, `baseListFilter` was intended to filter only the List View
* in the admin panel. However, base filtering is often required in other areas
* such as internal link relationships in the Lexical editor.
*
* @default true
*/

View File

@@ -1,24 +1,24 @@
import type { BaseListFilter, Where } from 'payload'
import type { BaseFilter, Where } from 'payload'
type Args = {
baseListFilter?: BaseListFilter
customFilter: BaseListFilter
baseFilter?: BaseFilter
customFilter: BaseFilter
}
/**
* Combines a base list filter with a tenant list filter
* Combines a base filter with a tenant list filter
*
* Combines where constraints inside of an AND operator
*/
export const combineListFilters =
({ baseListFilter, customFilter }: Args): BaseListFilter =>
export const combineFilters =
({ baseFilter, customFilter }: Args): BaseFilter =>
async (args) => {
const filterConstraints = []
if (typeof baseListFilter === 'function') {
const baseListFilterResult = await baseListFilter(args)
if (typeof baseFilter === 'function') {
const baseFilterResult = await baseFilter(args)
if (baseListFilterResult) {
filterConstraints.push(baseListFilterResult)
if (baseFilterResult) {
filterConstraints.push(baseFilterResult)
}
}

View File

@@ -19,10 +19,9 @@ export const getUserTenantIDs = <IDType extends number | string>(
return []
}
const {
tenantsArrayFieldName = defaults.tenantsArrayFieldName,
tenantsArrayTenantFieldName = defaults.tenantsArrayTenantFieldName,
} = options || {}
const tenantsArrayFieldName = options?.tenantsArrayFieldName || defaults.tenantsArrayFieldName
const tenantsArrayTenantFieldName =
options?.tenantsArrayTenantFieldName || defaults.tenantsArrayTenantFieldName
return (
(Array.isArray(user[tenantsArrayFieldName]) ? user[tenantsArrayFieldName] : [])?.reduce<

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-nested-docs",
"version": "3.49.0",
"version": "3.50.0",
"description": "The official Nested Docs plugin for Payload",
"homepage": "https://payloadcms.com",
"repository": {

Some files were not shown because too many files have changed in this diff Show More