Compare commits
65 Commits
v1.8.3-can
...
v1.8.4-can
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51108c02ea | ||
|
|
69b97bbc59 | ||
|
|
f2399bc05a | ||
|
|
93a85dd937 | ||
|
|
8ee9724277 | ||
|
|
7c446ec71a | ||
|
|
f2451d03c1 | ||
|
|
0986282f13 | ||
|
|
d3638bcb24 | ||
|
|
f386d1caad | ||
|
|
480c7b3e21 | ||
|
|
908d5747a8 | ||
|
|
9ec2a40274 | ||
|
|
a080a6294c | ||
|
|
9be854a1a4 | ||
|
|
c76dc77e64 | ||
|
|
a42f17ca41 | ||
|
|
ed136fbc51 | ||
|
|
e3ff4c46cb | ||
|
|
6125b66286 | ||
|
|
8285bac2f5 | ||
|
|
61bb0fae53 | ||
|
|
47b9af970b | ||
|
|
731c85337b | ||
|
|
4b59fda56f | ||
|
|
2361221198 | ||
|
|
d931ba9b50 | ||
|
|
51fd1db4eb | ||
|
|
dbd4dd215a | ||
|
|
c716954e89 | ||
|
|
5be247da0a | ||
|
|
b47e84369c | ||
|
|
fe7ddf3e0f | ||
|
|
2fc9288870 | ||
|
|
f9de807daa | ||
|
|
e85ce4eaf2 | ||
|
|
5fc36333b9 | ||
|
|
2809cb910c | ||
|
|
782f8ca047 | ||
|
|
8bdbd6b073 | ||
|
|
7fbd5adaa2 | ||
|
|
324ca171a3 | ||
|
|
bbf114b822 | ||
|
|
a2a8ac9549 | ||
|
|
ae384306eb | ||
|
|
9c4e003315 | ||
|
|
9bb5470342 | ||
|
|
2f209e3e9b | ||
|
|
314ddbd44c | ||
|
|
3b78ab04c7 | ||
|
|
bb21f51f74 | ||
|
|
666c2383ba | ||
|
|
2703853edb | ||
|
|
f728fca036 | ||
|
|
368103d76d | ||
|
|
3a2462baba | ||
|
|
bd2bfbbb93 | ||
|
|
1300fc864c | ||
|
|
ef2d17922b | ||
|
|
b63dd40512 | ||
|
|
bc41f81303 | ||
|
|
2697974694 | ||
|
|
095ccf7194 | ||
|
|
5f620a2325 | ||
|
|
01d1f43d45 |
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"tag": false
|
||||
},
|
||||
"github": {
|
||||
"release": false
|
||||
"release": true
|
||||
},
|
||||
"npm": {
|
||||
"skipChecks": true,
|
||||
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -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)
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<img src="https://img.shields.io/github/commit-activity/m/payloadcms/payload?style=flat-square" alt="git commit activity"/>
|
||||
</a>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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._
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
4
examples/email/.env.example
Normal file
4
examples/email/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
MONGODB_URI=mongodb://localhost/payload-example-email
|
||||
PAYLOAD_SECRET=
|
||||
NODE_ENV=development
|
||||
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:8000
|
||||
7
examples/email/.eslintrc.js
Normal file
7
examples/email/.eslintrc.js
Normal 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
5
examples/email/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
build
|
||||
dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
.env
|
||||
63
examples/email/README.md
Normal file
63
examples/email/README.md
Normal 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).
|
||||
4
examples/email/nodemon.json
Normal file
4
examples/email/nodemon.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ext": "ts",
|
||||
"exec": "ts-node src/server.ts"
|
||||
}
|
||||
35
examples/email/package.json
Normal file
35
examples/email/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
39
examples/email/src/collections/Newsletter.ts
Normal file
39
examples/email/src/collections/Newsletter.ts
Normal 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
|
||||
29
examples/email/src/collections/Users.ts
Normal file
29
examples/email/src/collections/Users.ts
Normal 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
|
||||
24
examples/email/src/email/generateEmailHTML.ts
Normal file
24
examples/email/src/email/generateEmailHTML.ts
Normal 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
|
||||
14
examples/email/src/email/generateForgotPasswordEmail.ts
Normal file
14
examples/email/src/email/generateForgotPasswordEmail.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import generateEmailHTML from './generateEmailHTML'
|
||||
|
||||
const generateForgotPasswordEmail = async ({ token }): Promise<string> =>
|
||||
generateEmailHTML({
|
||||
headline: 'Locked out?',
|
||||
content:
|
||||
'<p>Let'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
|
||||
16
examples/email/src/email/generateVerificationEmail.ts
Normal file
16
examples/email/src/email/generateVerificationEmail.ts
Normal 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
|
||||
317
examples/email/src/email/template.html
Normal file
317
examples/email/src/email/template.html
Normal 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"> </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"> </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>
|
||||
20
examples/email/src/email/transport.ts
Normal file
20
examples/email/src/email/transport.ts
Normal 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
|
||||
1
examples/email/src/emptyModule.js
Normal file
1
examples/email/src/emptyModule.js
Normal file
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
35
examples/email/src/payload-types.ts
Normal file
35
examples/email/src/payload-types.ts
Normal 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;
|
||||
}
|
||||
46
examples/email/src/payload.config.ts
Normal file
46
examples/email/src/payload.config.ts
Normal 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'),
|
||||
},
|
||||
})
|
||||
30
examples/email/src/server.ts
Normal file
30
examples/email/src/server.ts
Normal 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()
|
||||
38
examples/email/tsconfig.json
Normal file
38
examples/email/tsconfig.json
Normal 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
6919
examples/email/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
5
examples/multi-tenant/.env.example
Normal file
5
examples/multi-tenant/.env.example
Normal 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
|
||||
4
examples/multi-tenant/.eslintrc.js
Normal file
4
examples/multi-tenant/.eslintrc.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['@payloadcms'],
|
||||
}
|
||||
5
examples/multi-tenant/.gitignore
vendored
Normal file
5
examples/multi-tenant/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
build
|
||||
dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
.env
|
||||
1
examples/multi-tenant/.npmrc
Normal file
1
examples/multi-tenant/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
8
examples/multi-tenant/.prettierrc.js
Normal file
8
examples/multi-tenant/.prettierrc.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
parser: "typescript",
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: "all",
|
||||
arrowParens: "avoid",
|
||||
};
|
||||
132
examples/multi-tenant/README.md
Normal file
132
examples/multi-tenant/README.md
Normal 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).
|
||||
4
examples/multi-tenant/nodemon.json
Normal file
4
examples/multi-tenant/nodemon.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ext": "ts",
|
||||
"exec": "ts-node src/server.ts"
|
||||
}
|
||||
46
examples/multi-tenant/package.json
Normal file
46
examples/multi-tenant/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { Access } from 'payload/types'
|
||||
|
||||
export const lastLoggedInTenant: Access = ({ req: { user }, data }) =>
|
||||
user?.lastLoggedInTenant?.id === data?.id
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Access } from 'payload/config'
|
||||
|
||||
export const loggedIn: Access = ({ req: { user } }) => {
|
||||
return Boolean(user)
|
||||
}
|
||||
@@ -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) || [],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
43
examples/multi-tenant/src/collections/Pages/index.ts
Normal file
43
examples/multi-tenant/src/collections/Pages/index.ts
Normal 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(),
|
||||
],
|
||||
}
|
||||
@@ -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) || [],
|
||||
},
|
||||
}
|
||||
}
|
||||
36
examples/multi-tenant/src/collections/Tenants/index.ts
Normal file
36
examples/multi-tenant/src/collections/Tenants/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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) || [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
107
examples/multi-tenant/src/collections/Users/index.ts
Normal file
107
examples/multi-tenant/src/collections/Users/index.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
3
examples/multi-tenant/src/collections/access/anyone.ts
Normal file
3
examples/multi-tenant/src/collections/access/anyone.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Access } from 'payload/config'
|
||||
|
||||
export const anyone: Access = () => true
|
||||
@@ -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)
|
||||
145
examples/multi-tenant/src/collections/fields/link.ts
Normal file
145
examples/multi-tenant/src/collections/fields/link.ts
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RichTextLeaf } from 'payload/dist/fields/config/types'
|
||||
|
||||
const defaultLeaves: RichTextLeaf[] = ['bold', 'italic', 'underline']
|
||||
|
||||
export default defaultLeaves
|
||||
@@ -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'),
|
||||
))
|
||||
)
|
||||
}
|
||||
42
examples/multi-tenant/src/collections/fields/tenant/index.ts
Normal file
42
examples/multi-tenant/src/collections/fields/tenant/index.ts
Normal 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
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
32
examples/multi-tenant/src/collections/utilities/deepMerge.ts
Normal file
32
examples/multi-tenant/src/collections/utilities/deepMerge.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
56
examples/multi-tenant/src/payload-types.ts
Normal file
56
examples/multi-tenant/src/payload-types.ts
Normal 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
|
||||
}
|
||||
19
examples/multi-tenant/src/payload.config.ts
Normal file
19
examples/multi-tenant/src/payload.config.ts
Normal 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'),
|
||||
},
|
||||
})
|
||||
119
examples/multi-tenant/src/seed/index.ts
Normal file
119
examples/multi-tenant/src/seed/index.ts
Normal 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!',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
37
examples/multi-tenant/src/server.ts
Normal file
37
examples/multi-tenant/src/server.ts
Normal 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()
|
||||
34
examples/multi-tenant/tsconfig.json
Normal file
34
examples/multi-tenant/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
6847
examples/multi-tenant/yarn.lock
Normal file
6847
examples/multi-tenant/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
1706
schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ function initCollectionsGraphQL(payload: Payload): void {
|
||||
|
||||
collection.graphQL.paginatedType = buildPaginatedListType(
|
||||
pluralName,
|
||||
collection.graphQL.type
|
||||
collection.graphQL.type,
|
||||
);
|
||||
|
||||
collection.graphQL.whereInputType = buildWhereInputType(
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ export const code = baseField.keys({
|
||||
),
|
||||
admin: baseAdminFields.keys({
|
||||
language: joi.string(),
|
||||
editorOptions: joi.object().unknown(), // Editor['options'] @monaco-editor/react
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -389,8 +389,6 @@ function buildObjectType({
|
||||
}
|
||||
|
||||
if (id) {
|
||||
id = id.toString();
|
||||
|
||||
const relatedDocument = await context.req.payloadDataLoader.load(JSON.stringify([
|
||||
relatedCollectionSlug,
|
||||
id,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user