Compare commits

...

65 Commits

Author SHA1 Message Date
NikolaGanchev
51108c02ea feat: Add Bulgarian translation (#2753) 2023-06-01 18:43:17 -04:00
Alessio Gravili
69b97bbc59 fix: mongoose connection (#2754) 2023-06-01 12:03:47 -04:00
Dan Ribbens
f2399bc05a chore: fix bad merge 2023-05-31 15:56:57 -04:00
Dan Ribbens
93a85dd937 chore: allow custom mongourl during test (#2743)
Co-authored-by: swenzel <swen.wenzel@thearc.de>
Co-authored-by: PatrikKozak <patrik@trbl.design>
2023-05-31 15:51:49 -04:00
Jessica Chowdhury
8ee9724277 fix: fix locale popup overflow (#2737)
* fix: fix locale popup overflow

* chore: refines locale selector css

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2023-05-30 15:49:30 -04:00
PatrikKozak
7c446ec71a Merge pull request #2636 from AlessioGr/test-console-log-admin-url
fix: console log correct admin URL for tests
2023-05-30 15:07:28 -04:00
Jessica Chowdhury
f2451d03c1 chore: formats filesize to KB in upload collection list view (#2734) 2023-05-30 14:45:12 -04:00
Jarrod Flesch
0986282f13 fix: adds timestamps to global schemas (#2738) 2023-05-30 14:37:43 -04:00
Jessica Chowdhury
d3638bcb24 Merge pull request #2730 from StLyn4/typing-fixes
fix: typing of sendEmail function
2023-05-30 18:45:39 +01:00
Jessica Chowdhury
f386d1caad Merge pull request #2731 from payloadcms/fix/2729-code-editor-options
fix: adjusts code field joi schema to allow editorOptions
2023-05-30 18:30:37 +01:00
Jacob Fletcher
480c7b3e21 Merge pull request #2736 from payloadcms/fix/use-as-title
fix: searches on correct useAsTitle field in polymorphic list drawers
2023-05-30 13:11:03 -04:00
Jacob Fletcher
908d5747a8 chore: allows super-admins to view as tenant in multi-tenant example (#2719) 2023-05-30 13:10:14 -04:00
Jacob Fletcher
9ec2a40274 fix: searches on correct useAsTitle field in polymorphic list drawers #2710 2023-05-30 12:41:28 -04:00
James Mikrut
a080a6294c Merge pull request #2733 from payloadcms/chore/ui-field-doc
chore: updates ui field docs to show admin.components.Field is required
2023-05-30 12:05:05 -04:00
PatrikKozak
9be854a1a4 chore: updates ui field docs to show admin.components.Field is required 2023-05-30 12:01:33 -04:00
Jacob Fletcher
c76dc77e64 chore: writes e2e test for list drawer useAsTitle search 2023-05-30 11:28:26 -04:00
Elliot DeNolf
a42f17ca41 chore: use Discord vanity URL 2023-05-30 09:57:02 -04:00
Jarrod Flesch
ed136fbc51 fix: adjusts code field joi schema to allow editorOptions 2023-05-30 08:56:37 -04:00
Vsevolod Volkov
e3ff4c46cb fix: typing of sendMail function
Signed-off-by: Vsevolod Volkov <st.lyn4@gmail.com>
2023-05-30 14:31:19 +03:00
Jacob Fletcher
6125b66286 fix: removes payload dependency inception (#2717) 2023-05-26 16:24:10 -04:00
Jarrod Flesch
8285bac2f5 fix: corrects relationship field schema from pr #2696 (#2714) 2023-05-26 11:03:02 -04:00
James Mikrut
61bb0fae53 fix: username / email inconsistency when creating new users
Fixed UserExistsError error message
2023-05-25 20:47:05 -04:00
Jessica Chowdhury
47b9af970b Merge pull request #2665 from payloadcms/example/emails
example: email
2023-05-25 18:22:45 +01:00
Jarrod Flesch
731c85337b chore: stop tests from re-running when a PR body is edited (#2712) 2023-05-25 09:28:02 -04:00
James Mikrut
4b59fda56f Merge pull request #2708 from payloadcms/jmikrut-patch-1
Update overview.mdx
2023-05-24 15:16:53 -04:00
James Mikrut
2361221198 Update overview.mdx 2023-05-24 15:16:14 -04:00
Dan Ribbens
d931ba9b50 chore: update changelog 2023-05-24 13:12:53 -04:00
Dan Ribbens
51fd1db4eb chore(release): v1.8.3 2023-05-24 12:00:55 -04:00
Dan Ribbens
dbd4dd215a chore: yarn lock 2023-05-24 11:44:31 -04:00
Jarrod Flesch
c716954e89 fix: adds credentials to doc access request (#2705) 2023-05-24 10:39:03 -04:00
TomDo1234
5be247da0a Merge branch 'master' of https://github.com/TomDo1234/payload 2023-05-24 07:18:27 +10:00
TomDo1234
b47e84369c fixed UserExistsError message to say email instead of username 2023-05-24 07:12:58 +10:00
TomDo1234
fe7ddf3e0f Merge branch 'master' of https://github.com/TomDo1234/payload 2023-05-24 06:42:46 +10:00
Jacob Fletcher
2fc9288870 feat: builds multi-tenant example (#2689)
* feat: builds multi-tenant example

* chore: updates seed script logic
2023-05-23 16:40:18 -04:00
Jarrod Flesch
f9de807daa Fix: correct graphql param types (#2696)
* chore: colocates gql schema field types with operators
* chore: adds missing `json` gql field schema
* fix: corrects graphql `id` type from JSON to String
2023-05-23 15:27:35 -04:00
Jacob Fletcher
e85ce4eaf2 Merge pull request #2694 from payloadcms/fix/rel-drawer-save
fix: add new relationship drawer onSave handling
2023-05-23 15:24:22 -04:00
Jacob Fletcher
5fc36333b9 Merge pull request #2699 from payloadcms/fix/mobile-rel
fix: unable to clear relationships or open relationship drawer on mobile
2023-05-23 15:24:01 -04:00
Jacob Fletcher
2809cb910c chore: fixes broken test for externally updated relationships 2023-05-23 10:37:52 -04:00
Jacob Fletcher
782f8ca047 fix: unable to clear relationships or open relationship drawer on mobile #2691 #2692 2023-05-23 10:08:16 -04:00
Quentin Beauperin
8bdbd6b073 docs: fix global hooks intro anchor links (#2695) 2023-05-23 09:31:22 -04:00
James Mikrut
7fbd5adaa2 Merge pull request #2687 from payloadcms/chore/install-doc-mongo-uri-secret
chore: updates installation doc to use envs in server.ts example
2023-05-23 08:46:20 -04:00
James Mikrut
324ca171a3 Merge pull request #2693 from payloadcms/fix/2685-graphql-relations
Fix/2685 graphql relations
2023-05-23 08:37:53 -04:00
Jacob Fletcher
bbf114b822 chore: writes e2e test for relationships created using the document drawer 2023-05-22 23:59:09 -04:00
Jacob Fletcher
a2a8ac9549 fix: prevents add new relationship modal from adding duplicative values to the parent doc #2688 2023-05-22 18:01:23 -04:00
Jacob Fletcher
ae384306eb chore: threads operation through the default edit view onSave handler 2023-05-22 17:13:35 -04:00
James Mikrut
9c4e003315 Merge pull request #2690 from payloadcms/fix/#2662
fix: #2662, draft=true querying by id
2023-05-22 16:43:41 -04:00
James
9bb5470342 fix: #2685, graphql querying relationships with custom id 2023-05-22 16:40:24 -04:00
Elliot DeNolf
2f209e3e9b chore: recreate issue in test dir 2023-05-22 16:14:28 -04:00
James
314ddbd44c chore: tests 2023-05-22 15:50:54 -04:00
James
3b78ab04c7 fix: #2662, draft=true querying by id 2023-05-22 15:46:04 -04:00
Dan Ribbens
bb21f51f74 Merge branch 'fix/safely-validate-relationships' 2023-05-22 13:58:47 -04:00
Dan Ribbens
666c2383ba chore: lint fix 2023-05-22 13:58:25 -04:00
James
2703853edb fix: safely validates null relations 2023-05-22 13:46:11 -04:00
PatrikKozak
f728fca036 chore: updates installation doc to use envs in server.ts example 2023-05-22 11:29:41 -04:00
Dan Ribbens
368103d76d Merge branch 'master' of github.com:payloadcms/payload 2023-05-20 05:00:08 -04:00
Quentin Beauperin
3a2462baba docs: add missing admin.group property in configuration/globals (#2684) 2023-05-20 04:35:50 -04:00
Jessica Boezwinkle
bd2bfbbb93 docs: spacing fix on graphql docs 2023-05-19 16:59:23 +01:00
Jessica Boezwinkle
1300fc864c docs: additional params for find operation rest-api 2023-05-19 15:10:24 +01:00
Jessica Boezwinkle
ef2d17922b docs: adds rest-api examples for real this time 2023-05-19 14:49:06 +01:00
Dan Ribbens
b63dd40512 chore: release-it config update to include pre release on github 2023-05-18 12:00:49 -04:00
Jessica Boezwinkle
bc41f81303 example: adds email example 2023-05-16 11:07:33 +01:00
Alessio Gravili
2697974694 fix: fix tests by hard-coding the URL in the logger 2023-05-08 19:23:09 +02:00
Alessio Gravili
095ccf7194 chore: set serverURL for tests 2023-05-08 19:04:27 +02:00
TomDoFuture
5f620a2325 Upgraded the packages to latest minor versions where non breaking 2023-01-15 11:23:42 +11:00
TomDoFuture
01d1f43d45 Upgraded the packages to latest patch versions where non breaking 2023-01-15 11:20:10 +11:00
116 changed files with 20886 additions and 407 deletions

View File

@@ -2,7 +2,7 @@ name: build
on:
pull_request:
types: [opened, reopened, edited, synchronize]
types: [opened, reopened, synchronize]
push:
branches: ["master"]
@@ -45,7 +45,7 @@ jobs:
run: yarn dev:generate-types fields
- name: Generate GraphQL schema file
run: yarn dev:generate-graphql-schema
run: yarn dev:generate-graphql-schema graphql-schema-gen
- name: Install Playwright Browsers
run: npx playwright install --with-deps

View File

@@ -7,7 +7,7 @@
"tag": false
},
"github": {
"release": false
"release": true
},
"npm": {
"skipChecks": true,

View File

@@ -1,5 +1,16 @@
## [1.8.3](https://github.com/payloadcms/payload/compare/v1.8.3-canary.2...v1.8.3) (2023-05-24)
### Bug Fixes
* [#2662](https://github.com/payloadcms/payload/issues/2662), draft=true querying by id ([3b78ab0](https://github.com/payloadcms/payload/commit/3b78ab04c7a68e39afa9936ac692169ed2c8fb74))
* [#2685](https://github.com/payloadcms/payload/issues/2685), graphql querying relationships with custom id ([9bb5470](https://github.com/payloadcms/payload/commit/9bb54703423b3f0fdb242a5e63f322d346323b06))
* adds credentials to doc access request ([#2705](https://github.com/payloadcms/payload/issues/2705)) ([c716954](https://github.com/payloadcms/payload/commit/c716954e89b0aef976cbcbef9ece981ec9bab233))
* prevents add new relationship modal from adding duplicative values to the parent doc [#2688](https://github.com/payloadcms/payload/issues/2688) ([a2a8ac9](https://github.com/payloadcms/payload/commit/a2a8ac9549bd67e6ab578772689684fd2bc64872))
* unable to clear relationships or open relationship drawer on mobile [#2691](https://github.com/payloadcms/payload/issues/2691) [#2692](https://github.com/payloadcms/payload/issues/2692) ([782f8ca](https://github.com/payloadcms/payload/commit/782f8ca047178cadb4214702854a0e0cb2d9eaab))
## [1.8.2](https://github.com/payloadcms/payload/compare/v1.8.1...v1.8.2) (2023-05-10)
@@ -3184,4 +3195,4 @@ If none of your collections or globals should be publicly exposed, you don't nee
- add blind index for encrypting API Keys ([9a1c1f6](https://github.com/payloadcms/payload/commit/9a1c1f64c0ea0066b679195f50e6cb1ac4bf3552))
- add license key to access routej ([2565005](https://github.com/payloadcms/payload/commit/2565005cc099797a6e3b8995e0984c28b7837e82))
## [0.0.137](https://github.com/payloadcms/payload/commit/5c1e2846a2694a80cc8707703406c2ac1bb6af8a) (2020-11-12)
## [0.0.137](https://github.com/payloadcms/payload/commit/5c1e2846a2694a80cc8707703406c2ac1bb6af8a) (2020-11-12)

View File

@@ -32,7 +32,7 @@
<img src="https://img.shields.io/github/commit-activity/m/payloadcms/payload?style=flat-square" alt="git commit activity"/>
</a>
&nbsp;
<a href="https://discord.com/invite/r6sCXqVk3v">
<a href="https://discord.gg/payload">
<img alt="Discord" src="https://img.shields.io/discord/967097582721572934?label=Discord&color=7289da&style=flat-square" />
</a>
&nbsp;

View File

@@ -49,6 +49,12 @@ The directory split up in this way specifically to reduce friction when creating
The following command will start Payload with your config: `yarn dev my-test-dir`. This command will start up Payload using your config and refresh a test database on every restart.
If you wish to use to your own Mongo database for the `test` directory instead of using the in memory database, all you need to do is add the following env vars to the `test/dev.ts` file:
- `process.env.NODE_ENV`
- `process.env.PAYLOAD_TEST_MONGO_URL`
- Simply set `process.env.NODE_ENV` to `test` and set `process.env.PAYLOAD_TEST_MONGO_URL` to your mongo url e.g. `mongodb://127.0.0.1/your-test-db`.
NOTE: It is recommended to add the test credentials (located in `test/credentials.ts`) to your autofill for `localhost:3000/admin` as this will be required on every nodemon restart. The default credentials are `dev@payloadcms.com` as E-Mail and `test` as password.
## Pull Requests

View File

@@ -38,7 +38,7 @@ const defaultPayloadAccess = ({ req: { user } }) => {
<Banner type="success">
<strong>Note:</strong><br/>
In the Local API, all Access Control functions are skipped by default, allowing your server to do whatever it needs. But, you can opt back in by setting the option <strong>overrideAccess</strong> to <strong>true</strong>.
In the Local API, all Access Control functions are skipped by default, allowing your server to do whatever it needs. But, you can opt back in by setting the option <strong>overrideAccess</strong> to <strong>false</strong>.
</Banner>
### Access Control Types

View File

@@ -67,7 +67,7 @@ You can customize the way that the Admin panel behaves on a collection-by-collec
| Option | Description |
|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `group` | Text used as a label for grouping collection links together in the navigation. |
| `group` | Text used as a label for grouping collection and global links together in the navigation. |
| `hidden` | Set to true or a function, called with the current user, returning true to exclude this collection from navigation and admin routing. |
| `hooks` | Admin-specific hooks for this collection. [More](#admin-hooks) |
| `useAsTitle` | Specify a top-level field to use for a document title throughout the Admin panel. If no field is defined, the ID of the document is used as the title. |

View File

@@ -67,6 +67,7 @@ You can customize the way that the Admin panel behaves on a Global-by-Global bas
| Option | Description |
|--------------|-----------------------------------------------------------------------------------------------------------------------------------|
| `group` | Text used as a label for grouping collection and global links together in the navigation. |
| `hidden` | Set to true or a function, called with the current user, returning true to exclude this global from navigation and admin routing. |
| `components` | Swap in your own React components to be used within this Global. [More](/docs/admin/components#globals) |
| `preview` | Function to generate a preview URL within the Admin panel for this global that can point to your app. [More](#preview). |

View File

@@ -26,13 +26,13 @@ With this field, you can also inject custom `Cell` components that appear as add
### Config
| Option | Description |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| **`name`** \* | A unique identifier for this field. |
| **`label`** | Human-readable label for this UI field. |
| **`admin.components.Field`** | React component to be rendered for this field within the Edit view. [More](/docs/admin/components/#field-component) |
| **`admin.components.Cell`** | React component to be rendered as a Cell within collection List views. [More](/docs/admin/components/#field-component) |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
| Option | Description |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| **`name`** \* | A unique identifier for this field. |
| **`label`** | Human-readable label for this UI field. |
| **`admin.components.Field`** \* | React component to be rendered for this field within the Edit view. [More](/docs/admin/components/#field-component) |
| **`admin.components.Cell`** | React component to be rendered as a Cell within collection List views. [More](/docs/admin/components/#field-component) |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
_\* An asterisk denotes that a property is required._

View File

@@ -77,12 +77,13 @@ To initialize Payload, update your `server.ts` file to reflect the following cod
import express from "express";
import payload from "payload";
require("dotenv").config();
const app = express();
const start = async () => {
await payload.init({
secret: "SECRET_KEY",
mongoURL: "mongodb://localhost/payload",
secret: process.env.PAYLOAD_SECRET,
mongoURL: process.env.MONGODB_URI,
express: app,
});
@@ -96,6 +97,13 @@ const start = async () => {
start();
```
A quick reminder: in this configuration, we're making use of two environmental variables, `process.env.PAYLOAD_SECRET` and `process.env.MONGODB_URI`. Often, it's smart to store these values in an `.env` file at the root of your directory and set different values for each of your environments (local, stage, prod, etc). The `dotenv` package is very handy and works well alongside of Payload. A typical `.env` file will look like this:
```
MONGODB_URI=mongodb://127.0.0.1/your-payload-app
PAYLOAD_SECRET=your-payload-secret
```
Here is a list of all properties available to pass through `payload.init`:
##### `express`
@@ -104,7 +112,7 @@ Here is a list of all properties available to pass through `payload.init`:
##### `secret`
**Required**. This is a secure string that will be used to authenticate with Payload. It can be random but should be at least 14 characters and be very difficult to guess. Often, it's smart to store this value in an `env` and set different values for each of your environments (local, stage, prod, etc). The `dotenv` package is very handy and works well alongside of Payload.
**Required**. This is a secure string that will be used to authenticate with Payload. It can be random but should be at least 14 characters and be very difficult to guess.
Payload uses this secret key to generate secure user tokens (JWT). Behind the scenes, we do not use your secret key to encrypt directly - instead, we first take the secret key and create an encrypted string using the SHA-256 hash function. Then, we reduce the encrypted string to its first 32 characters. This final value is what Payload uses for encryption.
@@ -112,7 +120,7 @@ Payload uses this secret key to generate secure user tokens (JWT). Behind the sc
**Required**. This is a fully qualified MongoDB connection string that points to your MongoDB database. If you don't have MongoDB installed locally, you can [follow these steps for Mac OSX](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-os-x/) and [these steps](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-windows/) for Windows 10. If you want to use a local database and you know you have MongoDB installed locally, a typical connection string will look like this:
`mongodb://localhost/payload`
`mongodb://127.0.0.1/payload`
In contrast to running MongoDB locally, a popular option is to sign up for a free [MongoDB Atlas account](https://www.mongodb.com/cloud/atlas), which is a fully hosted and cloud-based installation of MongoDB that you don't need to ever worry about.

View File

@@ -80,8 +80,9 @@ Example
async (obj, args, context, info) => { }
```
**`obj`** The previous object. Not very often used and usually discarded.
**`obj`**
The previous object. Not very often used and usually discarded.
**`args`**

View File

@@ -8,11 +8,11 @@ keywords: hooks, globals, config, configuration, documentation, Content Manageme
Globals feature the ability to define the following hooks:
- [beforeValidate](#beforeValidate)
- [beforeChange](#beforeChange)
- [afterChange](#afterChange)
- [beforeRead](#beforeRead)
- [afterRead](#afterRead)
- [beforeValidate](#beforevalidate)
- [beforeChange](#beforechange)
- [afterChange](#afterchange)
- [beforeRead](#beforeread)
- [afterRead](#afterread)
## Config

View File

@@ -7,7 +7,8 @@ keywords: rest, api, documentation, Content Management System, cms, headless, ja
---
<Banner>
A fully functional REST API is automatically generated from your Collection and Global configs.
A fully functional REST API is automatically generated from your Collection
and Global configs.
</Banner>
All Payload API routes are mounted prefixed to your config's `routes.api` URL segment (default: `/api`).
@@ -26,58 +27,518 @@ Note: Collection slugs must be formatted in kebab-case
**All CRUD operations are exposed as follows:**
| Method | Path | Description |
|----------|-------------------------------|--------------------------------------------------|
| `GET` | `/api/{collection-slug}` | Find paginated documents |
| `GET` | `/api/{collection-slug}/:id` | Find a specific document by ID |
| `POST` | `/api/{collection-slug}` | Create a new document |
| `PATCH` | `/api/{collection-slug}` | Update all documents matching the `where` query |
| `PATCH` | `/api/{collection-slug}/:id` | Update a document by ID |
| `DELETE` | `/api/{collection-slug}` | Delete all documents matching the `where` query |
| `DELETE` | `/api/{collection-slug}/:id` | Delete an existing document by ID |
<RestExamples
data={[
{
operation: "Find",
method: "GET",
path: "/api/{collection-slug}",
description: "Find paginated documents",
example: {
slug: "getCollection",
req: true,
res: {
paginated: true,
data: {
id: "644a5c24cc1383022535fc7c",
title: "Home",
content: "REST API examples",
slug: "home",
createdAt: "2023-04-27T11:27:32.419Z",
updatedAt: "2023-04-27T11:27:32.419Z",
},
},
drawerContent: (
<>
<h6>Additional <code>find</code> query parameters</h6>
The <code>find</code> endpoint supports the following additional query parameters:
<ul>
<li>
<a href="/docs/queries/overview#sort">sort</a> - sort by field
</li>
<li>
<a href="/docs/queries/overview">where</a> - pass a where query to constrain returned
documents
</li>
<li>
<a href="/docs/queries/pagination#pagination-controls">limit</a> - limit the returned
documents to a certain number
</li>
<li>
<a href="/docs/queries/pagination#pagination-controls">page</a> - get a specific page of
documents
</li>
</ul>
</>
),
},
},
{
operation: "Find By ID",
method: "GET",
path: "/api/{collection-slug}/{id}",
description: "Find a specific document by ID",
example: {
slug: "findByID",
req: true,
res: {
id: "644a5c24cc1383022535fc7c",
title: "Home",
content: "REST API examples",
slug: "home",
createdAt: "2023-04-27T11:27:32.419Z",
updatedAt: "2023-04-27T11:27:32.419Z",
},
},
},
{
operation: "Create",
method: "POST",
path: "/api/{collection-slug}",
description: "Create a new document",
example: {
slug: "createDocument",
req: {
headers: true,
body: {
title: "New page",
content: "Here is some content",
},
},
res: {
message: "Page successfully created.",
doc: {
id: "644ba34c86359864f9535932",
title: "New page",
content: "Here is some content",
slug: "new-page",
createdAt: "2023-04-28T10:43:24.466Z",
updatedAt: "2023-04-28T10:43:24.466Z",
},
},
},
},
{
operation: "Update",
method: "PATCH",
path: "/api/{collection-slug}",
description: "Update all documents matching the where query",
example: {
slug: "updateDocument",
req: {
query: true,
headers: true,
body: {
title: "I have been updated!",
},
},
res: {
docs: [
{
id: "644ba34c86359864f9535932",
title: "I have been updated!",
content: "Here is some content",
slug: "new-page",
createdAt: "2023-04-28T10:43:24.466Z",
updatedAt: "2023-04-28T10:45:23.724Z",
},
],
errors: [],
},
},
},
{
operation: "Update By ID",
method: "PATCH",
path: "/api/{collection-slug}/{id}",
description: "Update a document by ID",
example: {
slug: "updateDocumentByID",
req: {
headers: true,
body: {
title: "I have been updated by ID!",
categories: "example-uuid",
tags: {
relationTo: "location",
value: "another-example-uuid",
},
},
},
res: {
message: "Updated successfully.",
doc: {
id: "644a5c24cc1383022535fc7c",
title: "I have been updated by ID!",
content: "REST API examples",
categories: {
id: "example-uuid",
name: "Test Category",
},
tags: [
{
relationTo: "location",
value: {
id: "another-example-uuid",
name: "Test Location",
},
},
],
slug: "home",
createdAt: "2023-04-27T11:27:32.419Z",
updatedAt: "2023-04-28T10:47:59.259Z",
},
},
},
},
{
operation: "Delete",
method: "DELETE",
path: "/api/{collection-slug}",
description: "Delete all documents matching the where query",
example: {
slug: "deleteDocuments",
req: {
query: true,
headers: true,
},
res: {
docs: [
{
id: "644ba4cf86359864f953594b",
title: "New page",
content: "Here is some content",
slug: "new-page",
createdAt: "2023-04-28T10:49:51.359Z",
updatedAt: "2023-04-28T10:49:51.359Z",
},
],
errors: [],
},
},
},
{
operation: "Delete by ID",
method: "DELETE",
path: "/api/{collection-slug}/{id}",
description: "Delete an existing document by ID",
example: {
slug: "deleteByID",
req: {
headers: true,
},
res: {
id: "644ba51786359864f9535954",
title: "New page",
content: "Here is some content",
slug: "new-page",
createdAt: "2023-04-28T10:51:03.028Z",
updatedAt: "2023-04-28T10:51:03.028Z",
},
},
},
##### Additional `find` query parameters
The `find` endpoint supports the following additional query parameters:
- [sort](/docs/queries/overview#sort) - sort by field
- [where](/docs/queries/overview) - pass a `where` query to constrain returned documents
- [limit](/docs/queries/pagination#pagination-controls) - limit the returned documents to a certain number
- [page](/docs/queries/pagination#pagination-controls) - get a specific page of documents
]}
/>
## Auth Operations
Auth enabled collections are also given the following endpoints:
| Method | Path | Description |
| -------- | --------------------------- | ----------- |
| `POST` | `/api/{collection-slug}/verify/:token` | [Email verification](/docs/authentication/operations#verify-by-email), if enabled. |
| `POST` | `/api/{collection-slug}/unlock` | [Unlock a user's account](/docs/authentication/operations#unlock), if enabled. |
| `POST` | `/api/{collection-slug}/login` | [Logs in](/docs/authentication/operations#login) a user with email / password. |
| `POST` | `/api/{collection-slug}/logout` | [Logs out](/docs/authentication/operations#logout) a user. |
| `POST` | `/api/{collection-slug}/refresh-token` | [Refreshes a token](/docs/authentication/operations#refresh) that has not yet expired. |
| `GET` | `/api/{collection-slug}/me` | [Returns the currently logged in user with token](/docs/authentication/operations#me). |
| `POST` | `/api/{collection-slug}/forgot-password` | [Password reset workflow](/docs/authentication/operations#forgot-password) entry point. |
| `POST` | `/api/{collection-slug}/reset-password` | [To reset the user's password](/docs/authentication/operations#reset-password). |
<RestExamples
data={[
{
operation: "Login",
method: "POST",
path: "/api/{user-collection}/login",
description: "Logs in a user with email / password",
example: {
slug: "login",
req: {
headers: true,
body: {
email: "dev@payloadcms.com",
password: "password",
},
},
res: {
message: "Auth Passed",
user: {
id: "644b8453cd20c7857da5a9b0",
email: "dev@payloadcms.com",
_verified: true,
createdAt: "2023-04-28T08:31:15.788Z",
updatedAt: "2023-04-28T11:11:03.716Z",
},
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
exp: 1682689147,
},
},
},
{
operation: "Logout",
method: "POST",
path: "/api/{user-collection}/logout",
description: "Logs out a user",
example: {
slug: "logout",
req: {
headers: true,
},
res: {
message: "You have been logged out successfully.",
},
},
},
{
operation: "Unlock",
method: "POST",
path: "/api/{user-collection}/unlock",
description: "Unlock a user account",
example: {
slug: "unlockCollection",
req: {
headers: true,
body: {
email: "dev@payloadcms.com",
},
},
res: {
message: "Success",
},
},
},
{
operation: "Refresh",
method: "POST",
path: "/api/{user-collection}/refresh-token",
description: "Refreshes a token that has not yet expired",
example: {
slug: "refreshToken",
req: {
headers: true,
},
res: {
message: "Token refresh successful",
refreshedToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
exp: 1682689362,
user: {
email: "dev@payloadcms.com",
id: "644b8453cd20c7857da5a9b0",
collection: "users",
},
},
},
},
{
operation: "Verify User",
method: "POST",
path: "/api/{user-collection}/verify/{token}",
description: "User verification",
example: {
slug: "verifyUser",
req: {
prop: "token: string, user-collection: string",
headers: true,
},
res: {
message: "Email verified successfully.",
},
},
},
{
operation: "Current User",
method: "GET",
path: "/api/{user-collection}/me",
description: "Returns the currently logged in user with token",
example: {
slug: "currentUser",
req: {
headers: true,
},
res: {
user: {
id: "644b8453cd20c7857da5a9b0",
email: "dev@payloadcms.com",
_verified: true,
createdAt: "2023-04-28T08:31:15.788Z",
updatedAt: "2023-04-28T11:45:23.926Z",
_strategy: "local-jwt",
},
collection: "users",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
exp: 1682689523,
},
},
},
{
operation: "Forgot Password",
method: "POST",
path: "/api/{user-collection}/forgot-password",
description: "Password reset workflow entry point",
example: {
slug: "forgotPassword",
req: {
headers: true,
body: {
email: "dev@payloadcms.com",
},
},
res: {
message: "Success",
},
},
},
{
operation: "Reset Password",
method: "POST",
path: "/api/{user-collection}/reset-password",
description: "Reset user password",
example: {
slug: "resetPassword",
req: {
headers: true,
body: {
token: "7eac3830ffcfc7f9f66c00315dabeb11575dba91",
password: "newPassword",
},
},
res: {
message: "Password reset successfully.",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
user: {
id: "644baa473ea9538765cc30fc",
email: "dev@payloadcms.com",
_verified: true,
createdAt: "2023-04-28T11:13:11.569Z",
updatedAt: "2023-04-28T11:49:23.860Z",
},
},
},
},
]}
/>
## Globals
Globals cannot be created or deleted, so there are only two REST endpoints opened:
| Method | Path | Description |
| -------- | --------------------------- | ----------------------- |
| `GET` | `/api/globals/{globalSlug}` | Get a global by slug |
| `POST` | `/api/globals/{globalSlug}` | Update a global by slug |
<RestExamples
data={[
{
operation: "Get Global",
method: "GET",
path: "/api/globals/{global-slug}",
description: "Get a global by slug",
example: {
slug: "getGlobal",
req: {
headers: true,
},
res: {
announcement: "Here is an announcement!",
globalType: "announcement",
createdAt: "2023-04-28T08:53:56.066Z",
updatedAt: "2023-04-28T08:53:56.066Z",
id: "644b89a496c64a833fe579c9",
},
},
},
{
operation: "Update Global",
method: "POST",
path: "/api/globals/{global-slug}",
description: "Update a global by slug",
example: {
slug: "updateGlobal",
req: {
headers: true,
body: {
announcement: "Paging Doctor Scrunt",
},
},
res: {
announcement: "Paging Doctor Scrunt",
globalType: "announcement",
createdAt: "2023-04-28T08:53:56.066Z",
updatedAt: "2023-04-28T08:53:56.066Z",
id: "644b89a496c64a833fe579c9",
},
},
},
]}
/>
## Preferences
In addition to the dynamically generated endpoints above Payload also has REST endpoints to manage the admin user [preferences](/docs/admin/overview#preferences) for data specific to the authenticated user.
| Method | Path | Description |
| -------- | --------------------------- | ----------------------- |
| `GET` | `/api/_preferences/{key}` | Get a preference by key |
| `POST` | `/api/_preferences/{key}` | Create or update by key |
| `DELETE` | `/api/_preferences/{key}` | Delete a user preference by key |
<RestExamples
data={[
{
operation: "Get Preference",
method: "GET",
path: "/api/_preferences/{key}",
description: "Get a preference by key",
example: {
slug: "getPreference",
req: {
headers: true,
},
res: {
_id: "644bb7a8307b3d363c6edf2c",
key: "region",
user: "644b8453cd20c7857da5a9b0",
userCollection: "users",
__v: 0,
createdAt: "2023-04-28T12:10:16.689Z",
updatedAt: "2023-04-28T12:10:16.689Z",
value: "Europe/London",
},
},
},
{
operation: "Create Preference",
method: "POST",
path: "/api/_preferences/{key}",
description: "Create or update a preference by key",
example: {
slug: "createPreference",
req: {
headers: true,
body: {
value: "Europe/London",
},
},
res: {
message: "Updated successfully.",
doc: {
user: "644b8453cd20c7857da5a9b0",
key: "region",
userCollection: "users",
value: "Europe/London",
},
},
},
},
{
operation: "Delete Preference",
method: "DELETE",
path: "/api/_preferences/{key}",
description: "Delete a preference by key",
example: {
slug: "deletePreference",
req: {
headers: true,
},
res: {
message: "deletedSuccessfully",
},
},
},
]}
/>
## Custom Endpoints
@@ -85,43 +546,48 @@ Additional REST API endpoints can be added to your application by providing an a
Each endpoint object needs to have:
| Property | Description |
| ---------- | ----------------------------------------- |
| **`path`** | A string for the endpoint route after the collection or globals slug |
| **`method`** | The lowercase HTTP verb to use: 'get', 'head', 'post', 'put', 'delete', 'connect' or 'options' |
| **`handler`** | A function or array of functions to be called with **req**, **res** and **next** arguments. [Express](https://expressjs.com/en/guide/routing.html#route-handlers) |
| **`root`** | When `true`, defines the endpoint on the root Express app, bypassing Payload handlers and the `routes.api` subpath. Note: this only applies to top-level endpoints of your Payload config, endpoints defined on `collections` or `globals` cannot be root. |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
| Property | Description |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`path`** | A string for the endpoint route after the collection or globals slug |
| **`method`** | The lowercase HTTP verb to use: 'get', 'head', 'post', 'put', 'delete', 'connect' or 'options' |
| **`handler`** | A function or array of functions to be called with **req**, **res** and **next** arguments. [Express](https://expressjs.com/en/guide/routing.html#route-handlers) |
| **`root`** | When `true`, defines the endpoint on the root Express app, bypassing Payload handlers and the `routes.api` subpath. Note: this only applies to top-level endpoints of your Payload config, endpoints defined on `collections` or `globals` cannot be root. |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
Example:
```ts
import { CollectionConfig } from 'payload/types';
import { CollectionConfig } from "payload/types";
// a collection of 'orders' with an additional route for tracking details, reachable at /api/orders/:id/tracking
export const Orders: CollectionConfig = {
slug: 'orders',
fields: [ /* ... */ ],
slug: "orders",
fields: [
/* ... */
],
// highlight-start
endpoints: [
{
path: '/:id/tracking',
method: 'get',
path: "/:id/tracking",
method: "get",
handler: async (req, res, next) => {
const tracking = await getTrackingInfo(req.params.id);
if (tracking) {
res.status(200).send({ tracking });
} else {
res.status(404).send({ error: 'not found' });
res.status(404).send({ error: "not found" });
}
}
}
},
},
],
// highlight-end
}
};
```
<Banner>
<strong>Note:</strong><br/>
**req** will have the **payload** object and can be used inside your endpoint handlers for making calls like req.payload.find() that will make use of access control and hooks.
<strong>Note:</strong>
<br />
**req** will have the **payload** object and can be used inside your endpoint
handlers for making calls like req.payload.find() that will make use of access
control and hooks.
</Banner>

View File

@@ -0,0 +1,4 @@
MONGODB_URI=mongodb://localhost/payload-example-email
PAYLOAD_SECRET=
NODE_ENV=development
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:8000

View File

@@ -0,0 +1,7 @@
module.exports = {
root: true,
extends: ['@payloadcms'],
rules: {
'@typescript-eslint/no-unused-vars': 'warn',
},
}

5
examples/email/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
build
dist
node_modules
package-lock.json
.env

63
examples/email/README.md Normal file
View File

@@ -0,0 +1,63 @@
# Payload Email Example
This example demonstrates how to integrate email functionality into Payload.
## Quick Start
To spin up this example locally, follow these steps:
1. Clone this repo
2. `cd` into this directory and run `yarn` or `npm install`
3. `cp .env.example .env` to copy the example environment variables
4. `yarn dev` or `npm run dev` to start the server and seed the database
5. `open http://localhost:8000/admin` to access the admin panel
6. Create your first user
## How it works
Payload utilizes [NodeMailer](https://nodemailer.com/about/) for email functionality. Once you add your email configuration to `payload.init()`, you send email from anywhere in your application just by calling `payload.sendEmail({})`.
1. Navigate to `src/server.ts` - this is where your email config gets passed to Payload
2. Open `src/email/transport.ts` - here we are defining the email config. You can use an env variable to switch between the mock email transport and live email service.
Now we can start sending email!
3. Go to `src/collections/Newsletter.ts` - with an `afterChange` hook, we are sending an email when a new user signs up for the newsletter
Let's not forget our authentication emails...
4. Auth-enabled collections have built-in options to verify the user and reset the user password. Open `src/collections/Users.ts` and see how we customize these emails.
Speaking of customization...
5. Take a look at `src/email/generateEmailHTML` and how it compiles a custom template when sending email. You change this to any HTML template of your choosing.
That's all you need, now you can go ahead and test out this repo by creating a new `user` or `newsletter-signup` and see the email integration in action.
## Development
To spin up this example locally, follow the [Quick Start](#quick-start).
## 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.
## Resources
For more information on integrating email, check out these resources:
<!-- Update with live blog post URL when published -->
- [Blog Post - Email 101](https://payloadcms.com/blog)
- [Email Documentation](https://payloadcms.com/docs/email/overview#email-functionality)
## Questions
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/r6sCXqVk3v) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).

View File

@@ -0,0 +1,4 @@
{
"ext": "ts",
"exec": "ts-node src/server.ts"
}

View File

@@ -0,0 +1,35 @@
{
"name": "payload-example-email",
"description": "Payload Email integration example.",
"version": "1.0.0",
"main": "dist/server.js",
"license": "MIT",
"scripts": {
"dev": "cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
"build:server": "tsc",
"build": "yarn copyfiles && yarn build:payload && yarn build:server",
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
"generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema",
"lint": "eslint src",
"lint:fix": "eslint --fix --ext .ts,.tsx src"
},
"dependencies": {
"dotenv": "^8.2.0",
"express": "^4.17.1",
"payload": "1.6.29",
"handlebars": "^4.7.7",
"inline-css": "^4.0.2"
},
"devDependencies": {
"@types/express": "^4.17.9",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"eslint": "^8.19.0",
"nodemon": "^2.0.6",
"ts-node": "^9.1.1",
"typescript": "^4.8.4"
}
}

View File

@@ -0,0 +1,39 @@
import type { CollectionConfig } from 'payload/types'
import generateEmailHTML from '../email/generateEmailHTML'
const Newsletter: CollectionConfig = {
slug: 'newsletter-signups',
admin: {
defaultColumns: ['name', 'email'],
},
hooks: {
afterChange: [
async ({ doc, operation, req }) => {
if (operation === 'create') {
req.payload.sendEmail({
to: doc.email,
from: 'sender@example.com',
subject: 'Thanks for signing up!',
html: await generateEmailHTML({
headline: 'Welcome to the newsletter!',
content: `<p>${doc.name ? `Hi ${doc.name}!` : 'Hi!'} We'll be in touch soon...</p>`,
}),
})
}
},
],
},
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'email',
type: 'text',
required: true,
}
],
}
export default Newsletter

View File

@@ -0,0 +1,29 @@
import type { CollectionConfig } from 'payload/types'
import generateForgotPasswordEmail from '../email/generateForgotPasswordEmail'
import generateVerificationEmail from '../email/generateVerificationEmail'
const Users: CollectionConfig = {
slug: 'users',
auth: {
verify: {
generateEmailSubject: () => 'Verify your email',
generateEmailHTML: generateVerificationEmail,
},
forgotPassword: {
generateEmailSubject: () => 'Reset your password',
generateEmailHTML: generateForgotPasswordEmail,
},
},
admin: {
useAsTitle: 'email',
},
fields: [
{
name: 'name',
type: 'text',
},
],
}
export default Users

View File

@@ -0,0 +1,24 @@
import fs from 'fs'
import Handlebars from 'handlebars'
import inlineCSS from 'inline-css'
import path from 'path'
const template = fs.readFileSync
? fs.readFileSync(path.join(__dirname, './template.html'), 'utf8')
: ''
// Compile the template
const getHTML = Handlebars.compile(template)
const generateEmailHTML = async (data): Promise<string> => {
const preInlinedCSS = getHTML(data)
const html = await inlineCSS(preInlinedCSS, {
url: ' ',
removeStyleTags: false,
})
return html
}
export default generateEmailHTML

View File

@@ -0,0 +1,14 @@
import generateEmailHTML from './generateEmailHTML'
const generateForgotPasswordEmail = async ({ token }): Promise<string> =>
generateEmailHTML({
headline: 'Locked out?',
content:
'<p>Let&apos;s get you back in.</p>',
cta: {
buttonLabel: 'Reset your password',
url: `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/reset-password?token=${token}`,
},
})
export default generateForgotPasswordEmail

View File

@@ -0,0 +1,16 @@
import generateEmailHTML from './generateEmailHTML'
const generateVerificationEmail = async (args): Promise<string> => {
const { user, token } = args
return generateEmailHTML({
headline: 'Verify your account',
content: `<p>Hi${user.name ? ' ' + user.name : ''}! Validate your account by clicking the button below.</p>`,
cta: {
buttonLabel: 'Verify',
url: `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/verify?token=${token}&email=${user.email}`,
},
})
}
export default generateVerificationEmail

View File

@@ -0,0 +1,317 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style type="text/css">
body,
html {
margin: 0;
padding: 0;
}
body,
html,
.bg {
height: 100%;
}
body,
h1,
h2,
h3,
h4,
p,
em,
strong {
font-family: sans-serif;
}
body {
font-size: 15px;
color: #333333;
}
a {
color: #333333;
outline: 0;
text-decoration: underline;
}
a img {
border: 0;
outline: 0;
}
img {
max-width: 100%;
height: auto;
vertical-align: top;
}
h1,
h2,
h3,
h4,
h5 {
font-weight: 900;
line-height: 1.25;
}
h1 {
font-size: 40px;
color: #333333;
margin: 0 0 25px 0;
}
h2 {
color: #333333;
margin: 0 0 25px 0;
font-size: 30px;
line-height: 30px;
}
h3 {
font-size: 25px;
color: #333333;
margin: 0 0 25px 0;
}
h4 {
font-size: 20px;
color: #333333;
margin: 0 0 15px 0;
line-height: 30px;
}
h5 {
color: #333333;
font-size: 17px;
font-weight: 900;
margin: 0 0 15px;
}
table {
border-collapse: collapse;
}
p,
td {
font-size: 14px;
line-height: 25px;
color: #333333;
}
p {
margin: 0 0 25px;
}
ul {
padding-left: 15px;
margin-left: 15px;
font-size: 14px;
line-height: 25px;
margin-bottom: 25px;
}
li {
font-size: 14px;
line-height: 25px;
color: #333333;
}
table.hr td {
font-size: 0;
line-height: 2px;
}
.white {
color: white;
}
/********************************
MAIN
********************************/
.main {
background: white;
}
/********************************
MAX WIDTHS
********************************/
.max-width {
max-width: 800px;
width: 94%;
margin: 0 3%;
}
/********************************
REUSABLES
********************************/
.padding {
padding: 60px;
}
.center {
text-align: center;
}
.no-border {
border: 0;
outline: none;
text-decoration: none;
}
.no-margin {
margin: 0;
}
.spacer {
line-height: 45px;
height: 45px;
}
/********************************
PANELS
********************************/
.panel {
width: 100%;
}
@media screen and (max-width : 800px) {
h1 {
font-size: 24px !important;
margin: 0 0 20px 0 !important;
}
h2 {
font-size: 20px !important;
margin: 0 0 20px 0 !important;
}
h3 {
font-size: 20px !important;
margin: 0 0 20px 0 !important;
}
h4 {
font-size: 18px !important;
margin: 0 0 15px 0 !important;
}
h5 {
font-size: 15px !important;
margin: 0 0 10px !important;
}
.max-width {
width: 90% !important;
margin: 0 5% !important;
}
td.padding {
padding: 30px !important;
}
td.padding-vert {
padding-top: 20px !important;
padding-bottom: 20px !important;
}
td.padding-horiz {
padding-left: 20px !important;
padding-right: 20px !important;
}
.spacer {
line-height: 20px !important;
height: 20px !important;
}
}
</style>
</head>
<body>
<div style="background-color:#F3F3F3; height: 100%;">
<table height="100%" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f3f3f3"
style="background-color: #f3f3f3;">
<tr>
<td valign="top" align="left">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td align="center" valign="top">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td align="center">
<table class="max-width" cellpadding="0" cellspacing="0" border="0" width="100%"
style="width: 100%;">
<tbody>
<tr>
<td class="spacer">&nbsp;</td>
</tr>
<tr>
<td class="padding main">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td>
<!-- LOGO -->
<a href="https://payloadcms.com/" target="_blank">
<img src="https://payloadcms.com/images/logo-dark.png" width="150"
height="auto" />
</a>
</td>
</tr>
<tr>
<td class="spacer">&nbsp;</td>
</tr>
<tr>
<td>
<!-- HEADLINE -->
<h1 style="margin: 0 0 30px;">{{headline}}</h1>
</td>
</tr>
<tr>
<td>
<!-- CONTENT -->
{{{content}}}
<!-- CTA -->
{{#if cta}}
<div>
<a href="{{cta.url}}"
style="background-color:#222222;border-radius:4px;color:#ffffff;display:inline-block;font-family:sans-serif;font-size:13px;font-weight:bold;line-height:60px;text-align:center;text-decoration:none;width:200px;-webkit-text-size-adjust:none;">
{{cta.buttonLabel}}
</a>
</div>
{{/if}}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,20 @@
let email
if (process.env.NODE_ENV === 'production') {
email = {
fromName: 'Payload',
fromAddress: 'info@payloadcms.com',
transportOptions: {
// Configure a custom transport here
},
}
} else {
email = {
fromName: 'Ethereal Email',
fromAddress: 'example@ethereal.com',
logMockCredentials: true,
}
}
export default email

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -0,0 +1,35 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
'newsletter-signups': NewsletterSignup;
users: User;
};
globals: {};
}
export interface NewsletterSignup {
id: string;
name?: string;
email: string;
createdAt: string;
updatedAt: string;
}
export interface User {
id: string;
name?: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
_verified?: boolean;
_verificationToken?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
password?: string;
}

View File

@@ -0,0 +1,46 @@
import dotenv from 'dotenv'
import path from 'path'
import { buildConfig } from 'payload/config'
import Users from './collections/Users'
import Newsletter from './collections/Newsletter'
dotenv.config({
path: path.resolve(__dirname, '../.env'),
})
const mockModulePath = path.resolve(__dirname, './emptyModule.js')
export default buildConfig({
admin: {
webpack: config => ({
...config,
resolve: {
...config?.resolve,
alias: [
'fs',
'handlebars',
'inline-css',
path.resolve(__dirname, './email/transport'),
path.resolve(__dirname, './email/generateEmailHTML'),
path.resolve(__dirname, './email/generateForgotPasswordEmail'),
path.resolve(__dirname, './email/generateVerificationEmail'),
].reduce(
(aliases, importPath) => ({
...aliases,
[importPath]: mockModulePath,
}),
config.resolve.alias,
),
},
}),
},
collections: [
Newsletter,
Users,
],
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,30 @@
import express from 'express'
import path from 'path'
import payload from 'payload'
import email from './email/transport'
require('dotenv').config({
path: path.resolve(__dirname, '../.env'),
})
const app = express()
app.get('/', (_, res) => {
res.redirect('/admin')
})
const start = async (): Promise<void> => {
await payload.init({
secret: process.env.PAYLOAD_SECRET,
mongoURL: process.env.MONGODB_URI,
express: app,
email,
onInit: () => {
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
},
})
app.listen(8000)
}
start()

View File

@@ -0,0 +1,38 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react",
"sourceMap": true,
"resolveJsonModule": true,
"paths": {
"payload/generated-types": [
"./src/payload-types.ts"
],
"node_modules/*": [
"./node_modules/*"
]
},
},
"include": [
"src"
],
"exclude": [
"node_modules",
"dist",
"build",
],
"ts-node": {
"transpileOnly": true
}
}

6919
examples/email/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
MONGODB_URI=mongodb://localhost/payload-example-auth
PAYLOAD_SECRET=PAYLOAD_AUTH_EXAMPLE_SECRET_KEY
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
PAYLOAD_SEED=true
PAYLOAD_DROP_DATABASE=true

View File

@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ['@payloadcms'],
}

5
examples/multi-tenant/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
build
dist
node_modules
package-lock.json
.env

View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -0,0 +1,8 @@
module.exports = {
printWidth: 100,
parser: "typescript",
semi: false,
singleQuote: true,
trailingComma: "all",
arrowParens: "avoid",
};

View File

@@ -0,0 +1,132 @@
# Payload Multi-Tenant Example
This example demonstrates how to achieve a multi-tenancy in [Payload](https://github.com/payloadcms/payload). This is a powerful way to vertically scale your application by sharing infrastructure across tenants.
## Quick Start
To spin up this example locally, follow these steps:
1. First clone the repo
1. Then `cd YOUR_PROJECT_REPO && cp .env.example .env`
1. Next `yarn && yarn dev`
1. Now `open http://localhost:3000/admin` to access the admin panel
1. Login with email `dev@payloadcms.com` and password `test`
That's it! Changes made in `./src` will be reflected in your app. See the [Development](#development) section for more details on how to log in as a tenant.
## How it works
A multi-tenant Payload application is a single server that hosts multiple "tenants". Examples of tenants may be your agency's clients, your business conglomerate's organizations, or your SaaS customers.
Each tenant has its own set of users, pages, and other data that is scoped to that tenant. This means that your application will be shared across tenants but the data will be scoped to each tenant. Tenants also run on separate domains entirely, so users are not aware of their tenancy.
### Collections
See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend any of this functionality.
- #### Users
The `users` collection is auth-enabled and encompass both app-wide and tenant-scoped users based on the value of their `roles` and `tenants` fields. Users with the role `super-admin` can manage your entire application, while users with the _tenant role_ of `admin` have limited access to the platform and can manage only the tenant(s) they are assigned to, see [Tenants](#tenants) for more details.
For additional help with authentication, see the official [Auth Example](https://github.com/payloadcms/payload/tree/master/examples/auth/cms#readme) or the [Authentication](https://payloadcms.com/docs/authentication/overview#authentication-overview) docs.
- #### Tenants
A `tenants` collection is used to achieve tenant-based access control. Each user is assigned an array of `tenants` which includes a relationship to a `tenant` and their `roles` within that tenant. You can then scope any document within your application to any of your tenants using a simple [relationship](https://payloadcms.com/docs/fields/relationship) field on the `users` or `pages` collections, or any other collection that your application needs. The value of this field is used to filter documents in the admin panel and API to ensure that users can only access documents that belong to their tenant and are within their role. See [Access Control](#access-control) for more details.
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview) docs.
- #### Pages
Each page is assigned a `tenant` which is used to control access and scope API requests. Pages that are created by tenants are automatically assigned that tenant based on that user's `lastLoggedInTenant` field.
## Access control
Basic role-based access control is setup to determine what users can and cannot do based on their roles, which are:
- `super-admin`: They can access the Payload admin panel to manage your multi-tenant application. They can see all tenants and make all operations.
- `user`: They can only access the Payload admin panel if they are a tenant-admin, in which case they have a limited access to operations based on their tenant (see below).
This applies to each collection in the following ways:
- `users`: Only super-admins, tenant-admins, and the user themselves can access their profile. Anyone can create a user, but only these admins can delete users. See [Users](#users) for more details.
- `tenants`: Only super-admins and tenant-admins can read, create, update, or delete tenants. See [Tenants](#tenants) for more details.
- `pages`: Everyone can access pages, but only super-admins and tenant-admins can create, update, or delete them.
When a user logs in, a `lastLoggedInTenant` field is saved to their profile. This is done by reading the value of `req.headers.host`, querying for a tenant with a matching `domain`, and verifying that the user is a member of that tenant. This field is then used to automatically assign the tenant to any documents that the user creates, such as pages. Super-admins can also use this field to browse the admin panel as a specific tenant.
> If you have versions and drafts enabled on your pages, you will need to add additional read access control condition to check the user's tenants that prevents them from accessing draft documents of other tenants.
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview#access-control) docs.
## CORS
This multi-tenant setup requires an open CORS policy. Since each tenant contains a dynamic list of domains, there's no way to know specifically which domains to whitelist at runtime without significant performance implications. This also means that the `serverURL` is not set, as this scopes all requests to a single domain.
Alternatively, if you know the domains of your tenants ahead of time and these values won't change often, you could simply remove the `domains` field altogether and instead use static values.
For more details on this, see the [CORS](https://payloadcms.com/docs/production/preventing-abuse#cross-origin-resource-sharing-cors) docs.
## Front-end
If you're building a website or other front-end for your tenant, you will need specify the `tenant` in your requests. For example, if you wanted to fetch all pages for the tenant `ABC`, you would make a request to `/api/pages?where[tenant][slug][equals]=abc`.
For a head start on building a website for your tenant(s), check out the official [Website Template](https://github.com/payloadcms/template-website). It includes a page layout builder, preview, SEO, and much more. It is not multi-tenant, though, but you can easily take the concepts from that example and apply them here.
## Development
To spin up this example locally, follow the [Quick Start](#quick-start).
### Seed
On boot, a seed script is included to scaffold a basic database for you to use as an example. This is done by setting the `PAYLOAD_DROP_DATABASE` and `PAYLOAD_SEED` environment variables which are included in the `.env.example` by default. You can remove these from your `.env` to prevent this behavior. You can also freshly seed your project at any time by running `yarn seed`. This seed creates a super-admin user with email `dev@payloadcms.com` and password `test` along with the following tenants:
- `ABC`
- Domains:
- `abc.localhost.com:3000`
- Users:
- `admin@abc.com` with role `admin` and password `test`
- `user@abc.com` with role `user` and password `test`
- Pages:
- `ABC Home` with content `Hello, ABC!`
- `BBC`
- Domains:
- `bbc.localhost.com:3000`
- Users:
- `admin@bbc.com` with role `admin` and password `test`
- `user@bbc.com` with role `user` and password `test`
- Pages:
- `BBC Home` with content `Hello, BBC!`
> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data.
### Hosts file
To fully experience the multi-tenancy of this example locally, your app must run on one of the domains listed in any of your tenant's `domains` field. The simplest way to do this to add the following lines to your hosts file.
```bash
# these domains were provided in the seed script
# if needed, change them based on your own tenant settings
# remember to specify the port number when browsing to these domains
127.0.0.1 abc.localhost.com
127.0.0.1 bbc.localhost.com
```
> On Mac you can find the hosts file at `/etc/hosts`. On Windows, it's at `C:\Windows\System32\drivers\etc\hosts`.
Then you can access your app at `http://abc.localhost.com:3000` and `http://bbc.localhost.com:3000`. Access control will be scoped to the correct tenant based on that user's `tenants`, see [Access Control](#access-control) for more details.
## 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 choose to self-host your app, check out the [Deployment](https://payloadcms.com/docs/production/deployment) docs for more details.
## Questions
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/r6sCXqVk3v) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).

View File

@@ -0,0 +1,4 @@
{
"ext": "ts",
"exec": "ts-node src/server.ts"
}

View File

@@ -0,0 +1,46 @@
{
"name": "payload-example-multi-tenant",
"description": "Payload multi-tenant example.",
"version": "1.0.0",
"main": "dist/server.js",
"license": "MIT",
"scripts": {
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
"seed": "rm -rf media && cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts ts-node src/server.ts",
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
"build:server": "tsc",
"build": "yarn copyfiles && yarn build:payload && yarn build:server",
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
"generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema",
"lint": "eslint src",
"lint:fix": "eslint --fix --ext .ts,.tsx src"
},
"dependencies": {
"dotenv": "^8.2.0",
"express": "^4.17.1",
"payload": "^1.8.2"
},
"devDependencies": {
"@payloadcms/eslint-config": "^0.0.1",
"@types/express": "^4.17.9",
"@types/node": "18.11.3",
"@types/react": "18.0.21",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"eslint": "^8.19.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"nodemon": "^2.0.6",
"prettier": "^2.7.1",
"ts-node": "^9.1.1",
"typescript": "^4.8.4"
}
}

View File

@@ -0,0 +1,4 @@
import type { Access } from 'payload/types'
export const lastLoggedInTenant: Access = ({ req: { user }, data }) =>
user?.lastLoggedInTenant?.id === data?.id

View File

@@ -0,0 +1,5 @@
import type { Access } from 'payload/config'
export const loggedIn: Access = ({ req: { user } }) => {
return Boolean(user)
}

View File

@@ -0,0 +1,21 @@
import type { Access } from 'payload/config'
import { checkUserRoles } from '../../utilities/checkUserRoles'
// the user must be an admin of the document's tenant
export const tenantAdmins: Access = ({ req: { user } }) => {
if (checkUserRoles(['super-admin'], user)) {
return true
}
return {
tenant: {
in:
user?.tenants
?.map(({ tenant, roles }) =>
roles.includes('admin') ? (typeof tenant === 'string' ? tenant : tenant.id) : null,
) // eslint-disable-line function-paren-newline
.filter(Boolean) || [],
},
}
}

View File

@@ -0,0 +1,13 @@
import type { Access } from 'payload/types'
import { isSuperAdmin } from '../../utilities/isSuperAdmin'
export const tenants: Access = ({ req: { user }, data }) =>
// individual documents
(data?.tenant?.id && user?.lastLoggedInTenant?.id === data.tenant.id) ||
(!user?.lastLoggedInTenant?.id && isSuperAdmin(user)) || {
// list of documents
tenant: {
equals: user?.lastLoggedInTenant?.id,
},
}

View File

@@ -0,0 +1,27 @@
import type { FieldHook } from 'payload/types'
const format = (val: string): string =>
val
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '')
.toLowerCase()
const formatSlug =
(fallback: string): FieldHook =>
({ operation, value, originalDoc, data }) => {
if (typeof value === 'string') {
return format(value)
}
if (operation === 'create') {
const fallbackData = data?.[fallback] || originalDoc?.[fallback]
if (fallbackData && typeof fallbackData === 'string') {
return format(fallbackData)
}
}
return value
}
export default formatSlug

View File

@@ -0,0 +1,43 @@
import type { CollectionConfig } from 'payload/types'
import richText from '../fields/richText'
import { tenant } from '../fields/tenant'
import { loggedIn } from './access/loggedIn'
import { tenantAdmins } from './access/tenantAdmins'
import { tenants } from './access/tenants'
import formatSlug from './hooks/formatSlug'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'updatedAt'],
},
access: {
read: tenants,
create: loggedIn,
update: tenantAdmins,
delete: tenantAdmins,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
label: 'Slug',
type: 'text',
index: true,
admin: {
position: 'sidebar',
},
hooks: {
beforeValidate: [formatSlug('title')],
},
},
tenant,
richText(),
],
}

View File

@@ -0,0 +1,21 @@
import type { Access } from 'payload/config'
import { isSuperAdmin } from '../../utilities/isSuperAdmin'
// the user must be an admin of the tenant being accessed
export const tenantAdmins: Access = ({ req: { user } }) => {
if (isSuperAdmin(user)) {
return true
}
return {
id: {
in:
user?.tenants
?.map(({ tenant, roles }) =>
roles.includes('admin') ? (typeof tenant === 'string' ? tenant : tenant.id) : null,
) // eslint-disable-line function-paren-newline
.filter(Boolean) || [],
},
}
}

View File

@@ -0,0 +1,36 @@
import type { CollectionConfig } from 'payload/types'
import { superAdmins } from '../access/superAdmins'
import { tenantAdmins } from './access/tenantAdmins'
export const Tenants: CollectionConfig = {
slug: 'tenants',
access: {
create: superAdmins,
read: tenantAdmins,
update: tenantAdmins,
delete: superAdmins,
},
admin: {
useAsTitle: 'name',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'domains',
type: 'array',
index: true,
fields: [
{
name: 'domain',
type: 'text',
required: true,
},
],
},
],
}

View File

@@ -0,0 +1,54 @@
import type { Access } from 'payload/config'
import type { User } from 'payload/generated-types'
import { isSuperAdmin } from '../../utilities/isSuperAdmin'
export const adminsAndSelf: Access<any, User> = async ({ req: { user } }) => {
if (user) {
const isSuper = isSuperAdmin(user)
// allow super-admins through only if they have not scoped their user via `lastLoggedInTenant`
if (isSuper && !user?.lastLoggedInTenant) {
return true
}
// allow users to read themselves and any users within the tenants they are admins of
return {
or: [
{
id: {
equals: user.id,
},
},
...(isSuper
? [
{
'tenants.tenant': {
in: [
typeof user?.lastLoggedInTenant === 'string'
? user?.lastLoggedInTenant
: user?.lastLoggedInTenant?.id,
].filter(Boolean),
},
},
]
: [
{
'tenants.tenant': {
in:
user?.tenants
?.map(({ tenant, roles }) =>
roles.includes('admin')
? typeof tenant === 'string'
? tenant
: tenant.id
: null,
) // eslint-disable-line function-paren-newline
.filter(Boolean) || [],
},
},
]),
],
}
}
}

View File

@@ -0,0 +1,19 @@
import type { FieldAccess } from 'payload/types'
import { checkUserRoles } from '../../utilities/checkUserRoles'
import { checkTenantRoles } from '../utilities/checkTenantRoles'
export const tenantAdmins: FieldAccess = args => {
const {
req: { user },
doc,
} = args
return (
checkUserRoles(['super-admin'], user) ||
doc?.tenants?.some(({ tenant }) => {
const id = typeof tenant === 'string' ? tenant : tenant?.id
return checkTenantRoles(['admin'], user, id)
})
)
}

View File

@@ -0,0 +1,29 @@
import type { AfterChangeHook } from 'payload/dist/collections/config/types'
export const loginAfterCreate: AfterChangeHook = async ({
doc,
req,
req: { payload, body = {}, res },
operation,
}) => {
if (operation === 'create' && !req.user) {
const { email, password } = body
if (email && password) {
const { user, token } = await payload.login({
collection: 'users',
data: { email, password },
req,
res,
})
return {
...doc,
token,
user,
}
}
}
return doc
}

View File

@@ -0,0 +1,30 @@
import type { AfterLoginHook } from 'payload/dist/collections/config/types'
export const recordLastLoggedInTenant: AfterLoginHook = async ({ req, user }) => {
try {
const relatedOrg = await req.payload.find({
collection: 'tenants',
where: {
'domains.domain': {
in: [req.headers.host],
},
},
depth: 0,
limit: 1,
})
if (relatedOrg.docs.length > 0) {
await req.payload.update({
id: user.id,
collection: 'users',
data: {
lastLoggedInTenant: relatedOrg.docs[0].id,
},
})
}
} catch (err: unknown) {
req.payload.logger.error(`Error recording last logged in tenant for user ${user.id}: ${err}`)
}
return user
}

View File

@@ -0,0 +1,107 @@
import type { CollectionConfig } from 'payload/types'
import { anyone } from '../access/anyone'
import { superAdminFieldAccess } from '../access/superAdmins'
import { adminsAndSelf } from './access/adminsAndSelf'
import { tenantAdmins } from './access/tenantAdmins'
import { loginAfterCreate } from './hooks/loginAfterCreate'
import { recordLastLoggedInTenant } from './hooks/recordLastLoggedInTenant'
import { isSuperOrTenantAdmin } from './utilities/isSuperOrTenantAdmin'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
access: {
read: adminsAndSelf,
create: anyone,
update: adminsAndSelf,
delete: adminsAndSelf,
admin: isSuperOrTenantAdmin,
},
hooks: {
afterChange: [loginAfterCreate],
afterLogin: [recordLastLoggedInTenant],
},
fields: [
{
name: 'firstName',
type: 'text',
},
{
name: 'lastName',
type: 'text',
},
{
name: 'roles',
type: 'select',
hasMany: true,
required: true,
access: {
create: superAdminFieldAccess,
update: superAdminFieldAccess,
read: superAdminFieldAccess,
},
options: [
{
label: 'Super Admin',
value: 'super-admin',
},
{
label: 'User',
value: 'user',
},
],
},
{
name: 'tenants',
type: 'array',
label: 'Tenants',
access: {
create: tenantAdmins,
update: tenantAdmins,
read: tenantAdmins,
},
fields: [
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
},
{
name: 'roles',
type: 'select',
hasMany: true,
required: true,
options: [
{
label: 'Admin',
value: 'admin',
},
{
label: 'User',
value: 'user',
},
],
},
],
},
{
name: 'lastLoggedInTenant',
type: 'relationship',
relationTo: 'tenants',
index: true,
access: {
create: () => false,
read: tenantAdmins,
update: superAdminFieldAccess,
},
admin: {
position: 'sidebar',
},
},
],
}

View File

@@ -0,0 +1,23 @@
import type { User } from '../../../payload-types'
export const checkTenantRoles = (
allRoles: User['tenants'][0]['roles'] = [],
user: User = undefined,
tenant: User['tenants'][0]['tenant'] = undefined,
): boolean => {
if (tenant) {
const id = typeof tenant === 'string' ? tenant : tenant?.id
if (
allRoles.some(role => {
return user?.tenants?.some(({ tenant: userTenant, roles }) => {
const tenantID = typeof userTenant === 'string' ? userTenant : userTenant?.id
return tenantID === id && roles?.includes(role)
})
})
)
return true
}
return false
}

View File

@@ -0,0 +1,70 @@
import type { PayloadRequest } from 'payload/dist/types'
import { isSuperAdmin } from '../../utilities/isSuperAdmin'
const logs = false
export const isSuperOrTenantAdmin = async (args: { req: PayloadRequest }): Promise<boolean> => {
const {
req,
req: { user, payload },
} = args
// always allow super admins through
if (isSuperAdmin(user)) {
return true
}
if (logs) {
const msg = `Finding tenant with host: '${req.headers.host}'`
payload.logger.info({ msg })
}
// read `req.headers.host`, lookup the tenant by `domain` to ensure it exists, and check if the user is an admin of that tenant
const foundTenants = await payload.find({
collection: 'tenants',
where: {
'domains.domain': {
in: [req.headers.host],
},
},
depth: 0,
limit: 1,
})
// if this tenant does not exist, deny access
if (foundTenants.totalDocs === 0) {
if (logs) {
const msg = `No tenant found for ${req.headers.host}`
payload.logger.info({ msg })
}
return false
}
if (logs) {
const msg = `Found tenant: '${foundTenants.docs?.[0]?.name}', checking if user is an tenant admin`
payload.logger.info({ msg })
}
// finally check if the user is an admin of this tenant
const tenantWithUser = user?.tenants?.find(
({ tenant: userTenant }) => userTenant?.id === foundTenants.docs[0].id,
)
if (tenantWithUser?.roles?.some(role => role === 'admin')) {
if (logs) {
const msg = `User is an admin of ${foundTenants.docs[0].name}, allowing access`
payload.logger.info({ msg })
}
return true
}
if (logs) {
const msg = `User is not an admin of ${foundTenants.docs[0].name}, denying access`
payload.logger.info({ msg })
}
return false
}

View File

@@ -0,0 +1,3 @@
import type { Access } from 'payload/config'
export const anyone: Access = () => true

View File

@@ -0,0 +1,9 @@
import type { Access } from 'payload/config'
import type { FieldHook } from 'payload/types'
import { checkUserRoles } from '../utilities/checkUserRoles'
export const superAdmins: Access = ({ req: { user } }) => checkUserRoles(['super-admin'], user)
export const superAdminFieldAccess: FieldHook = ({ req: { user } }) =>
checkUserRoles(['super-admin'], user)

View File

@@ -0,0 +1,145 @@
import type { Field } from 'payload/types'
import deepMerge from '../utilities/deepMerge'
export const appearanceOptions = {
primary: {
label: 'Primary Button',
value: 'primary',
},
secondary: {
label: 'Secondary Button',
value: 'secondary',
},
default: {
label: 'Default',
value: 'default',
},
}
export type LinkAppearances = 'primary' | 'secondary' | 'default'
type LinkType = (options?: {
appearances?: LinkAppearances[] | false
disableLabel?: boolean
overrides?: Record<string, unknown>
}) => Field
const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } = {}) => {
const linkResult: Field = {
name: 'link',
type: 'group',
admin: {
hideGutter: true,
},
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'radio',
options: [
{
label: 'Internal link',
value: 'reference',
},
{
label: 'Custom URL',
value: 'custom',
},
],
defaultValue: 'reference',
admin: {
layout: 'horizontal',
width: '50%',
},
},
{
name: 'newTab',
label: 'Open in new tab',
type: 'checkbox',
admin: {
width: '50%',
style: {
alignSelf: 'flex-end',
},
},
},
],
},
],
}
const linkTypes: Field[] = [
{
name: 'reference',
label: 'Document to link to',
type: 'relationship',
relationTo: ['pages'],
required: true,
maxDepth: 1,
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
},
{
name: 'url',
label: 'Custom URL',
type: 'text',
required: true,
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
},
},
]
if (!disableLabel) {
linkTypes[0].admin.width = '50%'
linkTypes[1].admin.width = '50%'
linkResult.fields.push({
type: 'row',
fields: [
...linkTypes,
{
name: 'label',
label: 'Label',
type: 'text',
required: true,
admin: {
width: '50%',
},
},
],
})
} else {
linkResult.fields = [...linkResult.fields, ...linkTypes]
}
if (appearances !== false) {
let appearanceOptionsToUse = [
appearanceOptions.default,
appearanceOptions.primary,
appearanceOptions.secondary,
]
if (appearances) {
appearanceOptionsToUse = appearances.map(appearance => appearanceOptions[appearance])
}
linkResult.fields.push({
name: 'appearance',
type: 'select',
defaultValue: 'default',
options: appearanceOptionsToUse,
admin: {
description: 'Choose how the link should be rendered.',
},
})
}
return deepMerge(linkResult, overrides)
}
export default link

View File

@@ -0,0 +1,5 @@
import type { RichTextElement } from 'payload/dist/fields/config/types'
const elements: RichTextElement[] = ['blockquote', 'h2', 'h3', 'h4', 'h5', 'h6', 'link']
export default elements

View File

@@ -0,0 +1,86 @@
import type { RichTextElement, RichTextField, RichTextLeaf } from 'payload/dist/fields/config/types'
import deepMerge from '../../utilities/deepMerge'
import link from '../link'
import elements from './elements'
import leaves from './leaves'
type RichText = (
overrides?: Partial<RichTextField>,
additions?: {
elements?: RichTextElement[]
leaves?: RichTextLeaf[]
},
) => RichTextField
const richText: RichText = (
overrides,
additions = {
elements: [],
leaves: [],
},
) =>
deepMerge<RichTextField, Partial<RichTextField>>(
{
name: 'richText',
type: 'richText',
required: true,
admin: {
upload: {
collections: {
media: {
fields: [
{
type: 'richText',
name: 'caption',
label: 'Caption',
admin: {
elements: [...elements],
leaves: [...leaves],
},
},
{
type: 'radio',
name: 'alignment',
label: 'Alignment',
options: [
{
label: 'Left',
value: 'left',
},
{
label: 'Center',
value: 'center',
},
{
label: 'Right',
value: 'right',
},
],
},
{
name: 'enableLink',
type: 'checkbox',
label: 'Enable Link',
},
link({
appearances: false,
disableLabel: true,
overrides: {
admin: {
condition: (_, data) => Boolean(data?.enableLink),
},
},
}),
],
},
},
},
elements: [...elements, ...(additions.elements || [])],
leaves: [...leaves, ...(additions.leaves || [])],
},
},
overrides,
)
export default richText

View File

@@ -0,0 +1,5 @@
import type { RichTextLeaf } from 'payload/dist/fields/config/types'
const defaultLeaves: RichTextLeaf[] = ['bold', 'italic', 'underline']
export default defaultLeaves

View File

@@ -0,0 +1,16 @@
import type { FieldAccess } from 'payload/types'
import { checkUserRoles } from '../../../utilities/checkUserRoles'
export const tenantAdminFieldAccess: FieldAccess = ({ req: { user }, doc }) => {
return (
checkUserRoles(['super-admin'], user) ||
!doc?.tenant ||
(doc?.tenant &&
user?.tenants?.some(
({ tenant: userTenant, roles }) =>
(typeof doc?.tenant === 'string' ? doc?.tenant : doc?.tenant.id) === userTenant?.id &&
roles?.includes('admin'),
))
)
}

View File

@@ -0,0 +1,42 @@
import type { Field } from 'payload/types'
import { superAdminFieldAccess } from '../../access/superAdmins'
import { isSuperAdmin } from '../../utilities/isSuperAdmin'
import { tenantAdminFieldAccess } from './access/tenantAdmins'
export const tenant: Field = {
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
// don't require this field because we need to auto-populate it, see below
// required: true,
// we also don't want to hide this field because super-admins may need to manage it
// to achieve this, create a custom component that conditionally renders the field based on the user's role
// hidden: true,
index: true,
admin: {
position: 'sidebar',
},
access: {
create: superAdminFieldAccess,
read: tenantAdminFieldAccess,
update: superAdminFieldAccess,
},
hooks: {
// automatically set the tenant to the last logged in tenant
// for super admins, allow them to set the tenant
beforeChange: [
async ({ req, req: { user }, data }) => {
if ((await isSuperAdmin(req.user)) && data?.tenant) {
return data.tenant
}
if (user?.lastLoggedInTenant?.id) {
return user.lastLoggedInTenant.id
}
return undefined
},
],
},
}

View File

@@ -0,0 +1,16 @@
import type { User } from '../../payload-types'
export const checkUserRoles = (allRoles: User['roles'] = [], user: User = undefined): boolean => {
if (user) {
if (
allRoles.some(role => {
return user?.roles?.some(individualRole => {
return individualRole === role
})
})
)
return true
}
return false
}

View File

@@ -0,0 +1,32 @@
/**
* Simple object check.
* @param item
* @returns {boolean}
*/
export function isObject(item: unknown): boolean {
return item && typeof item === 'object' && !Array.isArray(item)
}
/**
* Deep merge two objects.
* @param target
* @param ...sources
*/
export default function deepMerge<T, R>(target: T, source: R): T {
const output = { ...target }
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach(key => {
if (isObject(source[key])) {
if (!(key in (target as Record<string, unknown>))) {
Object.assign(output, { [key]: source[key] })
} else {
output[key] = deepMerge(target[key], source[key])
}
} else {
Object.assign(output, { [key]: source[key] })
}
})
}
return output
}

View File

@@ -0,0 +1,5 @@
import type { User } from 'payload/generated-types'
import { checkUserRoles } from './checkUserRoles'
export const isSuperAdmin = (user: User): boolean => checkUserRoles(['super-admin'], user)

View File

@@ -0,0 +1,56 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
users: User
tenants: Tenant
pages: Page
}
globals: {}
}
export interface User {
id: string
firstName?: string
lastName?: string
roles: Array<'super-admin' | 'user'>
tenants?: Array<{
tenant: string | Tenant
roles: Array<'admin' | 'user'>
id?: string
}>
lastLoggedInTenant?: string | Tenant
updatedAt: string
createdAt: string
email?: string
resetPasswordToken?: string
resetPasswordExpiration?: string
loginAttempts?: number
lockUntil?: string
password?: string
}
export interface Tenant {
id: string
name: string
domains: Array<{
domain: string
id?: string
}>
updatedAt: string
createdAt: string
}
export interface Page {
id: string
title: string
slug?: string
tenant?: string | Tenant
richText: Array<{
[k: string]: unknown
}>
updatedAt: string
createdAt: string
}

View File

@@ -0,0 +1,19 @@
import dotenv from 'dotenv'
import path from 'path'
dotenv.config({
path: path.resolve(__dirname, '../.env'),
})
import { buildConfig } from 'payload/config'
import { Pages } from './collections/Pages'
import { Tenants } from './collections/Tenants'
import { Users } from './collections/Users'
export default buildConfig({
collections: [Users, Tenants, Pages],
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,119 @@
import type { Payload } from 'payload'
export const seed = async (payload: Payload): Promise<void> => {
// create super admin
await payload.create({
collection: 'users',
data: {
email: 'dev@payloadcms.com',
password: 'test',
roles: ['super-admin'],
},
})
// create tenants, use `*.localhost.com` so that accidentally forgotten changes the hosts file are acceptable
const [abc, bbc] = await Promise.all([
await payload.create({
collection: 'tenants',
data: {
name: 'ABC',
domains: [{ domain: 'abc.localhost.com:3000' }],
},
}),
await payload.create({
collection: 'tenants',
data: {
name: 'BBC',
domains: [{ domain: 'bbc.localhost.com:3000' }],
},
}),
])
// create tenant-scoped admins and users
await Promise.all([
await payload.create({
collection: 'users',
data: {
email: 'admin@abc.com',
password: 'test',
roles: ['user'],
tenants: [
{
tenant: abc.id,
roles: ['admin'],
},
],
},
}),
await payload.create({
collection: 'users',
data: {
email: 'user@abc.com',
password: 'test',
roles: ['user'],
tenants: [
{
tenant: abc.id,
roles: ['user'],
},
],
},
}),
await payload.create({
collection: 'users',
data: {
email: 'admin@bbc.com',
password: 'test',
roles: ['user'],
tenants: [
{
tenant: bbc.id,
roles: ['admin'],
},
],
},
}),
await payload.create({
collection: 'users',
data: {
email: 'user@bbc.com',
password: 'test',
roles: ['user'],
tenants: [
{
tenant: bbc.id,
roles: ['user'],
},
],
},
}),
])
// create tenant-scoped pages
await Promise.all([
await payload.create({
collection: 'pages',
data: {
tenant: abc.id,
title: 'ABC Home',
richText: [
{
text: 'Hello, ABC!',
},
],
},
}),
await payload.create({
collection: 'pages',
data: {
title: 'BBC Home',
tenant: bbc.id,
richText: [
{
text: 'Hello, BBC!',
},
],
},
}),
])
}

View File

@@ -0,0 +1,37 @@
import dotenv from 'dotenv'
import path from 'path'
dotenv.config({
path: path.resolve(__dirname, '../.env'),
})
import express from 'express'
import payload from 'payload'
import { seed } from './seed'
const app = express()
app.get('/', (_, res) => {
res.redirect('/admin')
})
const start = async (): Promise<void> => {
await payload.init({
secret: process.env.PAYLOAD_SECRET,
mongoURL: process.env.MONGODB_URI,
express: app,
onInit: () => {
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
},
})
if (process.env.PAYLOAD_SEED === 'true') {
payload.logger.info('---- SEEDING DATABASE ----')
await seed(payload)
}
app.listen(3000)
}
start()

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react",
"sourceMap": true,
"resolveJsonModule": true,
"paths": {
"payload/generated-types": ["./src/payload-types.ts"],
"node_modules/*": ["./node_modules/*"]
},
},
"include": [
"src"
],
"exclude": [
"node_modules",
"dist",
"build",
],
"ts-node": {
"transpileOnly": true
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "1.8.2",
"version": "1.8.3",
"description": "Node, React and MongoDB Headless CMS and Application Framework",
"license": "MIT",
"engines": {
@@ -38,7 +38,7 @@
"build:watch": "nodemon --watch 'src/**' --ext 'ts,tsx' --exec \"yarn build:tsc\"",
"dev": "nodemon",
"dev:generate-types": "ts-node -T ./test/generateTypes.ts",
"dev:generate-graphql-schema": "cross-env PAYLOAD_CONFIG_PATH=test/graphql-schema-gen/config.ts ts-node -T ./src/bin/generateGraphQLSchema.ts",
"dev:generate-graphql-schema": "ts-node -T ./test/generateGraphQLSchema.ts",
"pretest": "yarn build",
"test": "yarn test:int && yarn test:components && yarn test:e2e",
"test:int": "cross-env DISABLE_LOGGING=true jest --forceExit --detectOpenHandles",

1706
schema.graphql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,10 @@
import React, { HTMLAttributes } from 'react';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { Props as EditViewProps } from '../../views/collections/Edit/types';
export type DocumentDrawerProps = {
collectionSlug: string
id?: string
onSave?: (json: {
doc: Record<string, any>
message: string
collectionConfig: SanitizedCollectionConfig
}) => void
onSave?: EditViewProps['onSave']
customHeader?: React.ReactNode
drawerSlug?: string
}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import AnimateHeight from 'react-animate-height';
import { useTranslation } from 'react-i18next';
import { useWindowInfo } from '@faceless-ui/window-info';
@@ -20,11 +20,24 @@ import EditMany from '../EditMany';
import DeleteMany from '../DeleteMany';
import PublishMany from '../PublishMany';
import UnpublishMany from '../UnpublishMany';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import './index.scss';
const baseClass = 'list-controls';
const getUseAsTitle = (collection: SanitizedCollectionConfig) => {
const {
admin: {
useAsTitle,
},
fields,
} = collection;
const topLevelFields = flattenFields(fields);
return topLevelFields.find((field) => fieldAffectsData(field) && field.name === useAsTitle);
};
const ListControls: React.FC<Props> = (props) => {
const {
collection,
@@ -37,7 +50,6 @@ const ListControls: React.FC<Props> = (props) => {
collection: {
fields,
admin: {
useAsTitle,
listSearchableFields,
},
},
@@ -46,10 +58,11 @@ const ListControls: React.FC<Props> = (props) => {
const params = useSearchParams();
const shouldInitializeWhereOpened = validateWhereQuery(params?.where);
const [titleField] = useState(() => {
const topLevelFields = flattenFields(fields);
return topLevelFields.find((field) => fieldAffectsData(field) && field.name === useAsTitle);
});
const [titleField, setTitleField] = useState(getUseAsTitle(collection));
useEffect(() => {
setTitleField(getUseAsTitle(collection));
}, [collection]);
const [textFieldsToBeSearched] = useState(getTextFieldsToBeSearched(listSearchableFields, fields));
const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>(shouldInitializeWhereOpened ? 'where' : undefined);
const { t, i18n } = useTranslation('general');

View File

@@ -30,13 +30,23 @@
ul {
list-style: none;
padding: 0;
text-align: left;
max-height: base(8);
margin: 0;
a {
li a {
all: unset;
cursor: pointer;
padding-right: 0;
&:hover {
text-decoration: underline;
}
}
}
@include mid-break {
.popup__content {
width: calc(100vw - calc(var(--gutter-h) * 2));
}
}
}

View File

@@ -23,6 +23,7 @@ const Localizer: React.FC = () => {
return (
<div className={baseClass}>
<Popup
showScrollbar
horizontalAlign="left"
button={locale}
render={({ close }) => (
@@ -35,7 +36,7 @@ const Localizer: React.FC = () => {
const localeClasses = [
baseLocaleClass,
locale === localeOption && `${baseLocaleClass}--active`,
];
].filter(Boolean).join('');
const newParams = {
...searchParams,
@@ -48,7 +49,7 @@ const Localizer: React.FC = () => {
return (
<li
key={localeOption}
className={localeClasses.join(' ')}
className={localeClasses}
>
<Link
to={{ search }}

View File

@@ -25,12 +25,19 @@
overflow: hidden;
}
&__scroll {
.popup__scroll {
padding: $baseline;
padding-right: calc(var(--scrollbar-width) + #{$baseline});
overflow-y: auto;
width: calc(100% + var(--scrollbar-width));
white-space: nowrap;
padding-right: calc(var(--scrollbar-width) + #{$baseline});
width: calc(100% + var(--scrollbar-width));
}
&--show-scrollbar {
.popup__scroll {
padding-right: 0;
width: 100%;
}
}
&:focus,

View File

@@ -26,6 +26,7 @@ const Popup: React.FC<Props> = (props) => {
padding,
forceOpen,
boundingRef,
showScrollbar = false,
} = props;
const { width: windowWidth, height: windowHeight } = useWindowInfo();
@@ -125,7 +126,8 @@ const Popup: React.FC<Props> = (props) => {
`${baseClass}--color-${color}`,
`${baseClass}--v-align-${verticalAlign}`,
`${baseClass}--h-align-${horizontalAlign}`,
(active) && `${baseClass}--active`,
active && `${baseClass}--active`,
showScrollbar && `${baseClass}--show-scrollbar`,
].filter(Boolean).join(' ');
return (

View File

@@ -1,21 +1,22 @@
import { CSSProperties } from 'react';
export type Props = {
className?: string
buttonClassName?: string
render?: (any) => React.ReactNode,
children?: React.ReactNode,
verticalAlign?: 'top' | 'bottom'
horizontalAlign?: 'left' | 'center' | 'right',
size?: 'small' | 'large' | 'wide',
color?: 'light' | 'dark',
buttonType?: 'default' | 'custom' | 'none',
button?: React.ReactNode,
forceOpen?: boolean
showOnHover?: boolean,
initActive?: boolean,
onToggleOpen?: (active: boolean) => void,
backgroundColor?: CSSProperties['backgroundColor'],
padding?: CSSProperties['padding'],
boundingRef?: React.MutableRefObject<HTMLElement>
className?: string
buttonClassName?: string
render?: (any) => React.ReactNode,
children?: React.ReactNode,
verticalAlign?: 'top' | 'bottom'
horizontalAlign?: 'left' | 'center' | 'right',
size?: 'small' | 'large' | 'wide',
color?: 'light' | 'dark',
buttonType?: 'default' | 'custom' | 'none',
button?: React.ReactNode,
forceOpen?: boolean
showOnHover?: boolean,
initActive?: boolean,
onToggleOpen?: (active: boolean) => void,
backgroundColor?: CSSProperties['backgroundColor'],
padding?: CSSProperties['padding'],
boundingRef?: React.MutableRefObject<HTMLElement>
showScrollbar?: boolean
}

View File

@@ -85,8 +85,10 @@ const SearchFilter: React.FC<Props> = (props) => {
}
return `${prev}, ${getTranslation(curr.label || curr.name, i18n)}`;
}, placeholder.current);
} else {
placeholder.current = t('searchBy', { label: getTranslation(fieldLabel, i18n) });
}
}, [t, listSearchableFields, i18n]);
}, [t, listSearchableFields, i18n, fieldLabel]);
return (
<div className={baseClass}>

View File

@@ -11,12 +11,21 @@ import { getTranslation } from '../../../../../../utilities/getTranslation';
import Tooltip from '../../../../elements/Tooltip';
import { useDocumentDrawer } from '../../../../elements/DocumentDrawer';
import { useConfig } from '../../../../utilities/Config';
import { Props as EditViewProps } from '../../../../views/collections/Edit/types';
import { Value } from '../types';
import './index.scss';
const baseClass = 'relationship-add-new';
export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, value, setValue, dispatchOptions }) => {
export const AddNewRelation: React.FC<Props> = ({
path,
hasMany,
relationTo,
value,
setValue,
dispatchOptions,
}) => {
const relatedCollections = useRelatedCollections(relationTo);
const { permissions } = useAuth();
const [show, setShow] = useState(false);
@@ -36,30 +45,43 @@ export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, val
collectionSlug: collectionConfig?.slug,
});
const onSave = useCallback((json) => {
const newValue = Array.isArray(relationTo) ? {
relationTo: collectionConfig.slug,
value: json.doc.id,
} : json.doc.id;
const onSave: EditViewProps['onSave'] = useCallback(({
operation,
doc,
}) => {
if (operation === 'create') {
const newValue: Value = Array.isArray(relationTo) ? {
relationTo: collectionConfig.slug,
value: doc.id,
} : doc.id;
dispatchOptions({
type: 'ADD',
collection: collectionConfig,
docs: [
json.doc,
],
sort: true,
i18n,
config,
});
// ensure the value is not already in the array
const isNewValue = Array.isArray(relationTo) && Array.isArray(value)
? !value.some((v) => v && typeof v === 'object' && v.value === doc.id)
: value !== doc.id;
if (hasMany) {
setValue([...(Array.isArray(value) ? value : []), newValue]);
} else {
setValue(newValue);
if (isNewValue) {
dispatchOptions({
type: 'ADD',
collection: collectionConfig,
docs: [
doc,
],
sort: true,
i18n,
config,
});
if (hasMany) {
setValue([...(Array.isArray(value) ? value : []), newValue]);
} else {
setValue(newValue);
}
}
setSelectedCollection(undefined);
}
setSelectedCollection(undefined);
}, [relationTo, collectionConfig, dispatchOptions, i18n, hasMany, setValue, value, config]);
const onPopopToggle = useCallback((state) => {

View File

@@ -1,11 +1,12 @@
import React from 'react';
import { Action } from '../types';
import { Action, OptionGroup, Value } from '../types';
export type Props = {
hasMany: boolean
relationTo: string | string[]
path: string
value: unknown
value: Value | Value[]
options: OptionGroup[]
setValue: (value: unknown) => void
dispatchOptions: React.Dispatch<Action>
}

View File

@@ -450,7 +450,7 @@ const Relationship: React.FC<Props> = (props) => {
/>
{!readOnly && allowCreate && (
<AddNewRelation
{...{ path: pathOrName, hasMany, relationTo, value, setValue, dispatchOptions }}
{...{ path: pathOrName, hasMany, relationTo, value, setValue, dispatchOptions, options }}
/>
)}
</div>

View File

@@ -54,6 +54,8 @@ export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
<DocumentDrawerToggler
className={`${baseClass}__drawer-toggler`}
aria-label={`Edit ${label}`}
onTouchEnd={(e) => e.stopPropagation()} // prevents react-select dropdown from opening
onMouseDown={(e) => e.stopPropagation()} // prevents react-select dropdown from opening
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onClick={() => setShowTooltip(false)}

View File

@@ -53,6 +53,7 @@ export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
<DocumentDrawerToggler
className={`${baseClass}__drawer-toggler`}
aria-label={t('editLabel', { label })}
onTouchEnd={(e) => e.stopPropagation()} // prevents react-select dropdown from opening
onMouseDown={(e) => e.stopPropagation()} // prevents react-select dropdown from opening
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}

View File

@@ -198,7 +198,12 @@ export const DocumentInfoProvider: React.FC<Props> = ({
}
if (docAccessURL) {
const res = await fetch(`${serverURL}${api}${docAccessURL}`);
const res = await fetch(`${serverURL}${api}${docAccessURL}`, {
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
},
});
const json = await res.json();
setDocPermissions(json);
} else {
@@ -206,7 +211,7 @@ export const DocumentInfoProvider: React.FC<Props> = ({
// (i.e. create has no id)
setDocPermissions(permissions[pluralType][slug]);
}
}, [serverURL, api, pluralType, slug, id, permissions]);
}, [serverURL, api, pluralType, slug, id, permissions, i18n.language]);
useEffect(() => {
getVersions();

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../utilities/Config';
@@ -43,7 +43,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
collection,
isEditing,
data,
onSave,
onSave: onSaveFromProps,
permissions,
isLoading,
internalState,
@@ -78,6 +78,15 @@ const DefaultEditView: React.FC<Props> = (props) => {
isEditing && `${baseClass}--is-editing`,
].filter(Boolean).join(' ');
const onSave = useCallback((json) => {
if (typeof onSaveFromProps === 'function') {
onSaveFromProps({
...json,
operation: id ? 'update' : 'create',
});
}
}, [id, onSaveFromProps]);
const operation = isEditing ? 'update' : 'create';
return (

View File

@@ -11,7 +11,12 @@ export type IndexProps = {
export type Props = IndexProps & {
data: Document
onSave?: () => void
onSave?: (json: Record<string, unknown> & {
doc: Record<string, any>
message: string
collectionConfig: SanitizedCollectionConfig
operation: 'create' | 'update',
}) => void
id?: string
permissions: CollectionPermission
isLoading: boolean

View File

@@ -21,6 +21,7 @@ import EditMany from '../../../elements/EditMany';
import DeleteMany from '../../../elements/DeleteMany';
import PublishMany from '../../../elements/PublishMany';
import UnpublishMany from '../../../elements/UnpublishMany';
import formatFilesize from '../../../../../uploads/formatFilesize';
import './index.scss';
@@ -54,6 +55,16 @@ const DefaultList: React.FC<Props> = (props) => {
const { breakpoints: { s: smallBreak } } = useWindowInfo();
const { t, i18n } = useTranslation('general');
let formattedDocs = data.docs || [];
if (collection.upload) {
formattedDocs = formattedDocs?.map((doc) => {
return {
...doc,
filesize: formatFilesize(doc.filesize),
};
});
}
return (
<div className={baseClass}>
@@ -108,7 +119,7 @@ const DefaultList: React.FC<Props> = (props) => {
)}
{(data.docs && data.docs.length > 0) && (
<RelationshipProvider>
<Table data={data.docs} />
<Table data={formattedDocs} />
</RelationshipProvider>
)}
{data.docs && data.docs.length === 0 && (

View File

@@ -54,12 +54,16 @@ const batchAndLoadDocs = (req: PayloadRequest): BatchLoadFn<string, TypeWithID>
const idField = payload.collections?.[collection].config.fields.find((field) => fieldAffectsData(field) && field.name === 'id');
if (isValidID(id, getIDType(idField))) {
let sanitizedID: string | number = id
if (idField?.type === 'number') sanitizedID = parseFloat(id)
if (isValidID(sanitizedID, getIDType(idField))) {
return {
...batches,
[batchKey]: [
...batches[batchKey] || [],
id,
sanitizedID,
],
};
}

View File

@@ -97,7 +97,7 @@ function initCollectionsGraphQL(payload: Payload): void {
collection.graphQL.paginatedType = buildPaginatedListType(
pluralName,
collection.graphQL.type
collection.graphQL.type,
);
collection.graphQL.whereInputType = buildWhereInputType(

View File

@@ -19,6 +19,9 @@ export default function initCollectionsLocal(ctx: Payload): void {
if (collection.auth && !collection.auth.disableLocalStrategy) {
schema.plugin(passportLocalMongoose, {
usernameField: 'email',
errorMessages: {
UserExistsError: 'A user with the given email is already registered',
},
});

View File

@@ -135,6 +135,7 @@ export const code = baseField.keys({
),
admin: baseAdminFields.keys({
language: joi.string(),
editorOptions: joi.object().unknown(), // Editor['options'] @monaco-editor/react
}),
});

View File

@@ -6,6 +6,7 @@ import {
CodeField,
DateField,
EmailField,
fieldAffectsData,
JSONField,
NumberField,
PointField,
@@ -18,7 +19,6 @@ import {
TextField,
UploadField,
Validate,
fieldAffectsData,
} from './config/types';
import canUseDOM from '../utilities/canUseDOM';
import { isValidID } from '../utilities/isValidID';
@@ -319,8 +319,11 @@ export const relationship: Validate<unknown, unknown, RelationshipField> = async
requestedID = val.value;
}
const idField = payload.collections[collection].config.fields.find((field) => fieldAffectsData(field) && field.name === 'id');
if (requestedID === null) return false;
const idField = payload.collections[collection]?.config?.fields?.find((field) => fieldAffectsData(field) && field.name === 'id');
let type;
if (idField) {
type = idField.type === 'number' ? 'number' : 'text';
} else {

View File

@@ -1,4 +1,3 @@
import merge from 'deepmerge';
import { toWords } from '../../utilities/formatLabels';
import { CollectionConfig } from '../../collections/config/types';
import sanitizeFields from '../../fields/config/sanitize';
@@ -6,6 +5,8 @@ import { GlobalConfig, SanitizedGlobalConfig } from './types';
import defaultAccess from '../../auth/defaultAccess';
import baseVersionFields from '../../versions/baseFields';
import mergeBaseFields from '../../fields/mergeBaseFields';
import translations from '../../translations';
import { fieldAffectsData } from '../../fields/config/types';
const sanitizeGlobals = (collections: CollectionConfig[], globals: GlobalConfig[]): SanitizedGlobalConfig[] => {
const sanitizedGlobals = globals.map((global) => {
@@ -56,6 +57,37 @@ const sanitizeGlobals = (collections: CollectionConfig[], globals: GlobalConfig[
// /////////////////////////////////
// Sanitize fields
// /////////////////////////////////
let hasUpdatedAt = null;
let hasCreatedAt = null;
sanitizedGlobal.fields.some((field) => {
if (fieldAffectsData(field)) {
if (field.name === 'updatedAt') hasUpdatedAt = true;
if (field.name === 'createdAt') hasCreatedAt = true;
}
return hasCreatedAt && hasUpdatedAt;
});
if (!hasUpdatedAt) {
sanitizedGlobal.fields.push({
name: 'updatedAt',
label: translations['general:updatedAt'],
type: 'date',
admin: {
hidden: true,
disableBulkEdit: true,
},
});
}
if (!hasCreatedAt) {
sanitizedGlobal.fields.push({
name: 'createdAt',
label: translations['general:createdAt'],
type: 'date',
admin: {
hidden: true,
disableBulkEdit: true,
},
});
}
const validRelationships = collections.map((c) => c.slug);
sanitizedGlobal.fields = sanitizeFields(sanitizedGlobal.fields, validRelationships);

View File

@@ -389,8 +389,6 @@ function buildObjectType({
}
if (id) {
id = id.toString();
const relatedDocument = await context.req.payloadDataLoader.load(JSON.stringify([
relatedCollectionSlug,
id,

View File

@@ -4,17 +4,15 @@ import {
GraphQLInputObjectType, GraphQLList,
} from 'graphql';
import { GraphQLJSON } from 'graphql-type-json';
import {
Field,
FieldAffectingData,
fieldAffectsData,
fieldHasSubFields,
fieldIsPresentationalOnly,
} from '../../fields/config/types';
import formatName from '../utilities/formatName';
import withOperators from './withOperators';
import operators from './operators';
import { withOperators } from './withOperators';
import fieldToSchemaMap from './fieldToWhereInputSchemaMap';
// buildWhereInputType is similar to buildObjectType and operates
@@ -28,7 +26,11 @@ const buildWhereInputType = (name: string, fields: Field[], parentName: string):
// This is the function that builds nested paths for all
// field types with nested paths.
let idField: FieldAffectingData | undefined;
const fieldTypes = fields.reduce((schema, field) => {
if (fieldAffectsData(field) && field.name === 'id') idField = field;
if (!fieldIsPresentationalOnly(field) && !field.hidden) {
const getFieldSchema = fieldToSchemaMap(parentName)[field.type];
@@ -55,14 +57,14 @@ const buildWhereInputType = (name: string, fields: Field[], parentName: string):
return schema;
}, {});
fieldTypes.id = {
type: withOperators(
{ name: 'id' } as FieldAffectingData,
GraphQLJSON,
parentName,
[...operators.equality, ...operators.contains],
),
};
if (!idField) {
fieldTypes.id = {
type: withOperators(
{ name: 'id', type: 'text' } as FieldAffectingData,
parentName,
),
};
}
const fieldName = formatName(name);

View File

@@ -1,234 +1,133 @@
import {
GraphQLBoolean,
GraphQLEnumType,
GraphQLFloat,
GraphQLInputObjectType,
GraphQLList,
GraphQLString,
} from 'graphql';
import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars';
import { GraphQLJSON } from 'graphql-type-json';
import {
ArrayField,
CheckboxField,
CodeField, CollapsibleField, DateField,
EmailField, fieldAffectsData, fieldHasSubFields, GroupField,
JSONField,
NumberField, optionIsObject, PointField,
NumberField, PointField,
RadioField, RelationshipField,
RichTextField, RowField, SelectField,
TabsField,
TextareaField,
TextField, UploadField,
} from '../../fields/config/types';
import withOperators from './withOperators';
import operators from './operators';
import { withOperators } from './withOperators';
import combineParentName from '../utilities/combineParentName';
import formatName from '../utilities/formatName';
import recursivelyBuildNestedPaths from './recursivelyBuildNestedPaths';
const fieldToSchemaMap: (parentName: string) => any = (parentName: string) => ({
number: (field: NumberField) => {
const type = GraphQLFloat;
return {
type: withOperators(
field,
type,
parentName,
[...operators.equality, ...operators.comparison],
),
};
},
text: (field: TextField) => {
const type = GraphQLString;
return {
type: withOperators(
field,
type,
parentName,
[...operators.equality, ...operators.partial, ...operators.contains],
),
};
},
email: (field: EmailField) => {
const type = EmailAddressResolver;
return {
type: withOperators(
field,
type,
parentName,
[...operators.equality, ...operators.partial, ...operators.contains],
),
};
},
textarea: (field: TextareaField) => {
const type = GraphQLString;
return {
type: withOperators(
field,
type,
parentName,
[...operators.equality, ...operators.partial],
),
};
},
richText: (field: RichTextField) => {
const type = GraphQLJSON;
return {
type: withOperators(
field,
type,
parentName,
[...operators.equality, ...operators.partial],
),
};
},
json: (field: JSONField) => {
const type = GraphQLJSON;
return {
type: withOperators(
field,
type,
parentName,
[...operators.equality, ...operators.partial],
),
};
},
code: (field: CodeField) => {
const type = GraphQLString;
return {
type: withOperators(
field,
type,
parentName,
[...operators.equality, ...operators.partial],
),
};
},
const fieldToSchemaMap = (parentName: string): any => ({
number: (field: NumberField) => ({
type: withOperators(
field,
parentName,
),
}),
text: (field: TextField) => ({
type: withOperators(
field,
parentName,
),
}),
email: (field: EmailField) => ({
type: withOperators(
field,
parentName,
),
}),
textarea: (field: TextareaField) => ({
type: withOperators(
field,
parentName,
),
}),
richText: (field: RichTextField) => ({
type: withOperators(
field,
parentName,
),
}),
json: (field: JSONField) => ({
type: withOperators(
field,
parentName,
),
}),
code: (field: CodeField) => ({
type: withOperators(
field,
parentName,
),
}),
radio: (field: RadioField) => ({
type: withOperators(
field,
new GraphQLEnumType({
name: `${combineParentName(parentName, field.name)}_Input`,
values: field.options.reduce((values, option) => {
if (optionIsObject(option)) {
return {
...values,
[formatName(option.value)]: {
value: option.value,
},
};
}
return {
...values,
[formatName(option)]: {
value: option,
},
};
}, {}),
}),
parentName,
[...operators.equality, ...operators.contains],
),
}),
date: (field: DateField) => {
const type = DateTimeResolver;
return {
type: withOperators(
field,
type,
parentName,
[...operators.equality, ...operators.comparison, 'like'],
),
};
},
point: (field: PointField) => {
const type = new GraphQLList(GraphQLFloat);
return {
type: withOperators(
field,
type,
parentName,
[...operators.equality, ...operators.comparison, ...operators.geo],
),
};
},
relationship: (field: RelationshipField) => {
let type = withOperators(
date: (field: DateField) => ({
type: withOperators(
field,
GraphQLString,
parentName,
[...operators.equality, ...operators.contains],
);
),
}),
point: (field: PointField) => ({
type: withOperators(
field,
parentName,
),
}),
relationship: (field: RelationshipField) => {
if (Array.isArray(field.relationTo)) {
type = new GraphQLInputObjectType({
name: `${combineParentName(parentName, field.name)}_Relation`,
fields: {
relationTo: {
type: new GraphQLEnumType({
name: `${combineParentName(parentName, field.name)}_Relation_RelationTo`,
values: field.relationTo.reduce((values, relation) => ({
...values,
[formatName(relation)]: {
value: relation,
},
}), {}),
}),
return {
type: new GraphQLInputObjectType({
name: `${combineParentName(parentName, field.name)}_Relation`,
fields: {
relationTo: {
type: new GraphQLEnumType({
name: `${combineParentName(parentName, field.name)}_Relation_RelationTo`,
values: field.relationTo.reduce((values, relation) => ({
...values,
[formatName(relation)]: {
value: relation,
},
}), {}),
}),
},
value: { type: GraphQLString },
},
value: { type: GraphQLString },
},
});
}),
};
}
return { type };
return {
type: withOperators(
field,
parentName,
),
};
},
upload: (field: UploadField) => ({
type: withOperators(
field,
GraphQLString,
parentName,
[...operators.equality],
),
}),
checkbox: (field: CheckboxField) => ({
type: withOperators(
field,
GraphQLBoolean,
parentName,
[...operators.equality],
),
}),
select: (field: SelectField) => ({
type: withOperators(
field,
new GraphQLEnumType({
name: `${combineParentName(parentName, field.name)}_Input`,
values: field.options.reduce((values, option) => {
if (typeof option === 'object' && option.value) {
return {
...values,
[formatName(option.value)]: {
value: option.value,
},
};
}
if (typeof option === 'string') {
return {
...values,
[option]: {
value: option,
},
};
}
return values;
}, {}),
}),
parentName,
[...operators.equality, ...operators.contains],
),
}),
array: (field: ArrayField) => recursivelyBuildNestedPaths(parentName, field),

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