Compare commits

...

76 Commits

Author SHA1 Message Date
Elliot DeNolf
cb90e9f622 chore(release): v3.0.0-beta.23 [skip ci] 2024-05-02 16:05:19 -04:00
Elliot DeNolf
b05a5e1fb6 chore(deps): bump turborepo 2024-05-02 15:57:18 -04:00
Elliot DeNolf
92a5da1006 chore: move stripe postman out of src [skip ci] 2024-05-02 15:50:44 -04:00
Paul
75a95469b2 feat(plugin-stripe): update plugin stripe for v3 (#6019) 2024-05-02 16:19:27 -03:00
Jarrod Flesch
c0ae287d46 fix: reset password validations (#6153)
Co-authored-by: Elliot DeNolf <denolfe@gmail.com>
Co-authored-by: James <james@trbl.design>
Co-authored-by: Alessio Gravili <alessio@gravili.de>
2024-05-02 15:08:47 -04:00
Patrik
a2b92aa3ff fix(ui): watch "where" query param inside route and reset WhereBuilder (#6184) 2024-05-02 13:25:51 -04:00
Friggo
544a2285d3 chore(translations): czech translation improvements (#6078) 2024-05-02 12:54:18 -04:00
Elliot DeNolf
8c39950ea3 chore(release): v3.0.0-beta.22 [skip ci] 2024-05-02 12:27:28 -04:00
Yiannis Demetriades
6d642fe9b9 fix(templates): adds back missing CSS import in blank 3.0 template (#6183) 2024-05-02 11:30:58 -04:00
Jacob Fletcher
f175a741bc chore(next): exports initPage utility (#6182) 2024-05-02 11:22:13 -04:00
Jacob Fletcher
3290376f80 fix(next): ensures admin access only blocks admin routes 2024-05-02 11:07:43 -04:00
Jacob Fletcher
2b5c1ba99a chore(next): exports initPage utility 2024-05-02 10:32:17 -04:00
Alessio Gravili
bcb3f08386 chore: hide test flakes, improve playwright CI logs, significantly reduce playwright timeouts, add back test retries, cache playwright browsers in CI, disable CI telemetry, improve test throttle utility (#6155) 2024-05-01 17:35:41 -04:00
Wilson
b729b9bebd docs: add before login comments (#6101) 2024-05-01 15:59:11 -04:00
Tylan Davis
26ee91eb48 docs: adjust line breaks in code blocks (#6001) 2024-05-01 15:57:39 -04:00
Paul
43a17f67a0 chore(richtext-lexical): export types for additional props (#6173) 2024-05-01 15:03:06 -03:00
Elliot DeNolf
c71d2db949 chore(release): v3.0.0-beta.21 [skip ci] 2024-05-01 13:25:14 -04:00
Jacob Fletcher
04f1df8af1 fix(templates): updates payload app files (#6172) 2024-05-01 12:43:47 -04:00
Elliot DeNolf
1c490aee42 fix(deps): move file-type to deps (#6171) 2024-05-01 12:20:45 -04:00
Elliot DeNolf
c6132df866 chore: rename resend package (#6168) 2024-05-01 12:02:40 -04:00
Alessio Gravili
d8f91cc94c feat(richtext-lexical)!: various validation improvement (#6163)
BREAKING: this will now display errors if you're previously had invalid link or upload fields data - for example if you have a required field added to an uploads node and did not provide a value to it every time you've added an upload node
2024-05-01 11:33:02 -04:00
Alessio Gravili
568b074809 fix: various loader issues (#6090) 2024-05-01 10:45:28 -04:00
Alessio Gravili
401c16e485 chore: lexical int tests: do not use relationTo to collection with rich text relationships disabled 2024-05-01 00:47:40 -04:00
Elliot DeNolf
17bee6a145 ci: reworks changelog and release notes generation (#6164) 2024-04-30 23:50:49 -04:00
Alessio Gravili
8829fba6cf feat(richtext-lexical)!: add validation to link and upload nodes
BREAKING: this will now display errors if you're previously had invalid link or upload fields data - for example if you have a required field added to an uploads node and did not provide a value to it every time you've added an upload node
2024-04-30 23:14:27 -04:00
Alessio Gravili
e8983abe65 ci: enable fields RichText e2e test suite 2024-04-30 23:12:47 -04:00
Alessio Gravili
01f38c4e33 feat(richtext-lexical): link node: disable client-side link validation. This allows overriding validation behavior by providing your own url field to the Link feature.
Allows you to, for example, allow anchor nodes to be inputted as URL by overriding validation.
2024-04-30 23:12:07 -04:00
Alessio Gravili
10b99ceb6f fix: add missing error logger to buildFormState error catch 2024-04-30 23:10:52 -04:00
Alessio Gravili
1140426b73 Merge remote-tracking branch 'origin/beta' into feat/improve-lexical-validations-2 2024-04-30 23:02:07 -04:00
Alessio Gravili
5a82f34801 feat(richtext-lexical)!: change link fields handling (#6162)
**BREAKING:**
- Drawer fields are no longer wrapped in a `fields` group. This might be breaking if you depend on them being in a field group in any way - potentially if you use custom link fields. This does not change how the data is saved
- If you pass in an array of custom fields to the link feature, those were previously added to the base fields. Now, they completely replace the base fields for consistency. If you want to ADD fields to the base fields now, you will have to pass in a function and spread `defaultFields` - similar to how adding your own features to lexical works

**Example Migration for ADDING fields to the link base fields:**

**Previous:**
```ts
 LinkFeature({
    fields: [
      {
        name: 'rel',
        label: 'Rel Attribute',
        type: 'select',
        hasMany: true,
        options: ['noopener', 'noreferrer', 'nofollow'],
        admin: {
          description:
            'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
        },
      },
    ],
  }),
```

**Now:**
```ts
 LinkFeature({
    fields: ({ defaultFields }) => [
      ...defaultFields,
      {
        name: 'rel',
        label: 'Rel Attribute',
        type: 'select',
        hasMany: true,
        options: ['noopener', 'noreferrer', 'nofollow'],
        admin: {
          description:
            'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
        },
      },
    ],
  }),
2024-04-30 23:01:08 -04:00
Alessio Gravili
5420d889fe fix(richtext-slate): do not add empty fields group if no custom fields are added 2024-04-30 21:53:47 -04:00
Alessio Gravili
d9bb51fdc7 feat(richtext-lexical)!: initialize lexical during sanitization (#6119)
BREAKING:

- sanitizeFields is now an async function
- the richText adapters now return a function instead of returning the adapter directly
2024-04-30 15:09:32 -04:00
Alessio Gravili
9a636a3cfb fix(richtext-lexical): floating toolbar caret positioned incorrectly for some line heights (#6149) 2024-04-30 12:01:25 -04:00
Alessio Gravili
181f82f33e feat(richtext-lexical): implement relationship node click and delete/backspace handling (#6147) 2024-04-30 11:11:47 -04:00
Alessio Gravili
6a9cde24b0 fix(richtext-lexical): drag and add block handles disappear too quickly for smaller screen sizes. (#6144) 2024-04-30 10:50:18 -04:00
Elliot DeNolf
dc31d9c715 test: parse and update tsconfig in before test hook 2024-04-30 00:24:06 -04:00
Elliot DeNolf
45b3f06e1b chore: implement better tsconfig reset mechanism 2024-04-29 23:23:09 -04:00
Elliot DeNolf
d5f7944ac4 chore(eslint): set prefer-ts-expect-error to error 2024-04-29 22:30:05 -04:00
Elliot DeNolf
3d50caf985 feat: implement resend rest email adapter (#5916) 2024-04-29 22:06:53 -04:00
Jacob Fletcher
4d7ef58e7e fix: blocks non-admin users from admin access (#6127) 2024-04-29 19:53:18 -04:00
Paul
3e117f4e99 chore: add graphql as a dependency to the blank template (#6128) 2024-04-29 20:09:27 -03:00
Elliot DeNolf
888d6f8856 ci(scripts): adjust release publish limit 2024-04-29 17:19:36 -04:00
Elliot DeNolf
9ebf8693d4 chore(release): v3.0.0-beta.20 [skip ci] 2024-04-29 16:51:35 -04:00
James Mikrut
d8c3127b09 fix: local req missing url headers (#6126)
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2024-04-29 16:40:59 -04:00
James Mikrut
b6631f4778 fix: logout-inactivity route was 404ing (#6121) 2024-04-29 15:53:08 -04:00
Elliot DeNolf
2e77bdf11e test: add test email adapter, use for all tests by default (#6120) 2024-04-29 14:38:35 -04:00
Elliot DeNolf
cce75f11ca ci: add better message for PR subject pattern error 2024-04-29 14:34:04 -04:00
Elliot DeNolf
d8b6b39dbb fix: validate user slug is an auth-enabled collection (#6118) 2024-04-29 14:25:23 -04:00
Jacob Fletcher
fa89057aac fix(next,ui): properly sets document operation for globals (#6116) 2024-04-29 13:58:06 -04:00
Jarrod Flesch
15db0a8018 fix: conditions throwing errors break form state (#6113) 2024-04-29 12:54:00 -04:00
Elliot DeNolf
b7a4d9cea4 chore(deps): adjust node engines to account for node register requirement (#6115) 2024-04-29 12:28:31 -04:00
Elliot DeNolf
5b676c36e5 docs(storage-*): readme fixes 2024-04-29 09:40:39 -04:00
Jacob Fletcher
32231762ff chore: version permissions (#6068) 2024-04-29 08:22:56 -04:00
Elliot DeNolf
a7096c1599 chore: telemetry localization (#6075) 2024-04-28 22:17:08 -04:00
Alessio Gravili
bed428c27e feat(richtext-lexical)!: upgrade lexical from 0.13.1 to 0.14.5 and backport other changes (#6095)
BREAKING:

- Lexical may introduce breaking changes in their updates. Please consult their changelog. One breaking change I noticed is that the SerializedParagraphNode now has a new, required textFormat property.

- Now that lexical supports ESM, all CJS-style imports have been changed to ESM-style imports. You may have to do the same in your codebase if you import from lexical core packages
2024-04-28 20:25:27 -04:00
Elliot DeNolf
873e698352 docs: storage-* and plugin-cloud-storage updates (#6096) 2024-04-28 20:07:49 -04:00
Alessio Gravili
ad13577399 chore(richtext-lexical): fix build and backport badNode logic change from lexical core 2024-04-28 19:58:07 -04:00
Alessio Gravili
31a9c77055 fix(richtext-lexical): preserve bullet list item indent on newline
BACKPORTS https://github.com/facebook/lexical/pull/5578
2024-04-28 19:30:52 -04:00
Alessio Gravili
bae0c2df5f fix(richtext-lexical): add missing uuid dependency 2024-04-28 19:26:25 -04:00
Alessio Gravili
0ed31def68 feat(richtext-lexical): implement upload node click and delete/backspace handling 2024-04-28 19:19:34 -04:00
Alessio Gravili
0e7a6ad5ab fix(richtext-lexical): prevent link modal from showing if selection spans further than the link
Backports https://github.com/facebook/lexical/pull/5551
2024-04-28 19:03:02 -04:00
Alessio Gravili
180797540c feat(richtext-lexical)!: change all CJS lexical imports to ESM lexical imports
BREAKING: You might have to do the same if you import from lexical in your application
2024-04-28 18:50:58 -04:00
Alessio Gravili
c00babf9b3 chore(richtext-lexical): upgrade all lexical dependencies from 0.13.1 to 0.14.5 2024-04-28 17:52:11 -04:00
Alessio Gravili
943681ae3c chore: upgrade typescript from 5.4.4 to 5.4.5 (#6093) 2024-04-28 17:46:41 -04:00
Alessio Gravili
f14ce367d2 fix(richtext-lexical): type errors for FeatureProviderServer with typescript strict mode (#6091) 2024-04-28 17:12:47 -04:00
Paul
3eb5766323 fix: issue with dupplicate ':' in email links (#6086) 2024-04-28 17:22:07 -03:00
Alessio Gravili
cd5e8d7b52 fix: importWithoutClientFiles not working due to incorrect import path used 2024-04-28 16:06:01 -04:00
Alessio Gravili
361d12e97c chore: loader test: use importConfig helper instead of manually registering loader to realistically test what a user would experience 2024-04-28 16:04:11 -04:00
Elliot DeNolf
fb4a5a3715 chore(ui): fix bad imports 2024-04-28 14:53:15 -04:00
Elliot DeNolf
9c2585ba86 chore(eslint): no-relative-monorepo-imports on package dir, other cleanup 2024-04-28 14:49:48 -04:00
Paul
feb6296bb4 chore: add tailwind and shadcn/ui example (#6085) 2024-04-28 15:06:43 -03:00
Alessio Gravili
74eb71c304 chore: add failing loader test case 2024-04-27 21:13:38 -04:00
Alessio Gravili
fa2083f764 fix: loader: typescript module resolver not resolving to source path of symlinked module 2024-04-27 20:45:04 -04:00
Jacob Fletcher
7111834a99 fix(ui): conditionally fetches versions based on read access 2024-04-26 17:40:28 -04:00
Jacob Fletcher
a943c7eddb fix(ui): conditionally renders versions tab based on read access 2024-04-26 17:40:14 -04:00
Jacob Fletcher
2d089a7bae chore: threads permissions through document tab conditions 2024-04-26 17:38:59 -04:00
339 changed files with 10408 additions and 2918 deletions

View File

@@ -8,6 +8,7 @@ module.exports = {
plugins: ['payload'],
rules: {
'payload/no-jsx-import-statements': 'warn',
'payload/no-relative-monorepo-imports': 'error',
},
},
{

View File

@@ -13,6 +13,8 @@ concurrency:
env:
NODE_VERSION: 18.20.2
PNPM_VERSION: 8.15.7
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry
jobs:
changes:
@@ -89,6 +91,8 @@ jobs:
- run: pnpm install
- run: pnpm run build:all
env:
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
- name: Cache build
uses: actions/cache@v4
@@ -243,6 +247,7 @@ jobs:
- fields__collections__Blocks
- fields__collections__Array
- fields__collections__Relationship
- fields__collections__RichText
- fields__collections__Lexical
- live-preview
- localization
@@ -252,7 +257,8 @@ jobs:
- plugin-seo
- versions
- uploads
env:
SUITE_NAME: ${{ matrix.suite }}
steps:
# https://github.com/actions/virtual-environments/issues/1187
- name: tune linux network
@@ -280,11 +286,33 @@ jobs:
run: pnpm docker:start
if: ${{ matrix.suite == 'plugin-cloud-storage' }}
- name: Install Playwright
run: pnpm exec playwright install --with-deps
- name: Store Playwright's Version
run: |
# Extract the version number using a more targeted regex pattern with awk
PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2}')
echo "Playwright's Version: $PLAYWRIGHT_VERSION"
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
- name: Cache Playwright Browsers for Playwright's Version
id: cache-playwright-browsers
uses: actions/cache@v3
with:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}
- name: Setup Playwright - Browsers and Dependencies
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Setup Playwright - Dependencies-only
if: steps.cache-playwright-browsers.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: E2E Tests
run: pnpm test:e2e ${{ matrix.suite }}
run: PLAYWRIGHT_JSON_OUTPUT_NAME=results_${{ matrix.suite }}.json pnpm test:e2e ${{ matrix.suite }}
env:
PLAYWRIGHT_JSON_OUTPUT_NAME: results_${{ matrix.suite }}.json
NEXT_TELEMETRY_DISABLED: 1
- uses: actions/upload-artifact@v4
if: always()
@@ -294,6 +322,13 @@ jobs:
if-no-files-found: ignore
retention-days: 1
# Disabled until this is fixed: https://github.com/daun/playwright-report-summary/issues/156
# - uses: daun/playwright-report-summary@v3
# with:
# report-file: results_${{ matrix.suite }}.json
# report-tag: ${{ matrix.suite }}
# job-summary: true
app-build-with-packed:
runs-on: ubuntu-latest
needs: build

View File

@@ -71,6 +71,10 @@ jobs:
# Disallow uppercase letters at the beginning of the subject
subjectPattern: ^(?![A-Z]).+$
subjectPatternError: |
The subject "{subject}" found in the pull request title "{title}"
didn't match the configured pattern. Please ensure that the subject
doesn't start with an uppercase character.
- uses: marocchino/sticky-pull-request-comment@v2
# When the previous steps fails, the workflow would stop. By adding this

View File

@@ -40,5 +40,6 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
},
"files.insertFinalNewline": true
}

View File

@@ -1,39 +0,0 @@
module.exports = {
// gitRawCommitsOpts: {
// from: 'v2.0.9',
// path: 'packages/payload',
// },
// infile: 'CHANGELOG.md',
options: {
preset: {
name: 'conventionalcommits',
types: [
{ section: 'Features', type: 'feat' },
{ section: 'Features', type: 'feature' },
{ section: 'Bug Fixes', type: 'fix' },
{ section: 'Documentation', type: 'docs' },
],
},
},
// outfile: 'NEW.md',
writerOpts: {
commitGroupsSort: (a, b) => {
const groupOrder = ['Features', 'Bug Fixes', 'Documentation']
return groupOrder.indexOf(a.title) - groupOrder.indexOf(b.title)
},
// Scoped commits at the end, alphabetical sort
commitsSort: (a, b) => {
if (a.scope || b.scope) {
if (!a.scope) return -1
if (!b.scope) return 1
return a.scope === b.scope
? a.subject.localeCompare(b.subject)
: a.scope.localeCompare(b.scope)
}
// Alphabetical sort
return a.subject.localeCompare(b.subject)
},
},
}

View File

@@ -49,7 +49,8 @@ export default buildConfig({
{
label: 'Arabic',
code: 'ar',
// opt-in to setting default text-alignment on Input fields to rtl (right-to-left) when current locale is rtl
// opt-in to setting default text-alignment on Input fields to rtl (right-to-left)
// when current locale is rtl
rtl: true,
},
],
@@ -134,13 +135,9 @@ to support localization, you need to specify each field that you would like to l
```js
{
name: 'title',
type
:
'text',
// highlight-start
localized
:
true,
type: 'text',
// highlight-start
localized: true,
// highlight-end
}
```

View File

@@ -20,7 +20,8 @@ The initial request made to Payload will begin a new transaction and attach it t
```ts
const afterChange: CollectionAfterChangeHook = async ({ req }) => {
// because req.transactionID is assigned from Payload and passed through, my-slug will only persist if the entire request is successful
// because req.transactionID is assigned from Payload and passed through,
// my-slug will only persist if the entire request is successful
await req.payload.create({
req,
collection: 'my-slug',

View File

@@ -196,7 +196,8 @@ const Pages: CollectionConfig = {
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
// The HTMLConverter Feature is the feature which manages the HTML serializers. If you do not pass any arguments to it, it will use the default serializers.
// The HTMLConverter Feature is the feature which manages the HTML serializers.
// If you do not pass any arguments to it, it will use the default serializers.
HTMLConverterFeature({}),
],
}),

View File

@@ -0,0 +1,2 @@
DATABASE_URI=mongodb://127.0.0.1/payload-template-blank-3-0
PAYLOAD_SECRET=YOUR_SECRET_HERE

View File

@@ -0,0 +1,7 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
}

43
examples/tailwind-shadcn-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
/.idea/*
!/.idea/runConfigurations
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env
/media

View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false
}

View File

@@ -0,0 +1,42 @@
# Payload Blank Template
A blank template for [Payload](https://github.com/payloadcms/payload) to help you get up and running quickly. This repo may have been created by running `npx create-payload-app@latest` and selecting the "blank" template or by cloning this template on [Payload Cloud](https://payloadcms.com/new/clone/blank).
See the official [Examples Directory](https://github.com/payloadcms/payload/tree/main/examples) for details on how to use Payload in a variety of different ways.
## Development
To spin up the project locally, follow these steps:
1. First clone the repo
1. Then `cd YOUR_PROJECT_REPO && cp .env.example .env`
1. Next `yarn && yarn dev` (or `docker-compose up`, see [Docker](#docker))
1. Now `open http://localhost:3000/admin` to access the admin panel
1. Create your first admin user using the form on the page
That's it! Changes made in `./src` will be reflected in your app.
### Docker
Alternatively, you can use [Docker](https://www.docker.com) to spin up this project locally. To do so, follow these steps:
1. Follow [steps 1 and 2 from above](#development), the docker-compose file will automatically use the `.env` file in your project root
1. Next run `docker-compose up`
1. Follow [steps 4 and 5 from above](#development) to login and create your first admin user
That's it! The Docker instance will help you get up and running quickly while also standardizing the development environment across your teams.
## Production
To run Payload in production, you need to build and serve the Admin panel. To do so, follow these steps:
1. First invoke the `payload build` script by running `yarn build` or `npm run build` in your project root. This creates a `./build` directory with a production-ready admin bundle.
1. Then run `yarn serve` or `npm run serve` to run Node in production and serve Payload from the `./build` directory.
### Deployment
The easiest way to deploy your project is to use [Payload Cloud](https://payloadcms.com/new/import), a one-click hosting solution to deploy production-ready instances of your Payload apps directly from your GitHub repo. You can also deploy your app manually, check out the [deployment documentation](https://payloadcms.com/docs/production/deployment) for full details.
## Questions
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/(app)/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@@ -0,0 +1,8 @@
import { withPayload } from '@payloadcms/next/withPayload'
/** @type {import('next').NextConfig} */
const nextConfig = {
// Your Next.js config here
}
export default withPayload(nextConfig)

View File

@@ -0,0 +1,46 @@
{
"name": "tailwind-shadcn",
"description": "A blank template to get started with Payload 3.0",
"version": "1.0.0",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"generate:types": "payload generate:types"
},
"engines": {
"node": ">=18.19.0"
},
"dependencies": {
"@payloadcms/db-mongodb": "beta",
"@payloadcms/next": "beta",
"@payloadcms/richtext-lexical": "beta",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cross-env": "^7.0.3",
"lucide-react": "^0.376.0",
"next": "14.3.0-canary.7",
"payload": "beta",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sharp": "0.32.6",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^20.11.25",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"autoprefixer": "^10.4.19",
"dotenv": "^16.4.5",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"tsx": "^4.7.1",
"typescript": "^5.4.2"
}
}

5617
examples/tailwind-shadcn-ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,26 @@
import { ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { Inter as FontSans } from 'next/font/google'
type LayoutProps = {
children: ReactNode
}
import './globals.css'
const fontSans = FontSans({
subsets: ['latin'],
variable: '--font-sans',
})
const Layout = ({ children }: LayoutProps) => {
return (
<html>
<body className={cn('min-h-screen bg-background font-sans antialiased', fontSans.variable)}>
{children}
</body>
</html>
)
}
export default Layout

View File

@@ -0,0 +1,11 @@
import { NextPage } from 'next'
const Page: NextPage = () => {
return (
<div className="container">
<h1 className="text-4xl font-bold">Hello, Next.js 14!</h1>
</div>
)
}
export default Page

View File

@@ -0,0 +1,17 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import config from '@payload-config'
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import { RootPage } from '@payloadcms/next/views'
type Args = {
params: {
segments: string[]
}
searchParams: {
[key: string]: string | string[]
}
}
const Page = ({ params, searchParams }: Args) => RootPage({ config, params, searchParams })
export default Page

View File

@@ -0,0 +1,9 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY it because it could be re-written at any time. */
import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_PATCH, REST_POST } from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)

View File

@@ -0,0 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY it because it could be re-written at any time. */
import config from '@payload-config'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -0,0 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY it because it could be re-written at any time. */
import config from '@payload-config'
import { GRAPHQL_POST } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)

View File

@@ -0,0 +1,64 @@
@tailwind components;
@tailwind utilities;
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
[data-theme='dark'] {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}

View File

@@ -0,0 +1,16 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import configPromise from '@payload-config'
import '@payloadcms/next/css'
import { RootLayout } from '@payloadcms/next/layouts'
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import React from 'react'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const Layout = ({ children }: Args) => <RootLayout config={configPromise}>{children}</RootLayout>
export default Layout

View File

@@ -0,0 +1,24 @@
import AlertBox from '@/components/AlertBox'
import type { CollectionConfig } from 'payload/types'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'alertBox',
type: 'ui',
admin: {
components: {
Field: AlertBox,
},
},
},
],
}

View File

@@ -0,0 +1,13 @@
import type { CollectionConfig } from 'payload/types'
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
fields: [
// Email added by default
// Add more fields as needed
],
}

View File

@@ -0,0 +1,13 @@
import React from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
const AlertBox: React.FC = () => {
return (
<Alert>
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>Please add an appropriate title.</AlertDescription>
</Alert>
)
}
export default AlertBox

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,26 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload/config'
import { fileURLToPath } from 'url'
import { Posts } from './collections/Posts'
import { Users } from './collections/Users'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: Users.slug,
},
collections: [Users, Posts],
editor: lexicalEditor({}),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: mongooseAdapter({
url: process.env.DATABASE_URI || '',
}),
})

View File

@@ -0,0 +1,77 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['selector', '[data-mode="dark"]', '.dark'],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: '',
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
}

View File

@@ -0,0 +1,44 @@
{
"compilerOptions": {
"baseUrl": ".",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
],
"@payload-config": [
"./src/payload.config.ts"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -1,3 +1,20 @@
/**
* Ignores all ESM packages that make Jest mad.
*
* "Jest encountered an unexpected token"
*
* Direct packages:
* - file-type
*/
const esModules = [
// file-type and all dependencies: https://github.com/sindresorhus/file-type
'file-type',
'strtok3',
'readable-web-to-node-stream',
'token-types',
'peek-readable',
].join('|')
/** @type {import('jest').Config} */
const baseJestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
@@ -14,6 +31,10 @@ const baseJestConfig = {
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest'],
},
transformIgnorePatterns: [
`/node_modules/(?!.pnpm)(?!(${esModules})/)`,
`/node_modules/.pnpm/(?!(${esModules.replace(/\//g, '\\+')})@)`,
],
verbose: true,
}

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.0.0-beta.19",
"version": "3.0.0-beta.23",
"private": true,
"type": "module",
"scripts": {
@@ -13,6 +13,7 @@
"build:db-mongodb": "turbo build --filter db-mongodb",
"build:db-postgres": "turbo build --filter db-postgres",
"build:email-nodemailer": "turbo build --filter email-nodemailer",
"build:email-resend": "turbo build --filter email-resend",
"build:eslint-config-payload": "turbo build --filter eslint-config-payload",
"build:graphql": "turbo build --filter graphql",
"build:live-preview": "turbo build --filter live-preview",
@@ -73,7 +74,8 @@
"*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --cache --fix"
]
],
"tsconfig.json": "node scripts/reset-tsconfig.js"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.525.0",
@@ -88,10 +90,6 @@
"@testing-library/jest-dom": "6.4.2",
"@testing-library/react": "14.2.1",
"@types/concat-stream": "^2.0.1",
"@types/conventional-changelog": "^3.1.4",
"@types/conventional-changelog-core": "^4.2.5",
"@types/conventional-changelog-preset-loader": "^2.3.4",
"@types/conventional-changelog-writer": "^4.0.10",
"@types/fs-extra": "^11.0.2",
"@types/jest": "29.5.12",
"@types/minimist": "1.2.5",
@@ -103,13 +101,9 @@
"@types/shelljs": "0.8.15",
"add-stream": "^1.0.0",
"chalk": "^4.1.2",
"changelogen": "^0.5.5",
"comment-json": "^4.2.3",
"concat-stream": "^2.0.0",
"conventional-changelog": "^5.1.0",
"conventional-changelog-conventionalcommits": "^7.0.2",
"conventional-changelog-core": "^7.0.0",
"conventional-changelog-preset-loader": "^4.1.0",
"conventional-changelog-writer": "^7.0.1",
"copyfiles": "2.4.1",
"cross-env": "7.0.3",
"dotenv": "8.6.0",
@@ -127,9 +121,8 @@
"husky": "^8.0.3",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"json5": "^2.2.3",
"jwt-decode": "4.0.0",
"lexical": "0.13.1",
"lexical": "0.14.5",
"lint-staged": "^14.0.1",
"minimist": "1.2.8",
"mongodb-memory-server": "^9.0",
@@ -161,8 +154,8 @@
"tempy": "^1.0.1",
"ts-node": "10.9.1",
"tsx": "^4.7.1",
"turbo": "^1.13.2",
"typescript": "5.4.4",
"turbo": "^1.13.3",
"typescript": "5.4.5",
"uuid": "^9.0.1",
"yocto-queue": "^1.0.0"
},

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.0.0-beta.19",
"version": "3.0.0-beta.23",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

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

View File

@@ -2,16 +2,19 @@ import { SanitizedConfig, sanitizeConfig } from 'payload/config'
import { Config } from 'payload/config'
import { getLocalizedSortProperty } from './getLocalizedSortProperty.js'
const config = sanitizeConfig({
localization: {
locales: ['en', 'es'],
defaultLocale: 'en',
fallback: true,
},
} as Config) as SanitizedConfig
let config: SanitizedConfig
describe('get localized sort property', () => {
it('passes through a non-localized sort property', () => {
beforeAll(async () => {
config = (await sanitizeConfig({
localization: {
locales: ['en', 'es'],
defaultLocale: 'en',
fallback: true,
},
} as Config)) as SanitizedConfig
})
it('passes through a non-localized sort property', async () => {
const result = getLocalizedSortProperty({
segments: ['title'],
config,

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ export const nodemailerAdapter = async (
const { defaultFromAddress, defaultFromName, transport } = await buildEmail(args)
const adapter: NodemailerAdapter = () => ({
name: 'nodemailer',
defaultFromAddress,
defaultFromName,
sendEmail: async (message) => {

View File

@@ -0,0 +1,10 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp

View File

@@ -0,0 +1,7 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
}

View File

@@ -0,0 +1,10 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
}
},
"module": {
"type": "es6"
}
}

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"exclude": [
"/**/mocks",
"/**/*.spec.ts"
],
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
}
},
"module": {
"type": "es6"
}
}

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2022 Payload CMS, LLC <info@payloadcms.com>
Portions Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,30 @@
# Resend REST Email Adapter
This adapter allows you to send emails using the [Resend](https://resend.com) REST API.
## Installation
```sh
pnpm add @payloadcms/email-resend`
```
## Usage
- Sign up for a [Resend](https://resend.com) account
- Set up a domain
- Create an API key
- Set API key as RESEND_API_KEY environment variable
- Configure your Payload config
```ts
// payload.config.js
import { resendAdapter } from '@payloadcms/email-resend'
export default buildConfig({
email: resendAdapter({
defaultFromAddress: 'dev@payloadcms.com',
defaultFromName: 'Payload CMS',
apiKey: process.env.RESEND_API_KEY || '',
}),
})
```

View File

@@ -0,0 +1,17 @@
/** @type {import('jest').Config} */
const customJestConfig = {
rootDir: '.',
extensionsToTreatAsEsm: ['.ts', '.tsx'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
testEnvironment: 'node',
testMatch: ['<rootDir>/**/*spec.ts'],
testTimeout: 10000,
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest'],
},
verbose: true,
}
export default customJestConfig

View File

@@ -0,0 +1,57 @@
{
"name": "@payloadcms/email-resend",
"version": "3.0.0-beta.23",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
"url": "https://github.com/payloadcms/payload.git",
"directory": "packages/email-resend"
},
"license": "MIT",
"author": "Payload CMS, Inc.",
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"require": "./src/index.ts",
"types": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm build:swc && pnpm build:types",
"build:swc": "swc ./src -d ./dist --config-file .swcrc-build",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf {dist,*.tsbuildinfo}",
"prepublishOnly": "pnpm clean && pnpm turbo build",
"test": "jest"
},
"devDependencies": {
"@types/jest": "29.5.12",
"jest": "^29.7.0",
"payload": "workspace:*"
},
"peerDependencies": {
"payload": "workspace:*"
},
"engines": {
"node": ">=18.20.2"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"registry": "https://registry.npmjs.org/",
"types": "./dist/index.d.ts"
}
}

View File

@@ -0,0 +1,87 @@
import { resendAdapter } from './index.js'
import { Payload } from 'payload/types'
describe('email-resend', () => {
const defaultFromAddress = 'dev@payloadcms.com'
const defaultFromName = 'Payload CMS'
const apiKey = 'test-api-key'
const from = 'dev@payloadcms.com'
const to = from
const subject = 'This was sent on init'
const text = 'This is my message body'
const mockPayload = {} as unknown as Payload
afterEach(() => {
jest.clearAllMocks()
})
it('should handle sending an email', async () => {
global.fetch = jest.spyOn(global, 'fetch').mockImplementation(
jest.fn(() =>
Promise.resolve({
json: () => {
return { id: 'test-id' }
},
}),
) as jest.Mock,
) as jest.Mock
const adapter = resendAdapter({
defaultFromAddress,
defaultFromName,
apiKey,
})
await adapter({ payload: mockPayload }).sendEmail({
from,
to,
subject,
text,
})
// @ts-expect-error
expect(global.fetch.mock.calls[0][0]).toStrictEqual('https://api.resend.com/emails')
// @ts-expect-error
const request = global.fetch.mock.calls[0][1]
expect(request.headers.Authorization).toStrictEqual(`Bearer ${apiKey}`)
expect(JSON.parse(request.body)).toMatchObject({
from,
to,
subject,
text,
})
})
it('should throw an error if the email fails to send', async () => {
const errorResponse = {
message: 'error information',
name: 'validation_error',
statusCode: 403,
}
global.fetch = jest.spyOn(global, 'fetch').mockImplementation(
jest.fn(() =>
Promise.resolve({
json: () => errorResponse,
}),
) as jest.Mock,
) as jest.Mock
const adapter = resendAdapter({
defaultFromAddress,
defaultFromName,
apiKey,
})
await expect(() =>
adapter({ payload: mockPayload }).sendEmail({
from,
to,
subject,
text,
}),
).rejects.toThrow(
`Error sending email: ${errorResponse.statusCode} ${errorResponse.name} - ${errorResponse.message}`,
)
})
})

View File

@@ -0,0 +1,240 @@
import type { EmailAdapter } from 'payload/config'
import type { SendEmailOptions } from 'payload/types'
import { APIError } from 'payload/errors'
export type ResendAdapterArgs = {
apiKey: string
defaultFromAddress: string
defaultFromName: string
}
type ResendAdapter = EmailAdapter<ResendResponse>
type ResendError = {
message: string
name: string
statusCode: number
}
type ResendResponse = { id: string } | ResendError
/**
* Email adapter for [Resend](https://resend.com) REST API
*/
export const resendAdapter = (args: ResendAdapterArgs): ResendAdapter => {
const { apiKey, defaultFromAddress, defaultFromName } = args
const adapter: ResendAdapter = () => ({
name: 'resend-rest',
defaultFromAddress,
defaultFromName,
sendEmail: async (message) => {
// Map the Payload email options to Resend email options
const sendEmailOptions = mapPayloadEmailToResendEmail(
message,
defaultFromAddress,
defaultFromName,
)
const res = await fetch('https://api.resend.com/emails', {
body: JSON.stringify(sendEmailOptions),
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
method: 'POST',
})
const data = (await res.json()) as ResendResponse
if ('id' in data) {
return data
} else {
const statusCode = data.statusCode || res.status
let formattedError = `Error sending email: ${statusCode}`
if (data.name && data.message) {
formattedError += ` ${data.name} - ${data.message}`
}
throw new APIError(formattedError, statusCode)
}
},
})
return adapter
}
function mapPayloadEmailToResendEmail(
message: SendEmailOptions,
defaultFromAddress: string,
defaultFromName: string,
): ResendSendEmailOptions {
return {
// Required
from: mapFromAddress(message.from, defaultFromName, defaultFromAddress),
subject: message.subject ?? '',
to: mapAddresses(message.to),
// Other To fields
bcc: mapAddresses(message.bcc),
cc: mapAddresses(message.cc),
// Optional
attachments: mapAttachments(message.attachments),
html: message.html?.toString() || '',
text: message.text?.toString() || '',
} as ResendSendEmailOptions
}
function mapFromAddress(
address: SendEmailOptions['from'],
defaultFromName: string,
defaultFromAddress: string,
): ResendSendEmailOptions['from'] {
if (!address) {
return `${defaultFromName} <${defaultFromAddress}>`
}
if (typeof address === 'string') {
return address
}
return `${address.name} <${address.address}>`
}
function mapAddresses(addresses: SendEmailOptions['to']): ResendSendEmailOptions['to'] {
if (!addresses) {
return ''
}
if (typeof addresses === 'string') {
return addresses
}
if (Array.isArray(addresses)) {
return addresses.map((address) => (typeof address === 'string' ? address : address.address))
}
return [addresses.address]
}
function mapAttachments(
attachments: SendEmailOptions['attachments'],
): ResendSendEmailOptions['attachments'] {
if (!attachments) {
return []
}
return attachments.map((attachment) => {
if (!attachment.filename || !attachment.content) {
throw new APIError('Attachment is missing filename or content', 400)
}
if (typeof attachment.content === 'string') {
return {
content: Buffer.from(attachment.content),
filename: attachment.filename,
}
}
if (attachment.content instanceof Buffer) {
return {
content: attachment.content,
filename: attachment.filename,
}
}
throw new APIError('Attachment content must be a string or a buffer', 400)
})
}
type ResendSendEmailOptions = {
/**
* Filename and content of attachments (max 40mb per email)
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
attachments?: Attachment[]
/**
* Blind carbon copy recipient email address. For multiple addresses, send as an array of strings.
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
bcc?: string | string[]
/**
* Carbon copy recipient email address. For multiple addresses, send as an array of strings.
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
cc?: string | string[]
/**
* Sender email address. To include a friendly name, use the format `"Your Name <sender@domain.com>"`
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
from: string
/**
* Custom headers to add to the email.
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
headers?: Record<string, string>
/**
* The HTML version of the message.
*
* @link https://resend.com/api-reference/emails/send-email#body-parameters
*/
html?: string
/**
* Reply-to email address. For multiple addresses, send as an array of strings.
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
reply_to?: string | string[]
/**
* Email subject.
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
subject: string
/**
* Email tags
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
tags?: Tag[]
/**
* The plain text version of the message.
*
* @link https://resend.com/api-reference/emails/send-email#body-parameters
*/
text?: string
/**
* Recipient email address. For multiple addresses, send as an array of strings. Max 50.
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
to: string | string[]
}
type Attachment = {
/** Content of an attached file. */
content?: Buffer | string
/** Name of attached file. */
filename?: false | string | undefined
/** Path where the attachment file is hosted */
path?: string
}
export type Tag = {
/**
* The name of the email tag. It can only contain ASCII letters (az, AZ), numbers (09), underscores (_), or dashes (-). It can contain no more than 256 characters.
*/
name: string
/**
* The value of the email tag. It can only contain ASCII letters (az, AZ), numbers (09), underscores (_), or dashes (-). It can contain no more than 256 characters.
*/
value: string
}

View File

@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true, // Make sure typescript knows that this module depends on their references
"noEmit": false /* Do not emit outputs. */,
"emitDeclarationOnly": true,
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
"rootDir": "./src" /* Specify the root folder within your source files. */,
"strict": true,
},
"exclude": [
"dist",
"node_modules",
"src/**/*.spec.ts",
],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"],
"references": [
{ "path": "../payload" },
]
}

View File

@@ -53,8 +53,9 @@ const typescriptRules = {
'@typescript-eslint/consistent-type-imports': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
// Type-aware any rules end
// ts-expect should always be preferred over ts-ignore
'@typescript-eslint/prefer-ts-expect-error': 'warn',
// ts-expect preferred over ts-ignore. It will error if the expected error is no longer present.
'@typescript-eslint/prefer-ts-expect-error': 'error',
// By default, it errors for unused variables. This is annoying, warnings are enough.
'@typescript-eslint/no-unused-vars': [
'warn',

View File

@@ -1,3 +1,7 @@
/**
* Disallows imports from .jsx extensions. Auto-fixes to .js.
*/
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {

View File

@@ -1,3 +1,9 @@
/**
* Disallows imports from relative monorepo package paths.
*
* ie. `import { mongooseAdapter } from '../../../packages/mongoose-adapter/src'`
*/
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.0.0-beta.19",
"version": "3.0.0-beta.23",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -462,6 +462,10 @@ function buildObjectType({
async resolve(parent, args, context: Context) {
let depth = config.defaultDepth
if (typeof args.depth !== 'undefined') depth = args.depth
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
// RichText fields have their own depth argument in GraphQL.

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.0.0-beta.19",
"version": "3.0.0-beta.23",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -49,8 +49,10 @@
"@types/busboy": "^1.5.3",
"busboy": "^1.6.0",
"deep-equal": "2.2.2",
"file-type": "19.0.0",
"graphql-http": "^1.22.0",
"graphql-playground-html": "1.6.30",
"http-status": "1.6.2",
"path-to-regexp": "^6.2.1",
"qs": "6.11.2",
"react-diff-viewer-continued": "3.2.6",
@@ -66,7 +68,6 @@
"@types/ws": "^8.5.10",
"css-loader": "^6.10.0",
"css-minimizer-webpack-plugin": "^6.0.0",
"file-type": "16.5.4",
"mini-css-extract-plugin": "1.6.2",
"payload": "workspace:*",
"postcss-loader": "^8.1.1",
@@ -79,9 +80,7 @@
"webpack-cli": "^5.1.4"
},
"peerDependencies": {
"file-type": "16.5.4",
"graphql": "^16.8.1",
"http-status": "1.6.2",
"next": "^14.3.0-canary.7",
"payload": "workspace:*"
},

View File

@@ -5,3 +5,4 @@ export { createPayloadRequest } from '../utilities/createPayloadRequest.js'
export { getNextRequestI18n } from '../utilities/getNextRequestI18n.js'
export { getPayloadHMR, reload } from '../utilities/getPayloadHMR.js'
export { headersWithCors } from '../utilities/headersWithCors.js'
export { initPage } from '../utilities/initPage.js'

View File

@@ -9,6 +9,7 @@ import type { FieldSchemaMap } from '../../utilities/buildFieldSchemaMap/types.j
import { buildFieldSchemaMap } from '../../utilities/buildFieldSchemaMap/index.js'
import { headersWithCors } from '../../utilities/headersWithCors.js'
import { routeError } from './routeError.js'
let cached = global._payload_fieldSchemaMap
@@ -226,14 +227,12 @@ export const buildFormState = async ({ req }: { req: PayloadRequestWithData }) =
status: httpStatus.OK,
})
} catch (err) {
return Response.json(
{
message: 'There was an error building form state',
},
{
headers,
status: httpStatus.BAD_REQUEST,
},
)
req.payload.logger.error({ err, msg: `There was an error building form state` })
return routeError({
config: req.payload.config,
err,
req,
})
}
}

View File

@@ -1,6 +1,6 @@
import type { Collection, PayloadRequestWithData } from 'payload/types'
import getFileType from 'file-type'
import { fileTypeFromFile } from 'file-type'
import fsPromises from 'fs/promises'
import httpStatus from 'http-status'
import path from 'path'
@@ -58,7 +58,7 @@ export const getFile = async ({ collection, filename, req }: Args): Promise<Resp
'Content-Length': stats.size + '',
})
const fileTypeResult = (await getFileType.fromFile(filePath)) || getFileTypeFallback(filePath)
const fileTypeResult = (await fileTypeFromFile(filePath)) || getFileTypeFallback(filePath)
headers.set('Content-Type', fileTypeResult.mime)
return new Response(data, {

View File

@@ -68,6 +68,10 @@ export const traverseFields = ({
break
case 'richText':
if (typeof field.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
if (typeof field.editor.generateSchemaMap === 'function') {
field.editor.generateSchemaMap({
config,

View File

@@ -25,7 +25,15 @@ type Args = {
searchParams: { [key: string]: string | string[] | undefined }
}
const authRoutes = ['/login', '/logout', '/create-first-user', '/forgot', '/reset', '/verify']
const authRoutes = [
'/login',
'/logout',
'/create-first-user',
'/forgot',
'/reset',
'/verify',
'/logout-inactivity',
]
export const initPage = async ({
config: configPromise,
@@ -91,7 +99,8 @@ export const initPage = async ({
const globalSlug = entityType === 'globals' ? entitySlug : undefined
const docID = collectionSlug && createOrID !== 'create' ? createOrID : undefined
const isAuthRoute = authRoutes.some((r) => r === route.replace(adminRoute, ''))
const isAdminRoute = route.startsWith(adminRoute)
const isAuthRoute = authRoutes.some((r) => route.replace(adminRoute, '').startsWith(r))
if (redirectUnauthenticatedUser && !user && !isAuthRoute) {
if (searchParams && 'redirect' in searchParams) delete searchParams.redirect
@@ -103,7 +112,7 @@ export const initPage = async ({
redirect(`${routes.admin}/login?redirect=${route + stringifiedSearchParams}`)
}
if (!permissions.canAccessAdmin && !isAuthRoute) {
if (!permissions.canAccessAdmin && isAdminRoute && !isAuthRoute) {
notFound()
}

View File

@@ -60,6 +60,7 @@ export const Account: React.FC<AdminViewProps> = ({ initPageResult, params, sear
config={payload.config}
hideTabs
i18n={i18n}
permissions={permissions}
/>
<HydrateClientUser permissions={permissions} user={user} />
<FormQueryParamsProvider

View File

@@ -200,6 +200,7 @@ export const Document: React.FC<AdminViewProps> = async ({
config={payload.config}
globalConfig={globalConfig}
i18n={i18n}
permissions={permissions}
/>
)}
<HydrateClientUser permissions={permissions} user={user} />

View File

@@ -88,7 +88,7 @@ export const DefaultEditView: React.FC = () => {
globalSlug: globalConfig?.slug,
})
const operation = id ? 'update' : 'create'
const operation = collectionSlug && !id ? 'create' : 'update'
const auth = collectionConfig ? collectionConfig.auth : undefined
const upload = collectionConfig ? collectionConfig.upload : undefined

View File

@@ -46,10 +46,13 @@ export const NotFoundPage = async ({
[key: string]: string | string[]
}
}) => {
const config = await configPromise
const { routes: { admin: adminRoute } = {} } = config
const initPageResult = await initPage({
config: configPromise,
config,
redirectUnauthenticatedUser: true,
route: '/not-found',
route: `${adminRoute}/not-found`,
searchParams,
})

View File

@@ -0,0 +1,103 @@
'use client'
import type { FormState } from 'payload/types'
import { ConfirmPassword } from '@payloadcms/ui/fields/ConfirmPassword'
import { HiddenInput } from '@payloadcms/ui/fields/HiddenInput'
import { Password } from '@payloadcms/ui/fields/Password'
import { Form, useFormFields } from '@payloadcms/ui/forms/Form'
import { FormSubmit } from '@payloadcms/ui/forms/Submit'
import { useAuth } from '@payloadcms/ui/providers/Auth'
import { useConfig } from '@payloadcms/ui/providers/Config'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import { useRouter } from 'next/navigation.js'
import React from 'react'
import { toast } from 'react-toastify'
type Args = {
token: string
}
const initialState: FormState = {
'confirm-password': {
initialValue: '',
valid: false,
value: '',
},
password: {
initialValue: '',
valid: false,
value: '',
},
}
export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
const i18n = useTranslation()
const {
admin: { user: userSlug },
routes: { admin, api },
serverURL,
} = useConfig()
const history = useRouter()
const { fetchFullUser } = useAuth()
const onSuccess = React.useCallback(
async (data) => {
if (data.token) {
await fetchFullUser()
history.push(`${admin}`)
} else {
history.push(`${admin}/login`)
toast.success(i18n.t('general:updatedSuccessfully'), { autoClose: 3000 })
}
},
[fetchFullUser, history, admin, i18n],
)
return (
<Form
action={`${serverURL}${api}/${userSlug}/reset-password`}
initialState={initialState}
method="POST"
onSuccess={onSuccess}
>
<PasswordToConfirm />
<ConfirmPassword />
<HiddenInput forceUsePathFromProps name="token" value={token} />
<FormSubmit>{i18n.t('authentication:resetPassword')}</FormSubmit>
</Form>
)
}
const PasswordToConfirm = () => {
const { t } = useTranslation()
const { value: confirmValue } = useFormFields(([fields]) => {
return fields['confirm-password']
})
const validate = React.useCallback(
(value: string) => {
if (!value) {
return t('validation:required')
}
if (value === confirmValue) {
return true
}
return t('fields:passwordsDoNotMatch')
},
[confirmValue, t],
)
return (
<Password
autoComplete="off"
label={t('authentication:newPassword')}
name="password"
required
validate={validate}
/>
)
}

View File

@@ -1,15 +1,5 @@
.reset-password {
display: flex;
align-items: center;
flex-wrap: wrap;
min-height: 100vh;
&__wrap {
margin: 0 auto var(--base);
width: 100%;
svg {
width: 100%;
}
form > .field-type {
margin-bottom: var(--base);
}
}

View File

@@ -2,15 +2,11 @@ import type { AdminViewProps } from 'payload/types'
import { Button } from '@payloadcms/ui/elements/Button'
import { Translation } from '@payloadcms/ui/elements/Translation'
import { ConfirmPassword } from '@payloadcms/ui/fields/ConfirmPassword'
import { HiddenInput } from '@payloadcms/ui/fields/HiddenInput'
import { Password } from '@payloadcms/ui/fields/Password'
import { Form } from '@payloadcms/ui/forms/Form'
import { FormSubmit } from '@payloadcms/ui/forms/Submit'
import { MinimalTemplate } from '@payloadcms/ui/templates/Minimal'
import LinkImport from 'next/link.js'
import React from 'react'
import { ResetPasswordClient } from './index.client.js'
import './index.scss'
export const resetPasswordBaseClass = 'reset-password'
@@ -22,7 +18,9 @@ export { generateResetPasswordMetadata } from './meta.js'
export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params }) => {
const { req } = initPageResult
const { token } = params
const {
segments: [_, token],
} = params
const {
i18n,
@@ -31,21 +29,9 @@ export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params
} = req
const {
admin: { user: userSlug },
routes: { admin, api },
serverURL,
routes: { admin },
} = config
// const onSuccess = async (data) => {
// if (data.token) {
// await fetchFullUser()
// history.push(`${admin}`)
// } else {
// history.push(`${admin}/login`)
// toast.success(i18n.t('general:updatedSuccessfully'), { autoClose: 3000 })
// }
// }
if (user) {
return (
<MinimalTemplate className={resetPasswordBaseClass}>
@@ -73,22 +59,7 @@ export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params
<MinimalTemplate className={resetPasswordBaseClass}>
<div className={`${resetPasswordBaseClass}__wrap`}>
<h1>{i18n.t('authentication:resetPassword')}</h1>
<Form
action={`${serverURL}${api}/${userSlug}/reset-password`}
method="POST"
// onSuccess={onSuccess}
redirect={admin}
>
<Password
autoComplete="off"
label={i18n.t('authentication:newPassword')}
name="password"
required
/>
<ConfirmPassword />
<HiddenInput forceUsePathFromProps name="token" value={token} />
<FormSubmit>{i18n.t('authentication:resetPassword')}</FormSubmit>
</Form>
<ResetPasswordClient token={token} />
</div>
</MinimalTemplate>
)

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "3.0.0-beta.19",
"version": "3.0.0-beta.23",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",
@@ -171,7 +171,7 @@
}
},
"engines": {
"node": ">=18.20.2"
"node": "^18.20.2 || >=20.6.0"
},
"publishConfig": {
"access": "public",

View File

@@ -1,7 +1,7 @@
import type { I18n } from '@payloadcms/translations'
import type { JSONSchema4 } from 'json-schema'
import type { SanitizedConfig } from '../config/types.js'
import type { Config, SanitizedConfig } from '../config/types.js'
import type { Field, FieldBase, RichTextField, Validate } from '../fields/config/types.js'
import type { PayloadRequestWithData, RequestContext } from '../types/index.js'
import type { WithServerSideProps } from './elements/WithServerSideProps.js'
@@ -84,3 +84,15 @@ export type RichTextAdapter<
CellComponent: React.FC<any>
FieldComponent: React.FC<RichTextFieldProps<Value, AdapterProps, ExtraFieldProperties>>
}
export type RichTextAdapterProvider<
Value extends object = object,
AdapterProps = any,
ExtraFieldProperties = {},
> = ({
config,
}: {
config: SanitizedConfig
}) =>
| Promise<RichTextAdapter<Value, AdapterProps, ExtraFieldProperties>>
| RichTextAdapter<Value, AdapterProps, ExtraFieldProperties>

View File

@@ -1,5 +1,6 @@
import type { I18n } from '@payloadcms/translations'
import type { Permissions } from '../../auth/types.js'
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { SanitizedConfig } from '../../config/types.js'
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
@@ -10,12 +11,14 @@ export type DocumentTabProps = {
config: SanitizedConfig
globalConfig?: SanitizedGlobalConfig
i18n: I18n
permissions: Permissions
}
export type DocumentTabCondition = (args: {
collectionConfig: SanitizedCollectionConfig
config: SanitizedConfig
globalConfig: SanitizedGlobalConfig
permissions: Permissions
}) => boolean
// Everything is optional because we merge in the defaults

View File

@@ -1,4 +1,4 @@
export type { RichTextAdapter, RichTextFieldProps } from './RichText.js'
export type { RichTextAdapter, RichTextAdapterProvider, RichTextFieldProps } from './RichText.js'
export type { CellComponentProps, DefaultCellComponentProps } from './elements/Cell.js'
export type { ConditionalDateProps } from './elements/DatePicker.js'
export type { DayPickerProps, SharedProps, TimePickerProps } from './elements/DatePicker.js'

View File

@@ -17,7 +17,7 @@ export async function getAccessResults({ req }: GetAccessResultsArgs): Promise<P
? payload.config.collections.find((collection) => collection.slug === user.collection)
: null
if (userCollectionConfig) {
if (userCollectionConfig && payload.config.admin.user === user.collection) {
results.canAccessAdmin = userCollectionConfig.access.admin
? await userCollectionConfig.access.admin({ req })
: isLoggedIn

View File

@@ -101,11 +101,11 @@ export const forgotPasswordOperation = async (incomingArgs: Arguments): Promise<
})
if (!disableEmail) {
const protocol = new URL(req.url).protocol
const protocol = new URL(req.url).protocol // includes the final :
const serverURL =
config.serverURL !== null && config.serverURL !== ''
? config.serverURL
: `${protocol}://${req.headers.get('host')}`
: `${protocol}//${req.headers.get('host')}`
let html = `${req.t('authentication:youAreReceivingResetPassword')}
<a href="${serverURL}${config.routes.admin}/reset/${token}">

View File

@@ -141,6 +141,10 @@ export const loginOperation = async <TSlug extends keyof GeneratedTypes['collect
user,
})
// /////////////////////////////////////
// beforeLogin - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeLogin.reduce(async (priorHook, hook) => {
await priorHook

View File

@@ -29,11 +29,11 @@ export async function sendVerificationEmail(args: Args): Promise<void> {
} = args
if (!disableEmail) {
const protocol = new URL(req.url).protocol
const protocol = new URL(req.url).protocol // includes the final :
const serverURL =
config.serverURL !== null && config.serverURL !== ''
? config.serverURL
: `${protocol}://${req.headers.get('host')}`
: `${protocol}//${req.headers.get('host')}`
const verificationURL = `${serverURL}${config.routes.admin}/${collectionConfig.slug}/verify/${token}`

View File

@@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from 'url'
import { CLIENT_EXTENSIONS } from './clientExtensions.js'
import { compile } from './compile.js'
import { resolveOriginalPath } from './resolveOriginalPath.js'
interface ResolveContext {
conditions: string[]
@@ -115,7 +116,7 @@ export const resolve: ResolveFn = async (specifier, context, nextResolve) => {
return {
format: resolvedIsTS ? 'ts' : undefined,
shortCircuit: true,
url: pathToFileURL(resolvedModule.resolvedFileName).href,
url: pathToFileURL(await resolveOriginalPath(resolvedModule.resolvedFileName)).href, // The typescript module resolver does not resolve to the original path, but to the symlinked path, if present. This can cause issues
}
}

View File

@@ -0,0 +1,35 @@
import fs from 'fs/promises'
import path from 'path'
/**
* In case any directory in the path contains symlinks, this function attempts to detect that resolves the original path.
*
* Example Input: /Users/alessio/Documents/GitHub/payload-3.0-alpha-demo/node_modules/tailwindcss/resolveConfig.js
* The "tailwindcss" in this example is a symlinked directory.
*
* Example Output: /Users/alessio/Documents/GitHub/payload-3.0-alpha-demo/node_modules/.pnpm/tailwindcss@3.4.3/node_modules/tailwindcss/resolveConfig.js
*/
export async function resolveOriginalPath(filePath: string) {
try {
// Normalize and split the path
const parts = path.resolve(filePath).split(path.sep)
let currentPath = '/'
// skip the first slash
for (const part of parts.slice(1)) {
currentPath = path.join(currentPath, part)
// Check if the current path component is a symlink
const stats = await fs.lstat(currentPath)
if (stats.isSymbolicLink()) {
// Resolve the symlink
const resolvedLink = await fs.readlink(currentPath)
currentPath = path.join(path.dirname(currentPath), resolvedLink)
}
}
return currentPath
} catch (error) {
console.error('Error resolving path:', error)
}
}

View File

@@ -1,6 +1,6 @@
import merge from 'deepmerge'
import type { Config } from '../../config/types.js'
import type { Config, SanitizedConfig } from '../../config/types.js'
import type { CollectionConfig, SanitizedCollectionConfig } from './types.js'
import baseAccountLockFields from '../../auth/baseFields/accountLock.js'
@@ -17,10 +17,15 @@ import { isPlainObject } from '../../utilities/isPlainObject.js'
import baseVersionFields from '../../versions/baseFields.js'
import { authDefaults, defaults } from './defaults.js'
const sanitizeCollection = (
export const sanitizeCollection = async (
config: Config,
collection: CollectionConfig,
): SanitizedCollectionConfig => {
/**
* If this property is set, RichText fields won't be sanitized immediately. Instead, they will be added to this array as promises
* so that you can sanitize them together, after the config has been sanitized.
*/
richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise<void>>,
): Promise<SanitizedCollectionConfig> => {
// /////////////////////////////////
// Make copy of collection config
// /////////////////////////////////
@@ -151,13 +156,12 @@ const sanitizeCollection = (
// /////////////////////////////////
const validRelationships = config.collections.map((c) => c.slug) || []
sanitized.fields = sanitizeFields({
sanitized.fields = await sanitizeFields({
config,
fields: sanitized.fields,
richTextSanitizationPromises,
validRelationships,
})
return sanitized as SanitizedCollectionConfig
}
export default sanitizeCollection

View File

@@ -152,6 +152,7 @@ const collectionSchema = joi.object().keys({
}),
upload: joi.alternatives().try(
joi.object({
adapter: joi.string(),
adminThumbnail: joi.alternatives().try(joi.string(), componentSchema),
crop: joi.bool(),
disableLocalStorage: joi.bool(),

View File

@@ -1,5 +1,3 @@
/* eslint-disable no-use-before-define */
/* eslint-disable no-nested-ternary */
import type { Config, SanitizedConfig } from './types.js'
import { sanitizeConfig } from './sanitize.js'
@@ -16,10 +14,8 @@ export async function buildConfig(config: Config): Promise<SanitizedConfig> {
return plugin(configAfterPlugin)
}, Promise.resolve(config))
const sanitizedConfig = sanitizeConfig(configAfterPlugins)
return sanitizedConfig
return await sanitizeConfig(configAfterPlugins)
}
return sanitizeConfig(config)
return await sanitizeConfig(config)
}

View File

@@ -9,10 +9,10 @@ import type {
} from './types.js'
import { defaultUserCollection } from '../auth/defaultUser.js'
import sanitizeCollection from '../collections/config/sanitize.js'
import { sanitizeCollection } from '../collections/config/sanitize.js'
import { migrationsCollection } from '../database/migrations/migrationsCollection.js'
import { InvalidConfiguration } from '../errors/index.js'
import sanitizeGlobals from '../globals/config/sanitize.js'
import { sanitizeGlobals } from '../globals/config/sanitize.js'
import getPreferencesCollection from '../preferences/preferencesCollection.js'
import checkDuplicateCollections from '../utilities/checkDuplicateCollections.js'
import { isPlainObject } from '../utilities/isPlainObject.js'
@@ -32,16 +32,19 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig>
}
}
if (!sanitizedConfig.collections.find(({ slug }) => slug === sanitizedConfig.admin.user)) {
const userCollection = sanitizedConfig.collections.find(
({ slug }) => slug === sanitizedConfig.admin.user,
)
if (!userCollection || !userCollection.auth) {
throw new InvalidConfiguration(
`${sanitizedConfig.admin.user} is not a valid admin user collection`,
)
}
return sanitizedConfig as Partial<SanitizedConfig>
return sanitizedConfig as unknown as Partial<SanitizedConfig>
}
export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => {
export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedConfig> => {
const configWithDefaults: Config = merge(defaults, incomingConfig, {
isMergeableObject: isPlainObject,
}) as Config
@@ -94,21 +97,51 @@ export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => {
...(incomingConfig?.i18n ?? {}),
}
configWithDefaults.collections.push(getPreferencesCollection(configWithDefaults))
configWithDefaults.collections.push(getPreferencesCollection(config as unknown as Config))
configWithDefaults.collections.push(migrationsCollection)
config.collections = config.collections.map((collection) =>
sanitizeCollection(configWithDefaults, collection),
)
const richTextSanitizationPromises: Array<(config: SanitizedConfig) => Promise<void>> = []
for (let i = 0; i < config.collections.length; i++) {
config.collections[i] = await sanitizeCollection(
config as unknown as Config,
config.collections[i],
richTextSanitizationPromises,
)
}
checkDuplicateCollections(config.collections)
if (config.globals.length > 0) {
config.globals = sanitizeGlobals(config as SanitizedConfig)
config.globals = await sanitizeGlobals(
config as unknown as Config,
richTextSanitizationPromises,
)
}
if (config.serverURL !== '') {
config.csrf.push(config.serverURL)
}
// Get deduped list of upload adapters
if (!config.upload) config.upload = { adapters: [] }
config.upload.adapters = Array.from(
new Set(config.collections.map((c) => c.upload?.adapter).filter(Boolean)),
)
/*
Execute richText sanitization
*/
if (typeof incomingConfig.editor === 'function') {
config.editor = await incomingConfig.editor({
config: config as SanitizedConfig,
})
}
const promises: Promise<void>[] = []
for (const sanitizeFunction of richTextSanitizationPromises) {
promises.push(sanitizeFunction(config as SanitizedConfig))
}
await Promise.all(promises)
return config as SanitizedConfig
}

View File

@@ -101,7 +101,7 @@ export default joi.object({
validate: joi.func().required(),
})
.unknown(),
email: joi.object(),
email: joi.alternatives().try(joi.object(), joi.func()),
endpoints: endpointsSchema,
globals: joi.array(),
graphQL: joi.object().keys({

View File

@@ -6,6 +6,7 @@ import type React from 'react'
import type { default as sharp } from 'sharp'
import type { DeepRequired } from 'ts-essentials'
import type { RichTextAdapterProvider } from '../admin/RichText.js'
import type { DocumentTab, RichTextAdapter } from '../admin/types.js'
import type { AdminView, ServerSideEditViewProps } from '../admin/views/types.js'
import type { User } from '../auth/types.js'
@@ -527,7 +528,7 @@ export type Config = {
*/
defaultMaxTextLength?: number
/** Default richtext editor to use for richText fields */
editor: RichTextAdapter<any, any, any>
editor: RichTextAdapterProvider<any, any, any>
/**
* Email Adapter
*
@@ -640,9 +641,11 @@ export type Config = {
export type SanitizedConfig = Omit<
DeepRequired<Config>,
'collections' | 'endpoint' | 'globals' | 'i18n' | 'localization'
'collections' | 'editor' | 'endpoint' | 'globals' | 'i18n' | 'localization' | 'upload'
> & {
collections: SanitizedCollectionConfig[]
/** Default richtext editor to use for richText fields */
editor: RichTextAdapter<any, any, any>
endpoints: Endpoint[]
globals: SanitizedGlobalConfig[]
i18n: Required<I18nOptions>
@@ -652,6 +655,12 @@ export type SanitizedConfig = Omit<
configDir: string
rawConfig: string
}
upload: ExpressFileUploadOptions & {
/**
* Deduped list of adapters used in the project
*/
adapters: string[]
}
}
export type EditConfig =

View File

@@ -4,6 +4,7 @@ import { emailDefaults } from './defaults.js'
import { getStringifiedToAddress } from './getStringifiedToAddress.js'
export const consoleEmailAdapter: EmailAdapter<void> = ({ payload }) => ({
name: 'console',
defaultFromAddress: emailDefaults.defaultFromAddress,
defaultFromName: emailDefaults.defaultFromName,
sendEmail: async (message) => {

View File

@@ -27,5 +27,6 @@ export type InitializedEmailAdapter<TSendEmailResponse = unknown> = ReturnType<
export type EmailAdapter<TSendEmailResponse = unknown> = ({ payload }: { payload: Payload }) => {
defaultFromAddress: string
defaultFromName: string
name: string
sendEmail: (message: SendEmailOptions) => Promise<TSendEmailResponse>
}

View File

@@ -0,0 +1 @@
export { getLocalI18n } from '../translations/getLocalI18n.js'

View File

@@ -1,3 +1,6 @@
import { Config } from '../../config/types.js'
import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors/index.js'
import { sanitizeFields } from './sanitize.js'
import type {
ArrayField,
Block,
@@ -6,41 +9,27 @@ import type {
Field,
NumberField,
TextField,
} from './types'
import { Config } from '../../config/types'
import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors'
import { sanitizeFields } from './sanitize'
import type { BaseDatabaseAdapter } from '../../database/types.js'
const dummyConfig: Config = {
collections: [],
db: {
defaultIDType: 'text',
init: () => ({}) as BaseDatabaseAdapter['init'],
} as BaseDatabaseAdapter,
}
} from './types.js'
describe('sanitizeFields', () => {
it('should throw on missing type field', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const config = {} as Config
it('should throw on missing type field', async () => {
const fields: Field[] = [
// @ts-expect-error
{
label: 'some-collection',
name: 'Some Collection',
},
]
expect(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
sanitizeFields({
config: dummyConfig,
await expect(async () => {
await sanitizeFields({
config,
fields,
validRelationships: [],
})
}).toThrow(MissingFieldType)
}).rejects.toThrow(MissingFieldType)
})
it('should throw on invalid field name', () => {
it('should throw on invalid field name', async () => {
const fields: Field[] = [
{
label: 'some.collection',
@@ -48,33 +37,35 @@ describe('sanitizeFields', () => {
type: 'text',
},
]
expect(() => {
sanitizeFields({
config: dummyConfig,
await expect(async () => {
await sanitizeFields({
config,
fields,
validRelationships: [],
})
}).toThrow(InvalidFieldName)
}).rejects.toThrow(InvalidFieldName)
})
describe('auto-labeling', () => {
it('should populate label if missing', () => {
it('should populate label if missing', async () => {
const fields: Field[] = [
{
name: 'someField',
type: 'text',
},
]
const sanitizedField = sanitizeFields({
config: dummyConfig,
fields,
validRelationships: [],
})[0] as TextField
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})
)[0] as TextField
expect(sanitizedField.name).toStrictEqual('someField')
expect(sanitizedField.label).toStrictEqual('Some Field')
expect(sanitizedField.type).toStrictEqual('text')
})
it('should allow auto-label override', () => {
it('should allow auto-label override', async () => {
const fields: Field[] = [
{
label: 'Do not label',
@@ -82,18 +73,20 @@ describe('sanitizeFields', () => {
type: 'text',
},
]
const sanitizedField = sanitizeFields({
config: dummyConfig,
fields,
validRelationships: [],
})[0] as TextField
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})
)[0] as TextField
expect(sanitizedField.name).toStrictEqual('someField')
expect(sanitizedField.label).toStrictEqual('Do not label')
expect(sanitizedField.type).toStrictEqual('text')
})
describe('opt-out', () => {
it('should allow label opt-out', () => {
it('should allow label opt-out', async () => {
const fields: Field[] = [
{
label: false,
@@ -101,17 +94,19 @@ describe('sanitizeFields', () => {
type: 'text',
},
]
const sanitizedField = sanitizeFields({
config: dummyConfig,
fields,
validRelationships: [],
})[0] as TextField
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})
)[0] as TextField
expect(sanitizedField.name).toStrictEqual('someField')
expect(sanitizedField.label).toStrictEqual(false)
expect(sanitizedField.type).toStrictEqual('text')
})
it('should allow label opt-out for arrays', () => {
it('should allow label opt-out for arrays', async () => {
const arrayField: ArrayField = {
fields: [
{
@@ -123,17 +118,19 @@ describe('sanitizeFields', () => {
name: 'items',
type: 'array',
}
const sanitizedField = sanitizeFields({
config: dummyConfig,
fields: [arrayField],
validRelationships: [],
})[0] as ArrayField
const sanitizedField = (
await sanitizeFields({
config,
fields: [arrayField],
validRelationships: [],
})
)[0] as ArrayField
expect(sanitizedField.name).toStrictEqual('items')
expect(sanitizedField.label).toStrictEqual(false)
expect(sanitizedField.type).toStrictEqual('array')
expect(sanitizedField.labels).toBeUndefined()
})
it('should allow label opt-out for blocks', () => {
it('should allow label opt-out for blocks', async () => {
const fields: Field[] = [
{
blocks: [
@@ -152,11 +149,13 @@ describe('sanitizeFields', () => {
type: 'blocks',
},
]
const sanitizedField = sanitizeFields({
config: dummyConfig,
fields,
validRelationships: [],
})[0] as BlockField
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})
)[0] as BlockField
expect(sanitizedField.name).toStrictEqual('noLabelBlock')
expect(sanitizedField.label).toStrictEqual(false)
expect(sanitizedField.type).toStrictEqual('blocks')
@@ -164,7 +163,7 @@ describe('sanitizeFields', () => {
})
})
it('should label arrays with plural and singular', () => {
it('should label arrays with plural and singular', async () => {
const fields: Field[] = [
{
fields: [
@@ -177,18 +176,20 @@ describe('sanitizeFields', () => {
type: 'array',
},
]
const sanitizedField = sanitizeFields({
config: dummyConfig,
fields,
validRelationships: [],
})[0] as ArrayField
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})
)[0] as ArrayField
expect(sanitizedField.name).toStrictEqual('items')
expect(sanitizedField.label).toStrictEqual('Items')
expect(sanitizedField.type).toStrictEqual('array')
expect(sanitizedField.labels).toMatchObject({ plural: 'Items', singular: 'Item' })
})
it('should label blocks with plural and singular', () => {
it('should label blocks with plural and singular', async () => {
const fields: Field[] = [
{
blocks: [
@@ -201,11 +202,13 @@ describe('sanitizeFields', () => {
type: 'blocks',
},
]
const sanitizedField = sanitizeFields({
config: dummyConfig,
fields,
validRelationships: [],
})[0] as BlockField
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})
)[0] as BlockField
expect(sanitizedField.name).toStrictEqual('specialBlock')
expect(sanitizedField.label).toStrictEqual('Special Block')
expect(sanitizedField.type).toStrictEqual('blocks')
@@ -218,7 +221,7 @@ describe('sanitizeFields', () => {
})
describe('relationships', () => {
it('should not throw on valid relationship', () => {
it('should not throw on valid relationship', async () => {
const validRelationships = ['some-collection']
const fields: Field[] = [
{
@@ -228,12 +231,12 @@ describe('sanitizeFields', () => {
type: 'relationship',
},
]
expect(() => {
sanitizeFields({ config: dummyConfig, fields, validRelationships })
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
}).not.toThrow()
})
it('should not throw on valid relationship - multiple', () => {
it('should not throw on valid relationship - multiple', async () => {
const validRelationships = ['some-collection', 'another-collection']
const fields: Field[] = [
{
@@ -243,12 +246,12 @@ describe('sanitizeFields', () => {
type: 'relationship',
},
]
expect(() => {
sanitizeFields({ config: dummyConfig, fields, validRelationships })
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
}).not.toThrow()
})
it('should not throw on valid relationship inside blocks', () => {
it('should not throw on valid relationship inside blocks', async () => {
const validRelationships = ['some-collection']
const relationshipBlock: Block = {
fields: [
@@ -269,12 +272,12 @@ describe('sanitizeFields', () => {
type: 'blocks',
},
]
expect(() => {
sanitizeFields({ config: dummyConfig, fields, validRelationships })
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
}).not.toThrow()
})
it('should throw on invalid relationship', () => {
it('should throw on invalid relationship', async () => {
const validRelationships = ['some-collection']
const fields: Field[] = [
{
@@ -284,12 +287,12 @@ describe('sanitizeFields', () => {
type: 'relationship',
},
]
expect(() => {
sanitizeFields({ config: dummyConfig, fields, validRelationships })
}).toThrow(InvalidFieldRelationship)
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
}).rejects.toThrow(InvalidFieldRelationship)
})
it('should throw on invalid relationship - multiple', () => {
it('should throw on invalid relationship - multiple', async () => {
const validRelationships = ['some-collection', 'another-collection']
const fields: Field[] = [
{
@@ -299,12 +302,12 @@ describe('sanitizeFields', () => {
type: 'relationship',
},
]
expect(() => {
sanitizeFields({ config: dummyConfig, fields, validRelationships })
}).toThrow(InvalidFieldRelationship)
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
}).rejects.toThrow(InvalidFieldRelationship)
})
it('should throw on invalid relationship inside blocks', () => {
it('should throw on invalid relationship inside blocks', async () => {
const validRelationships = ['some-collection']
const relationshipBlock: Block = {
fields: [
@@ -325,12 +328,12 @@ describe('sanitizeFields', () => {
type: 'blocks',
},
]
expect(() => {
sanitizeFields({ config: dummyConfig, fields, validRelationships })
}).toThrow(InvalidFieldRelationship)
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
}).rejects.toThrow(InvalidFieldRelationship)
})
it('should defaultValue of checkbox to false if required and undefined', () => {
it('should defaultValue of checkbox to false if required and undefined', async () => {
const fields: Field[] = [
{
name: 'My Checkbox',
@@ -339,17 +342,19 @@ describe('sanitizeFields', () => {
},
]
const sanitizedField = sanitizeFields({
config: dummyConfig,
fields,
validRelationships: [],
})[0] as CheckboxField
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})
)[0] as CheckboxField
expect(sanitizedField.defaultValue).toStrictEqual(false)
})
it('should return empty field array if no fields', () => {
const sanitizedFields = sanitizeFields({
config: dummyConfig,
it('should return empty field array if no fields', async () => {
const sanitizedFields = await sanitizeFields({
config,
fields: [],
validRelationships: [],
})

View File

@@ -1,4 +1,4 @@
import type { Config } from '../../config/types.js'
import type { Config, SanitizedConfig } from '../../config/types.js'
import type { Field } from './types.js'
import { MissingEditorProp } from '../../errors/MissingEditorProp.js'
@@ -25,6 +25,12 @@ type Args = {
* @default false
*/
requireFieldLevelRichTextEditor?: boolean
/**
* If this property is set, RichText fields won't be sanitized immediately. Instead, they will be added to this array as promises
* so that you can sanitize them together, after the config has been sanitized.
*/
richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise<void>>
/**
* If not null, will validate that upload and relationship fields do not relate to a collection that is not in this array.
* This validation will be skipped if validRelationships is null.
@@ -32,17 +38,18 @@ type Args = {
validRelationships: null | string[]
}
export const sanitizeFields = ({
export const sanitizeFields = async ({
config,
existingFieldNames = new Set(),
fields,
requireFieldLevelRichTextEditor = false,
richTextSanitizationPromises,
validRelationships,
}: Args): Field[] => {
}: Args): Promise<Field[]> => {
if (!fields) return []
return fields.map((unsanitizedField) => {
const field: Field = { ...unsanitizedField }
for (let i = 0; i < fields.length; i++) {
const field = fields[i]
if (!field.type) throw new MissingFieldType(field)
@@ -143,88 +150,95 @@ export const sanitizeFields = ({
// Make sure that the richText field has an editor
if (field.type === 'richText') {
if (!field.editor) {
if (config.editor && !requireFieldLevelRichTextEditor) {
field.editor = config.editor
} else {
throw new MissingEditorProp(field)
const sanitizeRichText = async (_config: SanitizedConfig) => {
if (!field.editor) {
if (_config.editor && !requireFieldLevelRichTextEditor) {
// config.editor should be sanitized at this point
field.editor = _config.editor
} else {
throw new MissingEditorProp(field)
}
}
}
// Add editor adapter hooks to field hooks
if (!field.hooks) field.hooks = {}
if (typeof field.editor === 'function') {
field.editor = await field.editor({ config: _config })
}
if (field?.editor?.hooks?.afterRead?.length) {
field.hooks.afterRead = field.hooks.afterRead
? field.hooks.afterRead.concat(field.editor.hooks.afterRead)
: field.editor.hooks.afterRead
// Add editor adapter hooks to field hooks
if (!field.hooks) field.hooks = {}
const mergeHooks = (hookName: keyof typeof field.editor.hooks) => {
if (typeof field.editor === 'function') return
if (field.editor?.hooks?.[hookName]?.length) {
field.hooks[hookName] = field.hooks[hookName]
? field.hooks[hookName].concat(field.editor.hooks[hookName])
: [...field.editor.hooks[hookName]]
}
}
mergeHooks('afterRead')
mergeHooks('afterChange')
mergeHooks('beforeChange')
mergeHooks('beforeValidate')
mergeHooks('beforeDuplicate')
}
if (field?.editor?.hooks?.beforeChange?.length) {
field.hooks.beforeChange = field.hooks.beforeChange
? field.hooks.beforeChange.concat(field.editor.hooks.beforeChange)
: field.editor.hooks.beforeChange
}
if (field?.editor?.hooks?.beforeValidate?.length) {
field.hooks.beforeValidate = field.hooks.beforeValidate
? field.hooks.beforeValidate.concat(field.editor.hooks.beforeValidate)
: field.editor.hooks.beforeValidate
}
if (field?.editor?.hooks?.beforeChange?.length) {
field.hooks.beforeChange = field.hooks.beforeChange
? field.hooks.beforeChange.concat(field.editor.hooks.beforeChange)
: field.editor.hooks.beforeChange
if (richTextSanitizationPromises) {
richTextSanitizationPromises.push(sanitizeRichText)
} else {
await sanitizeRichText(config as unknown as SanitizedConfig)
}
}
// TODO: Handle sanitization for any lexical sub-fields here as well
if ('fields' in field && field.fields) {
field.fields = sanitizeFields({
field.fields = await sanitizeFields({
config,
existingFieldNames: fieldAffectsData(field) ? new Set() : existingFieldNames,
fields: field.fields,
requireFieldLevelRichTextEditor,
richTextSanitizationPromises,
validRelationships,
})
}
if (field.type === 'tabs') {
field.tabs = field.tabs.map((tab) => {
const unsanitizedTab = { ...tab }
for (let j = 0; j < field.tabs.length; j++) {
const tab = field.tabs[j]
if (tabHasName(tab) && typeof tab.label === 'undefined') {
unsanitizedTab.label = toWords(tab.name)
tab.label = toWords(tab.name)
}
unsanitizedTab.fields = sanitizeFields({
tab.fields = await sanitizeFields({
config,
existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames,
fields: tab.fields,
requireFieldLevelRichTextEditor,
richTextSanitizationPromises,
validRelationships,
})
return unsanitizedTab
})
field.tabs[j] = tab
}
}
if ('blocks' in field && field.blocks) {
field.blocks = field.blocks.map((block) => {
const unsanitizedBlock = { ...block }
unsanitizedBlock.labels = !unsanitizedBlock.labels
? formatLabels(unsanitizedBlock.slug)
: unsanitizedBlock.labels
for (let j = 0; j < field.blocks.length; j++) {
const block = field.blocks[j]
block.labels = !block.labels ? formatLabels(block.slug) : block.labels
unsanitizedBlock.fields = sanitizeFields({
block.fields = await sanitizeFields({
config,
existingFieldNames: new Set(),
fields: block.fields,
requireFieldLevelRichTextEditor,
richTextSanitizationPromises,
validRelationships,
})
return unsanitizedBlock
})
field.blocks[j] = block
}
}
return field
})
fields[i] = field
}
return fields
}

View File

@@ -7,12 +7,12 @@ import type { CSSProperties } from 'react'
import monacoeditor from 'monaco-editor' // IMPORTANT - DO NOT REMOVE: This is required for pnpm's default isolated mode to work - even though the import is not used. This is due to a typescript bug: https://github.com/microsoft/TypeScript/issues/47663#issuecomment-1519138189. (tsbugisolatedmode)
import type React from 'react'
import type { RichTextAdapter, RichTextAdapterProvider } from '../../admin/RichText.js'
import type {
ConditionalDateProps,
Description,
ErrorProps,
LabelProps,
RichTextAdapter,
RowLabel,
} from '../../admin/types.js'
import type { SanitizedCollectionConfig, TypeWithID } from '../../collections/config/types.js'
@@ -585,7 +585,9 @@ export type RichTextField<
Label?: CustomComponent<LabelProps>
}
}
editor?: RichTextAdapter<Value, AdapterProps, AdapterProps>
editor?:
| RichTextAdapter<Value, AdapterProps, AdapterProps>
| RichTextAdapterProvider<Value, AdapterProps, AdapterProps>
type: 'richText'
} & ExtraProperties
@@ -594,7 +596,9 @@ export type RichTextFieldRequiredEditor<
AdapterProps = any,
ExtraProperties = object,
> = Omit<RichTextField<Value, AdapterProps, ExtraProperties>, 'editor'> & {
editor: RichTextAdapter<Value, AdapterProps, ExtraProperties>
editor:
| RichTextAdapter<Value, AdapterProps, AdapterProps>
| RichTextAdapterProvider<Value, AdapterProps, AdapterProps>
}
export type ArrayField = FieldBase & {

View File

@@ -141,6 +141,10 @@ export const promise = async ({
}
case 'richText': {
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
// This is run here AND in the GraphQL Resolver
if (editor?.populationPromises) {

View File

@@ -264,6 +264,10 @@ export const richText: Validate<object, unknown, unknown, RichTextField> = async
value,
options,
) => {
if (typeof options?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = options?.editor
return editor.validate(value, options)

View File

@@ -1,4 +1,4 @@
import type { Config } from '../../config/types.js'
import type { Config, SanitizedConfig } from '../../config/types.js'
import type { SanitizedGlobalConfig } from './types.js'
import defaultAccess from '../../auth/defaultAccess.js'
@@ -8,60 +8,66 @@ import mergeBaseFields from '../../fields/mergeBaseFields.js'
import { toWords } from '../../utilities/formatLabels.js'
import baseVersionFields from '../../versions/baseFields.js'
const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => {
export const sanitizeGlobals = async (
config: Config,
/**
* If this property is set, RichText fields won't be sanitized immediately. Instead, they will be added to this array as promises
* so that you can sanitize them together, after the config has been sanitized.
*/
richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise<void>>,
): Promise<SanitizedGlobalConfig[]> => {
const { collections, globals } = config
const sanitizedGlobals = globals.map((global) => {
const sanitizedGlobal = { ...global }
sanitizedGlobal.label = sanitizedGlobal.label || toWords(sanitizedGlobal.slug)
for (let i = 0; i < globals.length; i++) {
const global = globals[i]
global.label = global.label || toWords(global.slug)
// /////////////////////////////////
// Ensure that collection has required object structure
// /////////////////////////////////
sanitizedGlobal.endpoints = sanitizedGlobal.endpoints ?? []
if (!sanitizedGlobal.hooks) sanitizedGlobal.hooks = {}
if (!sanitizedGlobal.access) sanitizedGlobal.access = {}
if (!sanitizedGlobal.admin) sanitizedGlobal.admin = {}
global.endpoints = global.endpoints ?? []
if (!global.hooks) global.hooks = {}
if (!global.access) global.access = {}
if (!global.admin) global.admin = {}
if (!sanitizedGlobal.access.read) sanitizedGlobal.access.read = defaultAccess
if (!sanitizedGlobal.access.update) sanitizedGlobal.access.update = defaultAccess
if (!global.access.read) global.access.read = defaultAccess
if (!global.access.update) global.access.update = defaultAccess
if (!sanitizedGlobal.hooks.beforeValidate) sanitizedGlobal.hooks.beforeValidate = []
if (!sanitizedGlobal.hooks.beforeChange) sanitizedGlobal.hooks.beforeChange = []
if (!sanitizedGlobal.hooks.afterChange) sanitizedGlobal.hooks.afterChange = []
if (!sanitizedGlobal.hooks.beforeRead) sanitizedGlobal.hooks.beforeRead = []
if (!sanitizedGlobal.hooks.afterRead) sanitizedGlobal.hooks.afterRead = []
if (!global.hooks.beforeValidate) global.hooks.beforeValidate = []
if (!global.hooks.beforeChange) global.hooks.beforeChange = []
if (!global.hooks.afterChange) global.hooks.afterChange = []
if (!global.hooks.beforeRead) global.hooks.beforeRead = []
if (!global.hooks.afterRead) global.hooks.afterRead = []
if (sanitizedGlobal.versions) {
if (sanitizedGlobal.versions === true) sanitizedGlobal.versions = { drafts: false }
if (global.versions) {
if (global.versions === true) global.versions = { drafts: false }
if (sanitizedGlobal.versions.drafts) {
if (sanitizedGlobal.versions.drafts === true) {
sanitizedGlobal.versions.drafts = {
if (global.versions.drafts) {
if (global.versions.drafts === true) {
global.versions.drafts = {
autosave: false,
}
}
if (sanitizedGlobal.versions.drafts.autosave === true) {
sanitizedGlobal.versions.drafts.autosave = {
if (global.versions.drafts.autosave === true) {
global.versions.drafts.autosave = {
interval: 2000,
}
}
sanitizedGlobal.fields = mergeBaseFields(sanitizedGlobal.fields, baseVersionFields)
global.fields = mergeBaseFields(global.fields, baseVersionFields)
}
}
if (!sanitizedGlobal.custom) sanitizedGlobal.custom = {}
if (!global.custom) global.custom = {}
// /////////////////////////////////
// Sanitize fields
// /////////////////////////////////
let hasUpdatedAt = null
let hasCreatedAt = null
sanitizedGlobal.fields.some((field) => {
global.fields.some((field) => {
if (fieldAffectsData(field)) {
if (field.name === 'updatedAt') hasUpdatedAt = true
if (field.name === 'createdAt') hasCreatedAt = true
@@ -69,7 +75,7 @@ const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => {
return hasCreatedAt && hasUpdatedAt
})
if (!hasUpdatedAt) {
sanitizedGlobal.fields.push({
global.fields.push({
name: 'updatedAt',
type: 'date',
admin: {
@@ -80,7 +86,7 @@ const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => {
})
}
if (!hasCreatedAt) {
sanitizedGlobal.fields.push({
global.fields.push({
name: 'createdAt',
type: 'date',
admin: {
@@ -92,16 +98,15 @@ const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => {
}
const validRelationships = collections.map((c) => c.slug) || []
sanitizedGlobal.fields = sanitizeFields({
global.fields = await sanitizeFields({
config,
fields: sanitizedGlobal.fields,
fields: global.fields,
richTextSanitizationPromises,
validRelationships,
})
return sanitizedGlobal as SanitizedGlobalConfig
})
globals[i] = global
}
return sanitizedGlobals
return globals as SanitizedGlobalConfig[]
}
export default sanitizeGlobals

View File

@@ -70,6 +70,10 @@ export type ImageSize = Omit<ResizeOptions, 'withoutEnlargement'> & {
export type GetAdminThumbnail = (args: { doc: Record<string, unknown> }) => false | null | string
export type UploadConfig = {
/**
* The adapter to use for uploads.
*/
adapter?: string
/**
* Represents an admin thumbnail, which can be either a React component or a string.
* - If a string, it should be one of the image size names.

View File

@@ -4,7 +4,7 @@ import { sanitizeConfig } from '../config/sanitize.js'
import { configToJSONSchema } from './configToJSONSchema.js'
describe('configToJSONSchema', () => {
it('should handle optional arrays with required fields', () => {
it('should handle optional arrays with required fields', async () => {
const config: Config = {
collections: [
{
@@ -27,7 +27,7 @@ describe('configToJSONSchema', () => {
],
}
const sanitizedConfig = sanitizeConfig(config)
const sanitizedConfig = await sanitizeConfig(config)
const schema = configToJSONSchema(sanitizedConfig, 'text')
expect(schema?.definitions?.test).toStrictEqual({

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