feat: add database transaction support(#2983)

This commit is contained in:
Dan Ribbens
2023-07-21 14:13:18 -04:00
committed by GitHub
parent c8c22dad06
commit 8a681450d1
495 changed files with 22228 additions and 8512 deletions

View File

@@ -1,5 +1,35 @@
## [1.11.3](https://github.com/payloadcms/payload/compare/v1.11.2...v1.11.3) (2023-07-19)
### Bug Fixes
* adds backdrop blur to button ([#3006](https://github.com/payloadcms/payload/issues/3006)) ([4233426](https://github.com/payloadcms/payload/commit/42334263bbc6219be92c5728f1a4ac6c8d2d1306))
* rich text link element not validating on create ([#3014](https://github.com/payloadcms/payload/issues/3014)) ([60fca40](https://github.com/payloadcms/payload/commit/60fca40780d4ddd8e684a455de55c566ec91e223))
### Features
* auto-login in config capability ([#3009](https://github.com/payloadcms/payload/issues/3009)) ([733fc0b](https://github.com/payloadcms/payload/commit/733fc0b2d0cf0f2d58c8a28e84776f883774b0e0))
* returns queried user alongside refreshed token ([#2813](https://github.com/payloadcms/payload/issues/2813)) ([2fc03f1](https://github.com/payloadcms/payload/commit/2fc03f196e4e5fa0ad3369ec976c0b6889ebda88))
* support logger destination ([#2896](https://github.com/payloadcms/payload/issues/2896)) ([cd0bf68](https://github.com/payloadcms/payload/commit/cd0bf68a6150b1adbdb9ee318ac0a06c4476aa4d))
## [1.11.2](https://github.com/payloadcms/payload/compare/v1.11.1...v1.11.2) (2023-07-14)
### Features
* adds array, collapsible, tab and group error states ([4925f90](https://github.com/payloadcms/payload/commit/4925f90b5f5c8fb8092bf4e8d88d5e0c1846b094))
## [1.11.1](https://github.com/payloadcms/payload/compare/v1.11.0...v1.11.1) (2023-07-11)
### Bug Fixes
* [#2980](https://github.com/payloadcms/payload/issues/2980), locale=all was not iterating through arrays / blocks ([d6bfba7](https://github.com/payloadcms/payload/commit/d6bfba72a6b1a84bc5bb9dd14c7ce31d7afcbc1c))
* anchor Button component respect margins ([#2648](https://github.com/payloadcms/payload/issues/2648)) ([1877d22](https://github.com/payloadcms/payload/commit/1877d2247c89ca5c8e1f0e1f989154d54768fed8))
# [1.11.0](https://github.com/payloadcms/payload/compare/v1.10.5...v1.11.0) (2023-07-05)

View File

@@ -49,6 +49,8 @@ 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.
By default, it will automatically log you in with the default credentials. To disable that, you can either pass in the --no-auto-login flag (example: `yarn dev my-test-dir --no-auto-login`) or set the `PAYLOAD_PUBLIC_DISABLE_AUTO_LOGIN` environment variable to `false`.
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`

1
database/index.ts Normal file
View File

@@ -0,0 +1 @@
// export { baseDatabaseAdapter } from '../dist/database/baseAdapter';

View File

@@ -84,13 +84,9 @@ You can override components on a Collection-by-Collection basis via each Collect
| **`edit.PublishButton`** | Replace the default `Publish` button with a custom component. Drafts must be enabled. |
| **`edit.PreviewButton`** | Replace the default `Preview` button with a custom component. |
| **`BeforeList`** | Array of components to inject _before_ the built-in List view |
| |
| **`BeforeListTable`** | Array of components to inject _before_ the built-in List view's table |
| |
| **`AfterListTable`** | Array of components to inject _after_ the built-in List view's table |
| |
| **`AfterList`** | Array of components to inject _after_ the built-in List view |
| |
#### Examples

View File

@@ -25,7 +25,7 @@ _Screenshot of the Admin panel while editing a document from an example `AllFiel
All options for the Admin panel are defined in your base Payload config file.
| Option | Description |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --- |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `user` | The `slug` of a Collection that you want be used to log in to the Admin dashboard. [More](/docs/admin/overview#the-admin-user-collection) |
| `buildPath` | Specify an absolute path for where to store the built Admin panel bundle used in production. Defaults to `path.resolve(process.cwd(), 'build')`. |
| `meta` | Base meta data to use for the Admin panel. Included properties are `titleSuffix`, `ogImage`, and `favicon`. |
@@ -36,7 +36,7 @@ All options for the Admin panel are defined in your base Payload config file.
| `dateFormat` | Global date format that will be used for all dates in the Admin panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. |
| `avatar` | Set account profile picture. Options: `gravatar`, `default` or a custom React component. |
| `components` | Component overrides that affect the entirety of the Admin panel. [More](/docs/admin/components) |
| `webpack` | Customize the Webpack config that's used to generate the Admin panel. [More](/docs/admin/webpack) | |
| `webpack` | Customize the Webpack config that's used to generate the Admin panel. [More](/docs/admin/webpack) |
| **`logoutRoute`** | The route for the `logout` page. |
| **`inactivityRoute`** | The route for the `logout` inactivity page. |

View File

@@ -63,6 +63,22 @@ const response = await fetch("http://localhost:3000/api/pages", {
Payload ensures that the same, uniform access control is used across all authentication strategies. This enables you to utilize your existing access control configurations with both API keys and the standard email/password authentication. This consistency can aid in maintaining granular control over your API keys.
#### API Key *Only* Authentication
If you want to use API keys as the only authentication method for a collection, you can disable the default local strategy by setting `disableLocalStrategy` to `true` on the collection's `auth` property. This will disable the ability to authenticate with email and password, and will only allow for authentication via API key.
```ts
import { CollectionConfig } from 'payload/types';
export const Customers: CollectionConfig = {
slug: 'customers',
auth: {
useAPIKey: true,
disableLocalStrategy: true,
}
};
```
### Forgot Password
You can customize how the Forgot Password workflow operates with the following options on the `auth.forgotPassword` property:

View File

@@ -6,6 +6,11 @@ desc: Payload provides highly secure user Authentication out of the box, and you
keywords: authentication, config, configuration, overview, documentation, Content Management System, cms, headless, javascript, node, react, express
---
<YouTube
id="CT4KafeJjTI"
title="Simplified Authentication for Headless CMS: Unlocking Reusability in One Line"
/>
<Banner>
Payload provides for highly secure and customizable user Authentication out of the box, which allows for users to identify themselves to Payload.
</Banner>

View File

@@ -13,22 +13,22 @@ Once you have created a project, you will need to select your plan. This will de
<Banner type="success">
Note: All Payload Cloud teams that deploy a project require a card on file.
This helps us prevent fraud and abuse on our platform. If you select a plan
with a free trial, You will not be charged until your trial period is over.
with a free trial, you will not be charged until your trial period is over.
Well remind you 7 days before your trial ends and you can cancel anytime.
</Banner>
### Project Details
| Option | Description |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Region** | Select the region closest to your audience. This will ensure the fastest communication between your data and your client. |
| **Project Name** | A name for your project. You can change this at any time. |
| **Project Slug** | Choose a unique slug to identify your project. This needs to be unique for your team and you can change it any time. |
| **Team** | Select the team you want to create the project under. If this is your first project, a personal team will be created for your automatically. You can modify your team settings and invite new members at any time from the Team Settings page. |
| Option | Description |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Region** | Select the region closest to your audience. This will ensure the fastest communication between your data and your client. |
| **Project Name** | A name for your project. You can change this at any time. |
| **Project Slug** | Choose a unique slug to identify your project. This needs to be unique for your team and you can change it any time. |
| **Team** | Select the team you want to create the project under. If this is your first project, a personal team will be created for you automatically. You can modify your team settings and invite new members at any time from the Team Settings page. |
### Build Settings
If you are deploying a new project from a template, the following settings will all be automatically configured for you. If you are using your own repository, you will need to make sure your build settings are accurate for your project to deploy correctly.
If you are deploying a new project from a template, the following settings will be automatically configured for you. If you are using your own repository, you need to make sure your build settings are accurate for your project to deploy correctly.
| Option | Description |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -56,7 +56,7 @@ Payment methods can be set per project and can be updated any time. You can use
<Banner type="success">
<strong>Note:</strong> All Payload Cloud teams that deploy a project require a
card on file. This helps us prevent fraud and abuse on our platform. If you
select a plan with a free trial, You will not be charged until your trial
select a plan with a free trial, you will not be charged until your trial
period is over. Well remind you 7 days before your trial ends and you can
cancel anytime.
</Banner>

View File

@@ -6,42 +6,42 @@ desc: Get started with Payload Cloud, a deployment solution specifically designe
keywords: cloud, hosted, database, storage, email, deployment, serverless, node, mongodb, s3, aws, cloudflare, atlas, resend, payload, cms
---
A deployment solution specifically designed for Node + MongoDB applications, offering seamless deployment of your entire stack in one place. You can get started in minutes with a one-click template, or bring your own codebase with you.
A deployment solution specifically designed for Node.js + MongoDB applications, offering seamless deployment of your entire stack in one place. You can get started in minutes with a one-click template or bring your own codebase with you.
Payload Cloud offers different plans tailored to meet your specific needs, including a MongoDB Atlas database, S3 file storage, and email delivery powered by Resend. To see the full breakdown of features and plans, see our [Cloud Pricing page](https://payloadcms.com/cloud-pricing).
Payload Cloud offers various plans tailored to meet your specific needs, including a MongoDB Atlas database, S3 file storage, and email delivery powered by [Resend](https://resend.com). To see a full breakdown of features and plans, see our [Cloud Pricing page](https://payloadcms.com/cloud-pricing).
To get started, you will first need to create an account. Head over to [the login screen](https://payloadcms.com/login) and **Register for Free**.
To get started, you first need to create an account. Head over to [the login screen](https://payloadcms.com/login) and **Register for Free**.
<Banner type="success">
To create your first project, you can either select [a
template](#starting-from-a-template), or [import an existing
template](#starting-from-a-template) or [import an existing
project](#importing-from-an-existing-codebase) from GitHub.
</Banner>
## Starting from a Template
Templates come preconfigured and provide a one-click, simple solution to quickly deploy a new application.
Templates come preconfigured and provide a one-click solution to quickly deploy a new application.
![Screen for creating a new project from a template](https://payloadcms.com/images/docs/cloud/create-from-template.jpg)
_Creating a new project from a template._
After creating an account, select your desired template from the Projects page. At this point, you will need to connect to Authorize the Payload Cloud application with your GitHub account. Click Continue with GitHub and follow the prompts to authorize the app.
After creating an account, select your desired template from the Projects page. At this point, you need to connect to authorize the Payload Cloud application with your GitHub account. Click Continue with GitHub and follow the prompts to authorize the app.
Next, select your `GitHub Scope`. If you belong to multiple organizations they will show up here. If do not see the organization you are looking for, you may need to adjust your GitHub app permissions.
Next, select your `GitHub Scope`. If you belong to multiple organizations, they will show up here. If you do not see the organization you are looking for, you may need to adjust your GitHub app permissions.
After selecting your scope, create a unique `repository name` and select whether you want your repository to be public or private on GitHub.
<Banner type="warning">
<strong>Note:</strong> Public repositories can be accessed by anyone online,
while private repositories only grant access to you and anyone you explicitly
while private repositories grant access only to you and anyone you explicitly
authorize.
</Banner>
Once you are ready, click **Create Project**. This will clone the selected template to a new respository in your GitHub account, and take you to the configuration page to set up your project for deployment.
Once you are ready, click **Create Project**. This will clone the selected template to a new repository in your GitHub account, and take you to the configuration page to set up your project for deployment.
## Importing from an Existing Codebase
Payload Cloud works for any Node + MongoDB app. From the New Project page, select **import an existing Git codebase**. Choose the organization and select the repository you want to import. From here, you will be taken to the configuration page to set up your project for deployment.
Payload Cloud works for any Node.js + MongoDB app. From the New Project page, select **import an existing Git codebase**. Choose the organization and select the repository you want to import. From here, you will be taken to the configuration page to set up your project for deployment.
![Screen for creating a new project from an existing repository](https://payloadcms.com/images/docs/cloud/create-from-existing.jpg)
_Creating a new project from an existing repository._

View File

@@ -10,7 +10,7 @@ keywords: cloud, payload cloud, projects, project, overview, database, file stor
<Banner>
The overview tab shows your most recent deployment, along with build and
deploy logs. From here, you can see your live URL, deployment details like
deployment logs. From here, you can see your live URL, deployment details like
timestamps and commit hash, as well as the status of your deployment. You can
also trigger a redeployment manually, which will rebuild your project using
the current configuration.
@@ -21,7 +21,7 @@ _A screenshot of the Overview page for a Cloud project._
### Database
Your Payload Cloud project comes with a MongoDB serverless Atlas DB instance or Dedicated Atlas cluster, depending on your plan. To interact with your cloud database, you will be provided with a MongoDB connection string. This can be found under the **Database** tab of your project.
Your Payload Cloud project comes with a MongoDB serverless Atlas DB instance or a Dedicated Atlas cluster, depending on your plan. To interact with your cloud database, you will be provided with a MongoDB connection string. This can be found under the **Database** tab of your project.
`mongodb+srv://your_connection_string`
@@ -49,14 +49,14 @@ From the Environment Variables page of the Settings tab, you can add, update and
With Payload Cloud, you can add custom domain names to your project. To do so, first go to the Domains page of the Settings tab of your project. Here you can see your default domain. To add a new domain, type in the domain name you wish to use.
<Banner>
Note: do not include protocol (http:// or https://) or any routes (/page).
Only include the the domain name and extension, and optionally a subdomain. -
Note: do not include the protocol (http:// or https://) or any routes (/page).
Only include the domain name and extension, and optionally a subdomain. -
your-domain.com - backend.your-domain.com
</Banner>
Once you click save, a DNS record will be generated for your domain name to point to your live project. Add this record into your DNS providers records, and once the records are resolving properly (this can take 1hr to 48hrs in some cases), your domain will now to point to your live project.
You will also need to configure your Payload project to use your specified domain. in your `payload.config.ts` file, specify your serverURL with your domain:
You will also need to configure your Payload project to use your specified domain. In your `payload.config.ts` file, specify your `serverURL` with your domain:
```ts
export default buildConfig({
@@ -67,9 +67,9 @@ export default buildConfig({
### Email
Powered by Resend, Payload Cloud comes with integrated email support out of the box. No configuration is needed, and you can use `payload.sendEmail()` to send email right from your Payload app. To learn more about sending email with Payload, checkout the [Email Configuration](https://payloadcms.com/docs/email/overview) overview.
Powered by [Resend](https://resend.com), Payload Cloud comes with integrated email support out of the box. No configuration is needed, and you can use `payload.sendEmail()` to send email right from your Payload app. To learn more about sending email with Payload, checkout the [Email Configuration](https://payloadcms.com/docs/email/overview) overview.
If you are on the Pro or Enterprise plan, you can add your own custom Email domain name. From the Email page of your projects Settings page, add the domain you wish to use for email delivery. This will generate a set of DNS records. Add these records to your DNS provider and click verify to check that your records are resolving properly. Once verified, your emails will now be sent from your custom domain name.
If you are on the Pro or Enterprise plan, you can add your own custom Email domain name. From the Email page of your projects Settings, add the domain you wish to use for email delivery. This will generate a set of DNS records. Add these records to your DNS provider and click verify to check that your records are resolving properly. Once verified, your emails will now be sent from your custom domain name.
### Developing Locally

View File

@@ -16,11 +16,11 @@ _A screenshot of the Team Settings page._
### Members
Each team has members that can interact with your projects. You can invite multiple people to your team and each individual can belong to more than one team. You can delegate whether they have `owner` or `user` permissions. Owners are able to make admin-only changes, such as deleting projects, and editing billing information.
Each team has members that can interact with your projects. You can invite multiple people to your team and each individual can belong to more than one team. You can assign them either `owner` or `user` permissions. Owners are able to make admin-only changes, such as deleting projects, and editing billing information.
### Adding Members
To add a new member to your team, visit your Teams Settings page, and click “Invite Teammate”. You can then add their email address, and assign their role. Press “Save” to send invitations, which will send an email to the invited team member where they can create a new account.
To add a new member to your team, visit your Teams Settings page, and click “Invite Teammate”. You can then add their email address, and assign their role. Press “Save” to send the invitations, which will send an email to the invited team member where they can create a new account.
### Billing

View File

@@ -140,6 +140,10 @@ A boolean that when set to `true` tells Payload to start in local-only mode whic
Specify options for the built-in Pino logger that Payload uses for internal logging. See [Pino Docs](https://getpino.io/#/docs/api?id=options) for more info on what is available.
##### `loggerDestination`
Specify destination stream for the built-in Pino logger that Payload uses for internal logging. See [Pino Docs](https://getpino.io/#/docs/api?id=pino-destination) for more info on what is available.
##### `onInit`
A function that is called immediately following startup that receives the Payload instance as it's only argument.

View File

@@ -1,7 +1,7 @@
PAYLOAD_PUBLIC_SITE_URL=http://localhost:3000
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:8000
MONGODB_URI=mongodb://localhost/payload-example-auth
PAYLOAD_PUBLIC_SITE_URL=http://localhost:3001
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
MONGODB_URI=mongodb://127.0.0.1/payload-example-auth
PAYLOAD_SECRET=PAYLOAD_AUTH_EXAMPLE_SECRET_KEY
COOKIE_DOMAIN=localhost
PAYLOAD_SEED=true
PAYLOAD_DROP_DATABASE=true

View File

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

View File

@@ -1,8 +1,11 @@
# Payload Auth Example
This example demonstrates how to implement [Payload Authentication](https://payloadcms.com/docs/authentication/overview).
This example demonstrates how to implement [Payload Authentication](https://payloadcms.com/docs/authentication/overview). Follow the [Quick Start](#quick-start) to get up and running quickly. There are various fully working front-ends made explicitly for this example, including:
There is a fully working Next.js app made explicitly for this example which can be found [here](../nextjs). Follow the instructions there to get started. If you are setting up authentication for another front-end, please consider contributing to this repo with your own example!
- [Next.js App Router](../next-app)
- [Next.js Pages Router](../next-pages)
Follow the instructions in each respective README to get started. If you are setting up authentication for another front-end, please consider contributing to this repo with your own example!
## Quick Start
@@ -11,8 +14,8 @@ 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:8000/admin` to access the admin panel
1. Login with email `dev@payloadcms.com` and password `test`
1. Now `open http://localhost:3000/admin` to access the admin panel
1. Login with email `demo@payloadcms.com` and password `demo`
That's it! Changes made in `./src` will be reflected in your app. See the [Development](#development) section for more details.
@@ -46,7 +49,7 @@ To spin up this example locally, follow the [Quick Start](#quick-start).
### Seed
On boot, a seed script is included to create a user with email `dev@payloadcms.com`, password `test`, the role `admin`.
On boot, a seed script is included to create a user with email `demo@payloadcms.com`, password `demo`, the role `admin`.
> 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.

View File

@@ -19,7 +19,7 @@
"dependencies": {
"dotenv": "^8.2.0",
"express": "^4.17.1",
"payload": "^1.7.5"
"payload": "^1.11.0"
},
"devDependencies": {
"@payloadcms/eslint-config": "^0.0.1",

View File

@@ -43,6 +43,7 @@ export const Users: CollectionConfig = {
name: 'roles',
type: 'select',
hasMany: true,
saveToJWT: true,
hooks: {
beforeChange: [protectRoles],
},

View File

@@ -3,13 +3,14 @@ import type { FieldHook } from 'payload/types'
// ensure there is always a `user` role
// do not let non-admins change roles
// eslint-disable-next-line consistent-return
export const protectRoles: FieldHook<User & { id: string }> = async ({ req, data }) => {
const isAdmin = req.user?.roles.includes('admin') || data.email === 'dev@payloadcms.com' // for the seed script
const isAdmin = req.user?.roles.includes('admin') || data.email === 'demo@payloadcms.com' // for the seed script
if (!isAdmin) {
return ['user']
}
return [...(data?.roles || []), 'user']
const userRoles = new Set(data?.roles || [])
userRoles.add('user')
return [...userRoles]
}

View File

@@ -1,4 +1,5 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
@@ -7,21 +8,23 @@
export interface Config {
collections: {
users: User
}
globals: {}
users: User;
};
globals: {};
}
export interface User {
id: string
firstName?: string
lastName?: string
roles?: Array<'admin' | 'user'>
email?: string
resetPasswordToken?: string
resetPasswordExpiration?: string
loginAttempts?: number
lockUntil?: string
createdAt: string
updatedAt: string
password?: string
id: string;
firstName?: string;
lastName?: string;
roles?: ('admin' | 'user')[];
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
salt?: string;
hash?: string;
loginAttempts?: number;
lockUntil?: string;
password?: string;
}

View File

@@ -5,8 +5,14 @@ import { Users } from './collections/Users'
export default buildConfig({
collections: [Users],
cors: [process.env.PAYLOAD_PUBLIC_SERVER_URL, process.env.PAYLOAD_PUBLIC_SITE_URL],
csrf: [process.env.PAYLOAD_PUBLIC_SERVER_URL, process.env.PAYLOAD_PUBLIC_SITE_URL],
cors: [
process.env.PAYLOAD_PUBLIC_SERVER_URL || '',
process.env.PAYLOAD_PUBLIC_SITE_URL || '',
].filter(Boolean),
csrf: [
process.env.PAYLOAD_PUBLIC_SERVER_URL || '',
process.env.PAYLOAD_PUBLIC_SITE_URL || '',
].filter(Boolean),
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
},

View File

@@ -4,8 +4,8 @@ export const seed = async (payload: Payload): Promise<void> => {
await payload.create({
collection: 'users',
data: {
email: 'dev@payloadcms.com',
password: 'test',
email: 'demo@payloadcms.com',
password: 'demo',
roles: ['admin'],
},
})

View File

@@ -30,7 +30,7 @@ const start = async (): Promise<void> => {
await seed(payload)
}
app.listen(8000)
app.listen(3000)
}
start()

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es2019",
"lib": [
"dom",
"dom.iterable",
@@ -14,10 +14,15 @@
"rootDir": "./src",
"jsx": "react",
"sourceMap": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"paths": {
"payload/generated-types": ["./src/payload-types.ts"],
"node_modules/*": ["./node_modules/*"]
"payload/generated-types": [
"./src/payload-types.ts"
],
"node_modules/*": [
"./node_modules/*"
]
},
},
"include": [
@@ -31,4 +36,4 @@
"ts-node": {
"transpileOnly": true
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
NEXT_PUBLIC_CMS_URL=http://localhost:3000

View File

@@ -0,0 +1,7 @@
module.exports = {
root: true,
extends: ['plugin:@next/next/recommended', '@payloadcms'],
rules: {
'import/extensions': 'off',
},
}

View File

@@ -0,0 +1,41 @@
# Payload Auth Example Front-End
This is a [Payload](https://payloadcms.com) + [Next.js](https://nextjs.org) app using the [App Router](https://nextjs.org/docs/app) made explicitly for the [Payload Auth Example](https://github.com/payloadcms/payload/tree/master/examples/auth). It demonstrates how to authenticate your Next.js app using [Payload Authentication](https://payloadcms.com/docs/authentication/overview).
> This example uses the App Router, the latest API of Next.js. If your app is using the legacy [Pages Router](https://nextjs.org/docs/pages), check out the official [Pages Router Example](https://github.com/payloadcms/payload/tree/master/examples/auth/next-pages).
## Getting Started
### Payload
First you'll need a running Payload app. If you have not done so already, clone down the [`cms`](../cms) folder and follow the setup instructions there to get it up and running. This will provide all the necessary APIs that your Next.js app will be using for authentication.
### Next.js
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
5. `open http://localhost:3001` to see the result
Once running, a user is automatically seeded in your local environment with some basic instructions. See the [Payload Auth Example](https://github.com/payloadcms/payload/tree/master/examples/auth) for full details.
## Learn More
To learn more about Payload and Next.js, take a look at the following resources:
- [Payload Documentation](https://payloadcms.com/docs) - learn about Payload features and API.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Payload GitHub repository](https://github.com/payloadcms/payload) as well as [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deployment
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new) from the creators of Next.js. You could also combine this app into a [single Express server](https://github.com/payloadcms/payload/tree/master/examples/custom-server) and deploy in to [Payload Cloud](https://payloadcms.com/new/import).
Check out our [Payload deployment documentation](https://payloadcms.com/docs/production/deployment) or the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
## Questions
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).

View File

@@ -0,0 +1,40 @@
@import '../../_css/type.scss';
.button {
border: none;
cursor: pointer;
display: inline-flex;
justify-content: center;
background-color: transparent;
text-decoration: none;
display: inline-flex;
padding: 12px 24px;
}
.content {
display: flex;
align-items: center;
justify-content: space-around;
}
.label {
@extend %label;
text-align: center;
display: flex;
align-items: center;
}
.appearance--primary {
background-color: var(--theme-elevation-1000);
color: var(--theme-elevation-0);
}
.appearance--secondary {
background-color: transparent;
box-shadow: inset 0 0 0 1px var(--theme-elevation-1000);
}
.appearance--default {
padding: 0;
color: var(--theme-text);
}

View File

@@ -0,0 +1,75 @@
'use client'
import React, { ElementType } from 'react'
import Link from 'next/link'
import classes from './index.module.scss'
export type Props = {
label?: string
appearance?: 'default' | 'primary' | 'secondary'
el?: 'button' | 'link' | 'a'
onClick?: () => void
href?: string
newTab?: boolean
className?: string
type?: 'submit' | 'button'
disabled?: boolean
invert?: boolean
}
export const Button: React.FC<Props> = ({
el: elFromProps = 'link',
label,
newTab,
href,
appearance,
className: classNameFromProps,
onClick,
type = 'button',
disabled,
invert,
}) => {
let el = elFromProps
const newTabProps = newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}
const className = [
classes.button,
classNameFromProps,
classes[`appearance--${appearance}`],
invert && classes[`${appearance}--invert`],
]
.filter(Boolean)
.join(' ')
const content = (
<div className={classes.content}>
<span className={classes.label}>{label}</span>
</div>
)
if (onClick || type === 'submit') el = 'button'
if (el === 'link') {
return (
<Link href={href || ''} className={className} {...newTabProps} onClick={onClick}>
{content}
</Link>
)
}
const Element: ElementType = el
return (
<Element
href={href}
className={className}
type={type}
{...newTabProps}
onClick={onClick}
disabled={disabled}
>
{content}
</Element>
)
}

View File

@@ -0,0 +1,13 @@
.gutter {
max-width: 1920px;
margin-left: auto;
margin-right: auto;
}
.gutterLeft {
padding-left: var(--gutter-h);
}
.gutterRight {
padding-right: var(--gutter-h);
}

View File

@@ -16,7 +16,12 @@ export const Gutter: React.FC<Props> = forwardRef<HTMLDivElement, Props>((props,
return (
<div
ref={ref}
className={[left && classes.gutterLeft, right && classes.gutterRight, className]
className={[
classes.gutter,
left && classes.gutterLeft,
right && classes.gutterRight,
className,
]
.filter(Boolean)
.join(' ')}
>

View File

@@ -0,0 +1,20 @@
@use '../../../_css/queries.scss' as *;
.nav {
display: flex;
gap: calc(var(--base) / 4) var(--base);
align-items: center;
flex-wrap: wrap;
opacity: 1;
transition: opacity 100ms linear;
visibility: visible;
> * {
text-decoration: none;
}
}
.hide {
opacity: 0;
visibility: hidden;
}

View File

@@ -0,0 +1,38 @@
'use client'
import React from 'react'
import Link from 'next/link'
import { useAuth } from '../../../_providers/Auth'
import classes from './index.module.scss'
export const HeaderNav: React.FC = () => {
const { user } = useAuth()
return (
<nav
className={[
classes.nav,
// fade the nav in on user load to avoid flash of content and layout shift
// Vercel also does this in their own website header, see https://vercel.com
user === undefined && classes.hide,
]
.filter(Boolean)
.join(' ')}
>
{user && (
<React.Fragment>
<Link href="/account">Account</Link>
<Link href="/logout">Logout</Link>
</React.Fragment>
)}
{!user && (
<React.Fragment>
<Link href="/login">Login</Link>
<Link href="/create-account">Create Account</Link>
</React.Fragment>
)}
</nav>
)
}

View File

@@ -0,0 +1,22 @@
@use '../../_css/queries.scss' as *;
.header {
padding: var(--base) 0;
}
.wrap {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: calc(var(--base) / 2) var(--base);
}
.logo {
width: 150px;
}
:global([data-theme="light"]) {
.logo {
filter: invert(1);
}
}

View File

@@ -0,0 +1,34 @@
import React from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { Gutter } from '../Gutter'
import { HeaderNav } from './Nav'
import classes from './index.module.scss'
export function Header() {
return (
<header className={classes.header}>
<Gutter className={classes.wrap}>
<Link href="/" className={classes.logo}>
<picture>
<source
srcSet="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-light.svg"
media="(prefers-color-scheme: dark)"
/>
<Image
width={150}
height={30}
alt="Payload Logo"
src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-dark.svg"
/>
</picture>
</Link>
<HeaderNav />
</Gutter>
</header>
)
}
export default Header

View File

@@ -0,0 +1,56 @@
@import '../../_css/common';
.inputWrap {
width: 100%;
}
.input {
width: 100%;
font-family: system-ui;
border-radius: 0;
box-shadow: none;
border: none;
background: none;
background-color: var(--theme-elevation-100);
color: var(--theme-elevation-1000);
height: calc(var(--base) * 2);
line-height: calc(var(--base) * 2);
padding: 0 calc(var(--base) / 2);
&:focus {
border: none;
outline: none;
}
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
-webkit-text-fill-color: var(--theme-text);
-webkit-box-shadow: 0 0 0px 1000px var(--theme-elevation-150) inset;
transition: background-color 5000s ease-in-out 0s;
}
}
@media (prefers-color-scheme: dark) {
.input {
background-color: var(--theme-elevation-150);
}
}
.error {
background-color: var(--theme-error-150);
}
.label {
margin-bottom: 0;
display: block;
line-height: 1;
margin-bottom: calc(var(--base) / 2);
}
.errorMessage {
font-size: small;
line-height: 1.25;
margin-top: 4px;
color: red;
}

View File

@@ -0,0 +1,55 @@
import React from 'react'
import { FieldValues, UseFormRegister, Validate } from 'react-hook-form'
import classes from './index.module.scss'
type Props = {
name: string
label: string
register: UseFormRegister<FieldValues & any>
required?: boolean
error: any
type?: 'text' | 'number' | 'password' | 'email'
validate?: (value: string) => boolean | string
}
export const Input: React.FC<Props> = ({
name,
label,
required,
register,
error,
type = 'text',
validate,
}) => {
return (
<div className={classes.inputWrap}>
<label htmlFor="name" className={classes.label}>
{`${label} ${required ? '*' : ''}`}
</label>
<input
className={[classes.input, error && classes.error].filter(Boolean).join(' ')}
{...{ type }}
{...register(name, {
required,
validate,
...(type === 'email'
? {
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Please enter a valid email',
},
}
: {}),
})}
/>
{error && (
<div className={classes.errorMessage}>
{!error?.message && error?.type === 'required'
? 'This field is required'
: error?.message}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,46 @@
@import '../../_css/common';
.message {
padding: calc(var(--base) / 2) calc(var(--base) / 2);
line-height: 1.25;
width: 100%;
}
.default {
background-color: var(--theme-elevation-100);
color: var(--theme-elevation-1000);
}
.warning {
background-color: var(--theme-warning-500);
color: var(--theme-warning-900);
}
.error {
background-color: var(--theme-error-500);
color: var(--theme-error-900);
}
.success {
background-color: var(--theme-success-500);
color: var(--theme-success-900);
}
@media (prefers-color-scheme: dark) {
.default {
background-color: var(--theme-elevation-900);
color: var(--theme-elevation-100);
}
.warning {
color: var(--theme-warning-100);
}
.error {
color: var(--theme-error-100);
}
.success {
color: var(--theme-success-100);
}
}

View File

@@ -0,0 +1,33 @@
import React from 'react'
import classes from './index.module.scss'
export const Message: React.FC<{
message?: React.ReactNode
error?: React.ReactNode
success?: React.ReactNode
warning?: React.ReactNode
className?: string
}> = ({ message, error, success, warning, className }) => {
const messageToRender = message || error || success || warning
if (messageToRender) {
return (
<div
className={[
classes.message,
className,
error && classes.error,
success && classes.success,
warning && classes.warning,
!error && !success && !warning && classes.default,
]
.filter(Boolean)
.join(' ')}
>
{messageToRender}
</div>
)
}
return null
}

View File

@@ -0,0 +1,29 @@
'use client'
import { useSearchParams } from 'next/navigation'
import { Message } from '../Message'
export const RenderParams: React.FC<{
params?: string[]
message?: string
className?: string
}> = ({ params = ['error', 'message', 'success'], message, className }) => {
const searchParams = useSearchParams()
const paramValues = params.map(param => searchParams.get(param)).filter(Boolean)
if (paramValues.length) {
return (
<div className={className}>
{paramValues.map(paramValue => (
<Message
key={paramValue}
message={(message || 'PARAM')?.replace('PARAM', paramValue || '')}
/>
))}
</div>
)
}
return null
}

View File

@@ -2,4 +2,8 @@
:first-child {
margin-top: 0;
}
a {
text-decoration: underline;
}
}

View File

@@ -0,0 +1,92 @@
import React, { Fragment } from 'react'
import escapeHTML from 'escape-html'
import { Text } from 'slate'
// eslint-disable-next-line no-use-before-define
type Children = Leaf[]
type Leaf = {
type: string
value?: {
url: string
alt: string
}
children: Children
url?: string
[key: string]: unknown
}
const serialize = (children: Children): React.ReactNode[] =>
children.map((node, i) => {
if (Text.isText(node)) {
let text = <span dangerouslySetInnerHTML={{ __html: escapeHTML(node.text) }} />
if (node.bold) {
text = <strong key={i}>{text}</strong>
}
if (node.code) {
text = <code key={i}>{text}</code>
}
if (node.italic) {
text = <em key={i}>{text}</em>
}
if (node.underline) {
text = (
<span style={{ textDecoration: 'underline' }} key={i}>
{text}
</span>
)
}
if (node.strikethrough) {
text = (
<span style={{ textDecoration: 'line-through' }} key={i}>
{text}
</span>
)
}
return <Fragment key={i}>{text}</Fragment>
}
if (!node) {
return null
}
switch (node.type) {
case 'h1':
return <h1 key={i}>{serialize(node.children)}</h1>
case 'h2':
return <h2 key={i}>{serialize(node.children)}</h2>
case 'h3':
return <h3 key={i}>{serialize(node.children)}</h3>
case 'h4':
return <h4 key={i}>{serialize(node.children)}</h4>
case 'h5':
return <h5 key={i}>{serialize(node.children)}</h5>
case 'h6':
return <h6 key={i}>{serialize(node.children)}</h6>
case 'blockquote':
return <blockquote key={i}>{serialize(node.children)}</blockquote>
case 'ul':
return <ul key={i}>{serialize(node.children)}</ul>
case 'ol':
return <ol key={i}>{serialize(node.children)}</ol>
case 'li':
return <li key={i}>{serialize(node.children)}</li>
case 'link':
return (
<a href={escapeHTML(node.url)} key={i}>
{serialize(node.children)}
</a>
)
default:
return <p key={i}>{serialize(node.children)}</p>
}
})
export default serialize

View File

@@ -1,14 +1,9 @@
@use './queries.scss' as *;
@use './colors.scss' as *;
@use './type.scss' as *;
@import "./theme.scss";
:root {
--breakpoint-xs-width : #{$breakpoint-xs-width};
--breakpoint-s-width : #{$breakpoint-s-width};
--breakpoint-m-width : #{$breakpoint-m-width};
--breakpoint-l-width : #{$breakpoint-l-width};
--scrollbar-width: 17px;
--base: 24px;
--font-body: system-ui;
--font-mono: 'Roboto Mono', monospace;
@@ -16,9 +11,6 @@
--gutter-h: 180px;
--block-padding: 120px;
--header-z-index: 100;
--modal-z-index: 90;
@include large-break {
--gutter-h: 144px;
--block-padding: 96px;
@@ -30,17 +22,13 @@
}
}
/////////////////////////////
// GLOBAL STYLES
/////////////////////////////
* {
box-sizing: border-box;
}
html {
@extend %body;
background: var(--color-white);
background: var(--theme-bg);
-webkit-font-smoothing: antialiased;
}
@@ -52,18 +40,18 @@ body,
body {
font-family: var(--font-body);
color: var(--color-black);
margin: 0;
color: var(--theme-text);
}
::selection {
background: var(--color-green);
color: var(--color-black);
background: var(--theme-success-500);
color: var(--color-base-800);
}
::-moz-selection {
background: var(--color-green);
color: var(--color-black);
background: var(--theme-success-500);
color: var(--color-base-800);
}
img {

View File

@@ -0,0 +1,83 @@
:root {
--color-base-0: rgb(255, 255, 255);
--color-base-50: rgb(245, 245, 245);
--color-base-100: rgb(235, 235, 235);
--color-base-150: rgb(221, 221, 221);
--color-base-200: rgb(208, 208, 208);
--color-base-250: rgb(195, 195, 195);
--color-base-300: rgb(181, 181, 181);
--color-base-350: rgb(168, 168, 168);
--color-base-400: rgb(154, 154, 154);
--color-base-450: rgb(141, 141, 141);
--color-base-500: rgb(128, 128, 128);
--color-base-550: rgb(114, 114, 114);
--color-base-600: rgb(101, 101, 101);
--color-base-650: rgb(87, 87, 87);
--color-base-700: rgb(74, 74, 74);
--color-base-750: rgb(60, 60, 60);
--color-base-800: rgb(47, 47, 47);
--color-base-850: rgb(34, 34, 34);
--color-base-900: rgb(20, 20, 20);
--color-base-950: rgb(7, 7, 7);
--color-base-1000: rgb(0, 0, 0);
--color-success-50: rgb(247, 255, 251);
--color-success-100: rgb(240, 255, 247);
--color-success-150: rgb(232, 255, 243);
--color-success-200: rgb(224, 255, 239);
--color-success-250: rgb(217, 255, 235);
--color-success-300: rgb(209, 255, 230);
--color-success-350: rgb(201, 255, 226);
--color-success-400: rgb(193, 255, 222);
--color-success-450: rgb(186, 255, 218);
--color-success-500: rgb(178, 255, 214);
--color-success-550: rgb(160, 230, 193);
--color-success-600: rgb(142, 204, 171);
--color-success-650: rgb(125, 179, 150);
--color-success-700: rgb(107, 153, 128);
--color-success-750: rgb(89, 128, 107);
--color-success-800: rgb(71, 102, 86);
--color-success-850: rgb(53, 77, 64);
--color-success-900: rgb(36, 51, 43);
--color-success-950: rgb(18, 25, 21);
--color-warning-50: rgb(255, 255, 246);
--color-warning-100: rgb(255, 255, 237);
--color-warning-150: rgb(254, 255, 228);
--color-warning-200: rgb(254, 255, 219);
--color-warning-250: rgb(254, 255, 210);
--color-warning-300: rgb(254, 255, 200);
--color-warning-350: rgb(254, 255, 191);
--color-warning-400: rgb(253, 255, 182);
--color-warning-450: rgb(253, 255, 173);
--color-warning-500: rgb(253, 255, 164);
--color-warning-550: rgb(228, 230, 148);
--color-warning-600: rgb(202, 204, 131);
--color-warning-650: rgb(177, 179, 115);
--color-warning-700: rgb(152, 153, 98);
--color-warning-750: rgb(127, 128, 82);
--color-warning-800: rgb(101, 102, 66);
--color-warning-850: rgb(76, 77, 49);
--color-warning-900: rgb(51, 51, 33);
--color-warning-950: rgb(25, 25, 16);
--color-error-50: rgb(255, 241, 241);
--color-error-100: rgb(255, 226, 228);
--color-error-150: rgb(255, 212, 214);
--color-error-200: rgb(255, 197, 200);
--color-error-250: rgb(255, 183, 187);
--color-error-300: rgb(255, 169, 173);
--color-error-350: rgb(255, 154, 159);
--color-error-400: rgb(255, 140, 145);
--color-error-450: rgb(255, 125, 132);
--color-error-500: rgb(255, 111, 118);
--color-error-550: rgb(230, 100, 106);
--color-error-600: rgb(204, 89, 94);
--color-error-650: rgb(179, 78, 83);
--color-error-700: rgb(153, 67, 71);
--color-error-750: rgb(128, 56, 59);
--color-error-800: rgb(102, 44, 47);
--color-error-850: rgb(77, 33, 35);
--color-error-900: rgb(51, 22, 24);
--color-error-950: rgb(25, 11, 12);
}

View File

@@ -1,2 +1 @@
@forward './queries.scss';
@forward './type.scss';

View File

@@ -3,10 +3,6 @@ $breakpoint-s-width: 768px;
$breakpoint-m-width: 1024px;
$breakpoint-l-width: 1440px;
////////////////////////////
// MEDIA QUERIES
/////////////////////////////
@mixin extra-small-break {
@media (max-width: #{$breakpoint-xs-width}) {
@content;
@@ -29,4 +25,4 @@ $breakpoint-l-width: 1440px;
@media (max-width: #{$breakpoint-l-width}) {
@content;
}
}
}

View File

@@ -0,0 +1,241 @@
@media (prefers-color-scheme: light) {
:root {
--theme-success-50: var(--color-success-50);
--theme-success-100: var(--color-success-100);
--theme-success-150: var(--color-success-150);
--theme-success-200: var(--color-success-200);
--theme-success-250: var(--color-success-250);
--theme-success-300: var(--color-success-300);
--theme-success-350: var(--color-success-350);
--theme-success-400: var(--color-success-400);
--theme-success-450: var(--color-success-450);
--theme-success-500: var(--color-success-500);
--theme-success-550: var(--color-success-550);
--theme-success-600: var(--color-success-600);
--theme-success-650: var(--color-success-650);
--theme-success-700: var(--color-success-700);
--theme-success-750: var(--color-success-750);
--theme-success-800: var(--color-success-800);
--theme-success-850: var(--color-success-850);
--theme-success-900: var(--color-success-900);
--theme-success-950: var(--color-success-950);
--theme-warning-50: var(--color-warning-50);
--theme-warning-100: var(--color-warning-100);
--theme-warning-150: var(--color-warning-150);
--theme-warning-200: var(--color-warning-200);
--theme-warning-250: var(--color-warning-250);
--theme-warning-300: var(--color-warning-300);
--theme-warning-350: var(--color-warning-350);
--theme-warning-400: var(--color-warning-400);
--theme-warning-450: var(--color-warning-450);
--theme-warning-500: var(--color-warning-500);
--theme-warning-550: var(--color-warning-550);
--theme-warning-600: var(--color-warning-600);
--theme-warning-650: var(--color-warning-650);
--theme-warning-700: var(--color-warning-700);
--theme-warning-750: var(--color-warning-750);
--theme-warning-800: var(--color-warning-800);
--theme-warning-850: var(--color-warning-850);
--theme-warning-900: var(--color-warning-900);
--theme-warning-950: var(--color-warning-950);
--theme-error-50: var(--color-error-50);
--theme-error-100: var(--color-error-100);
--theme-error-150: var(--color-error-150);
--theme-error-200: var(--color-error-200);
--theme-error-250: var(--color-error-250);
--theme-error-300: var(--color-error-300);
--theme-error-350: var(--color-error-350);
--theme-error-400: var(--color-error-400);
--theme-error-450: var(--color-error-450);
--theme-error-500: var(--color-error-500);
--theme-error-550: var(--color-error-550);
--theme-error-600: var(--color-error-600);
--theme-error-650: var(--color-error-650);
--theme-error-700: var(--color-error-700);
--theme-error-750: var(--color-error-750);
--theme-error-800: var(--color-error-800);
--theme-error-850: var(--color-error-850);
--theme-error-900: var(--color-error-900);
--theme-error-950: var(--color-error-950);
--theme-elevation-0: var(--color-base-0);
--theme-elevation-50: var(--color-base-50);
--theme-elevation-100: var(--color-base-100);
--theme-elevation-150: var(--color-base-150);
--theme-elevation-200: var(--color-base-200);
--theme-elevation-250: var(--color-base-250);
--theme-elevation-300: var(--color-base-300);
--theme-elevation-350: var(--color-base-350);
--theme-elevation-400: var(--color-base-400);
--theme-elevation-450: var(--color-base-450);
--theme-elevation-500: var(--color-base-500);
--theme-elevation-550: var(--color-base-550);
--theme-elevation-600: var(--color-base-600);
--theme-elevation-650: var(--color-base-650);
--theme-elevation-700: var(--color-base-700);
--theme-elevation-750: var(--color-base-750);
--theme-elevation-800: var(--color-base-800);
--theme-elevation-850: var(--color-base-850);
--theme-elevation-900: var(--color-base-900);
--theme-elevation-950: var(--color-base-950);
--theme-elevation-1000: var(--color-base-1000);
--theme-bg: var(--theme-elevation-0);
--theme-input-bg: var(--theme-elevation-50);
--theme-text: var(--theme-elevation-750);
--theme-border-color: var(--theme-elevation-150);
color-scheme: light;
color: var(--theme-text);
--highlight-default-bg-color: var(--theme-success-400);
--highlight-default-text-color: var(--theme-text);
--highlight-danger-bg-color: var(--theme-error-150);
--highlight-danger-text-color: var(--theme-text);
}
h1 a,
h2 a,
h3 a,
h4 a,
h5 a,
h6 a {
color: var(--theme-elevation-750);
&:hover {
color: var(--theme-elevation-800);
}
&:visited {
color: var(--theme-elevation-750);
&:hover {
color: var(--theme-elevation-800);
}
}
}
}
@media (prefers-color-scheme: dark) {
:root {
--theme-elevation-0: var(--color-base-1000);
--theme-elevation-50: var(--color-base-950);
--theme-elevation-100: var(--color-base-900);
--theme-elevation-150: var(--color-base-850);
--theme-elevation-200: var(--color-base-800);
--theme-elevation-250: var(--color-base-750);
--theme-elevation-300: var(--color-base-700);
--theme-elevation-350: var(--color-base-650);
--theme-elevation-400: var(--color-base-600);
--theme-elevation-450: var(--color-base-550);
--theme-elevation-500: var(--color-base-500);
--theme-elevation-550: var(--color-base-450);
--theme-elevation-600: var(--color-base-400);
--theme-elevation-650: var(--color-base-350);
--theme-elevation-700: var(--color-base-300);
--theme-elevation-750: var(--color-base-250);
--theme-elevation-800: var(--color-base-200);
--theme-elevation-850: var(--color-base-150);
--theme-elevation-900: var(--color-base-100);
--theme-elevation-950: var(--color-base-50);
--theme-elevation-1000: var(--color-base-0);
--theme-success-50: var(--color-success-950);
--theme-success-100: var(--color-success-900);
--theme-success-150: var(--color-success-850);
--theme-success-200: var(--color-success-800);
--theme-success-250: var(--color-success-750);
--theme-success-300: var(--color-success-700);
--theme-success-350: var(--color-success-650);
--theme-success-400: var(--color-success-600);
--theme-success-450: var(--color-success-550);
--theme-success-500: var(--color-success-500);
--theme-success-550: var(--color-success-450);
--theme-success-600: var(--color-success-400);
--theme-success-650: var(--color-success-350);
--theme-success-700: var(--color-success-300);
--theme-success-750: var(--color-success-250);
--theme-success-800: var(--color-success-200);
--theme-success-850: var(--color-success-150);
--theme-success-900: var(--color-success-100);
--theme-success-950: var(--color-success-50);
--theme-warning-50: var(--color-warning-950);
--theme-warning-100: var(--color-warning-900);
--theme-warning-150: var(--color-warning-850);
--theme-warning-200: var(--color-warning-800);
--theme-warning-250: var(--color-warning-750);
--theme-warning-300: var(--color-warning-700);
--theme-warning-350: var(--color-warning-650);
--theme-warning-400: var(--color-warning-600);
--theme-warning-450: var(--color-warning-550);
--theme-warning-500: var(--color-warning-500);
--theme-warning-550: var(--color-warning-450);
--theme-warning-600: var(--color-warning-400);
--theme-warning-650: var(--color-warning-350);
--theme-warning-700: var(--color-warning-300);
--theme-warning-750: var(--color-warning-250);
--theme-warning-800: var(--color-warning-200);
--theme-warning-850: var(--color-warning-150);
--theme-warning-900: var(--color-warning-100);
--theme-warning-950: var(--color-warning-50);
--theme-error-50: var(--color-error-950);
--theme-error-100: var(--color-error-900);
--theme-error-150: var(--color-error-850);
--theme-error-200: var(--color-error-800);
--theme-error-250: var(--color-error-750);
--theme-error-300: var(--color-error-700);
--theme-error-350: var(--color-error-650);
--theme-error-400: var(--color-error-600);
--theme-error-450: var(--color-error-550);
--theme-error-500: var(--color-error-500);
--theme-error-550: var(--color-error-450);
--theme-error-600: var(--color-error-400);
--theme-error-650: var(--color-error-350);
--theme-error-700: var(--color-error-300);
--theme-error-750: var(--color-error-250);
--theme-error-800: var(--color-error-200);
--theme-error-850: var(--color-error-150);
--theme-error-900: var(--color-error-100);
--theme-error-950: var(--color-error-50);
--theme-bg: var(--theme-elevation-100);
--theme-text: var(--theme-elevation-900);
--theme-input-bg: var(--theme-elevation-150);
--theme-border-color: var(--theme-elevation-250);
color-scheme: dark;
color: var(--theme-text);
--highlight-default-bg-color: var(--theme-success-100);
--highlight-default-text-color: var(--theme-success-600);
--highlight-danger-bg-color: var(--theme-error-100);
--highlight-danger-text-color: var(--theme-error-550);
}
h1 a,
h2 a,
h3 a,
h4 a,
h5 a,
h6 a {
color: var(--theme-success-600);
&:hover {
color: var(--theme-success-400);
}
&:visited {
color: var(--theme-success-700);
&:hover {
color: var(--theme-success-500);
}
}
}
}

View File

@@ -0,0 +1,110 @@
@use 'queries' as *;
%h1,
%h2,
%h3,
%h4,
%h5,
%h6 {
font-weight: 700;
}
%h1 {
margin: 40px 0;
font-size: 64px;
line-height: 70px;
font-weight: bold;
@include mid-break {
margin: 24px 0;
font-size: 42px;
line-height: 42px;
}
}
%h2 {
margin: 28px 0;
font-size: 48px;
line-height: 54px;
font-weight: bold;
@include mid-break {
margin: 22px 0;
font-size: 32px;
line-height: 40px;
}
}
%h3 {
margin: 24px 0;
font-size: 32px;
line-height: 40px;
font-weight: bold;
@include mid-break {
margin: 20px 0;
font-size: 26px;
line-height: 32px;
}
}
%h4 {
margin: 20px 0;
font-size: 26px;
line-height: 32px;
font-weight: bold;
@include mid-break {
font-size: 22px;
line-height: 30px;
}
}
%h5 {
margin: 20px 0;
font-size: 22px;
line-height: 30px;
font-weight: bold;
@include mid-break {
font-size: 18px;
line-height: 24px;
}
}
%h6 {
margin: 20px 0;
font-size: inherit;
line-height: inherit;
font-weight: bold;
}
%body {
font-size: 18px;
line-height: 32px;
@include mid-break {
font-size: 15px;
line-height: 24px;
}
}
%large-body {
font-size: 25px;
line-height: 32px;
@include mid-break {
font-size: 22px;
line-height: 30px;
}
}
%label {
font-size: 16px;
line-height: 24px;
text-transform: uppercase;
@include mid-break {
font-size: 13px;
}
}

View File

@@ -0,0 +1,34 @@
export const USER = `
id
email
firstName
lastName
`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const gql = async (query: string): Promise<any> => {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/graphql`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
}),
})
const { data, errors } = await res.json()
if (errors) {
throw new Error(errors[0].message)
}
if (res.ok && data) {
return data
}
} catch (e: unknown) {
throw new Error(e as string)
}
}

View File

@@ -0,0 +1,177 @@
'use client'
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { User } from '../../payload-types'
import { gql, USER } from './gql'
import { rest } from './rest'
import { AuthContext, Create, ForgotPassword, Login, Logout, ResetPassword } from './types'
const Context = createContext({} as AuthContext)
export const AuthProvider: React.FC<{ children: React.ReactNode; api?: 'rest' | 'gql' }> = ({
children,
api = 'rest',
}) => {
const [user, setUser] = useState<User | null>()
const create = useCallback<Create>(
async args => {
if (api === 'rest') {
const user = await rest(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users`, args)
setUser(user)
return user
}
if (api === 'gql') {
const { createUser: user } = await gql(`mutation {
createUser(data: { email: "${args.email}", password: "${args.password}", firstName: "${args.firstName}", lastName: "${args.lastName}" }) {
${USER}
}
}`)
setUser(user)
return user
}
},
[api],
)
const login = useCallback<Login>(
async args => {
if (api === 'rest') {
const user = await rest(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/login`, args)
setUser(user)
return user
}
if (api === 'gql') {
const { loginUser } = await gql(`mutation {
loginUser(email: "${args.email}", password: "${args.password}") {
user {
${USER}
}
exp
}
}`)
setUser(loginUser?.user)
return loginUser?.user
}
},
[api],
)
const logout = useCallback<Logout>(async () => {
if (api === 'rest') {
await rest(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/logout`)
setUser(null)
return
}
if (api === 'gql') {
await gql(`mutation {
logoutUser
}`)
setUser(null)
}
}, [api])
// On mount, get user and set
useEffect(() => {
const fetchMe = async () => {
if (api === 'rest') {
const user = await rest(
`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/me`,
{},
{
method: 'GET',
},
)
setUser(user)
}
if (api === 'gql') {
const { meUser } = await gql(`query {
meUser {
user {
${USER}
}
exp
}
}`)
setUser(meUser.user)
}
}
fetchMe()
}, [api])
const forgotPassword = useCallback<ForgotPassword>(
async args => {
if (api === 'rest') {
const user = await rest(
`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/forgot-password`,
args,
)
setUser(user)
return user
}
if (api === 'gql') {
const { forgotPasswordUser } = await gql(`mutation {
forgotPasswordUser(email: "${args.email}")
}`)
return forgotPasswordUser
}
},
[api],
)
const resetPassword = useCallback<ResetPassword>(
async args => {
if (api === 'rest') {
const user = await rest(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/reset-password`, args)
setUser(user)
return user
}
if (api === 'gql') {
const { resetPasswordUser } = await gql(`mutation {
resetPasswordUser(password: "${args.password}", token: "${args.token}") {
user {
${USER}
}
}
}`)
setUser(resetPasswordUser.user)
return resetPasswordUser.user
}
},
[api],
)
return (
<Context.Provider
value={{
user,
setUser,
login,
logout,
create,
resetPassword,
forgotPassword,
}}
>
{children}
</Context.Provider>
)
}
type UseAuth<T = User> = () => AuthContext // eslint-disable-line no-unused-vars
export const useAuth: UseAuth = () => useContext(Context)

View File

@@ -0,0 +1,34 @@
import type { User } from '../../payload-types'
export const rest = async (
url: string,
args?: any, // eslint-disable-line @typescript-eslint/no-explicit-any
options?: RequestInit,
): Promise<User | null | undefined> => {
const method = options?.method || 'POST'
try {
const res = await fetch(url, {
method,
...(method === 'POST' ? { body: JSON.stringify(args) } : {}),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
...options,
})
const { errors, user } = await res.json()
if (errors) {
throw new Error(errors[0].message)
}
if (res.ok) {
return user
}
} catch (e: unknown) {
throw new Error(e as string)
}
}

View File

@@ -0,0 +1,41 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import type { User } from '../payload-types'
export const getMeUser = async (args?: {
nullUserRedirect?: string
validUserRedirect?: string
}): Promise<{
user: User
token: string | undefined
}> => {
const { nullUserRedirect, validUserRedirect } = args || {}
const cookieStore = cookies()
const token = cookieStore.get('payload-token')?.value
const meUserReq = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/me`, {
headers: {
Authorization: `JWT ${token}`,
},
})
const {
user,
}: {
user: User
} = await meUserReq.json()
if (validUserRedirect && meUserReq.ok && user) {
redirect(validUserRedirect)
}
if (nullUserRedirect && (!meUserReq.ok || !user)) {
redirect(nullUserRedirect)
}
return {
user,
token,
}
}

View File

@@ -0,0 +1,24 @@
@import "../../_css/common";
.form {
margin-bottom: var(--base);
display: flex;
flex-direction: column;
gap: calc(var(--base) / 2);
align-items: flex-start;
width: 66.66%;
@include mid-break {
width: 100%;
}
}
.changePassword {
all: unset;
cursor: pointer;
text-decoration: underline;
}
.submit {
margin-top: calc(var(--base) / 2);
}

View File

@@ -0,0 +1,152 @@
'use client'
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useRouter } from 'next/navigation'
import { Button } from '../../_components/Button'
import { Input } from '../../_components/Input'
import { Message } from '../../_components/Message'
import { useAuth } from '../../_providers/Auth'
import classes from './index.module.scss'
type FormData = {
email: string
name: string
password: string
passwordConfirm: string
}
export const AccountForm: React.FC = () => {
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const { user, setUser } = useAuth()
const [changePassword, setChangePassword] = useState(false)
const router = useRouter()
const {
register,
handleSubmit,
formState: { errors, isLoading },
reset,
watch,
} = useForm<FormData>()
const password = useRef({})
password.current = watch('password', '')
const onSubmit = useCallback(
async (data: FormData) => {
if (user) {
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/${user.id}`, {
// Make sure to include cookies with fetch
credentials: 'include',
method: 'PATCH',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) {
const json = await response.json()
setUser(json.doc)
setSuccess('Successfully updated account.')
setError('')
setChangePassword(false)
reset({
email: json.doc.email,
name: json.doc.name,
password: '',
passwordConfirm: '',
})
} else {
setError('There was a problem updating your account.')
}
}
},
[user, setUser, reset],
)
useEffect(() => {
if (user === null) {
router.push(`/login?unauthorized=account`)
}
// Once user is loaded, reset form to have default values
if (user) {
reset({
email: user.email,
password: '',
passwordConfirm: '',
})
}
}, [user, router, reset, changePassword])
return (
<form onSubmit={handleSubmit(onSubmit)} className={classes.form}>
<Message error={error} success={success} className={classes.message} />
{!changePassword ? (
<Fragment>
<p>
{'To change your password, '}
<button
type="button"
className={classes.changePassword}
onClick={() => setChangePassword(!changePassword)}
>
click here
</button>
.
</p>
<Input
name="email"
label="Email Address"
required
register={register}
error={errors.email}
type="email"
/>
</Fragment>
) : (
<Fragment>
<p>
{'Change your password below, or '}
<button
type="button"
className={classes.changePassword}
onClick={() => setChangePassword(!changePassword)}
>
cancel
</button>
.
</p>
<Input
name="password"
type="password"
label="Password"
required
register={register}
error={errors.password}
/>
<Input
name="passwordConfirm"
type="password"
label="Confirm Password"
required
register={register}
validate={value => value === password.current || 'The passwords do not match'}
error={errors.passwordConfirm}
/>
</Fragment>
)}
<Button
type="submit"
className={classes.submit}
label={isLoading ? 'Processing' : changePassword ? 'Change password' : 'Update account'}
appearance="primary"
/>
</form>
)
}

View File

@@ -0,0 +1,7 @@
.account {
margin-bottom: var(--block-padding);
}
.params {
margin-top: var(--base);
}

View File

@@ -0,0 +1,34 @@
import React from 'react'
import Link from 'next/link'
import { Button } from '../_components/Button'
import { Gutter } from '../_components/Gutter'
import { RenderParams } from '../_components/RenderParams'
import { getMeUser } from '../_utilities/getMeUser'
import { AccountForm } from './AccountForm'
import classes from './index.module.scss'
export default async function Account() {
await getMeUser({
nullUserRedirect: `/login?error=${encodeURIComponent(
'You must be logged in to access your account.',
)}&redirect=${encodeURIComponent('/account')}`,
})
return (
<Gutter className={classes.account}>
<RenderParams className={classes.params} />
<h1>Account</h1>
<p>
{`This is your account dashboard. Here you can update your account information and more. To manage all users, `}
<Link href={`${process.env.NEXT_PUBLIC_CMS_URL}/admin/collections/users`}>
login to the admin dashboard
</Link>
{'.'}
</p>
<AccountForm />
<Button href="/logout" appearance="secondary" label="Log out" />
</Gutter>
)
}

View File

@@ -0,0 +1,22 @@
@import "../../_css/common";
.form {
margin-bottom: var(--base);
display: flex;
flex-direction: column;
gap: calc(var(--base) / 2);
align-items: flex-start;
width: 66.66%;
@include mid-break {
width: 100%;
}
}
.submit {
margin-top: calc(var(--base) / 2);
}
.message {
margin-bottom: var(--base);
}

View File

@@ -0,0 +1,121 @@
'use client'
import React, { useCallback, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '../../_components/Button'
import { Input } from '../../_components/Input'
import { Message } from '../../_components/Message'
import { useAuth } from '../../_providers/Auth'
import classes from './index.module.scss'
type FormData = {
email: string
password: string
passwordConfirm: string
}
export const CreateAccountForm: React.FC = () => {
const searchParams = useSearchParams()
const allParams = searchParams.toString() ? `?${searchParams.toString()}` : ''
const { login } = useAuth()
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<FormData>()
const password = useRef({})
password.current = watch('password', '')
const onSubmit = useCallback(
async (data: FormData) => {
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const message = response.statusText || 'There was an error creating the account.'
setError(message)
return
}
const redirect = searchParams.get('redirect')
const timer = setTimeout(() => {
setLoading(true)
}, 1000)
try {
await login(data)
clearTimeout(timer)
if (redirect) router.push(redirect as string)
else router.push(`/account?success=${encodeURIComponent('Account created successfully')}`)
} catch (_) {
clearTimeout(timer)
setError('There was an error with the credentials provided. Please try again.')
}
},
[login, router, searchParams],
)
return (
<form onSubmit={handleSubmit(onSubmit)} className={classes.form}>
<p>
{`This is where new customers can signup and create a new account. To manage all users, `}
<Link href={`${process.env.NEXT_PUBLIC_CMS_URL}/admin/collections/users`}>
login to the admin dashboard
</Link>
{'.'}
</p>
<Message error={error} className={classes.message} />
<Input
name="email"
label="Email Address"
required
register={register}
error={errors.email}
type="email"
/>
<Input
name="password"
type="password"
label="Password"
required
register={register}
error={errors.password}
/>
<Input
name="passwordConfirm"
type="password"
label="Confirm Password"
required
register={register}
validate={value => value === password.current || 'The passwords do not match'}
error={errors.passwordConfirm}
/>
<Button
type="submit"
className={classes.submit}
label={loading ? 'Processing' : 'Create Account'}
appearance="primary"
/>
<div>
{'Already have an account? '}
<Link href={`/login${allParams}`}>Login</Link>
</div>
</form>
)
}

View File

@@ -0,0 +1,5 @@
@import "../_css/common";
.createAccount {
margin-bottom: var(--block-padding);
}

View File

@@ -0,0 +1,24 @@
import React from 'react'
import { Gutter } from '../_components/Gutter'
import { RenderParams } from '../_components/RenderParams'
import { getMeUser } from '../_utilities/getMeUser'
import { CreateAccountForm } from './CreateAccountForm'
import classes from './index.module.scss'
export default async function CreateAccount() {
await getMeUser({
validUserRedirect: `/account?message=${encodeURIComponent(
'Cannot create a new account while logged in, please log out and try again.',
)}`,
})
return (
<Gutter className={classes.createAccount}>
<h1>Create Account</h1>
<RenderParams />
<CreateAccountForm />
</Gutter>
)
}

View File

@@ -0,0 +1,28 @@
import { Header } from './_components/Header'
import { AuthProvider } from './_providers/Auth'
import './_css/app.scss'
export const metadata = {
title: 'Payload Auth + Next.js App Router Example',
description: 'An example of how to authenticate with Payload from a Next.js app.',
}
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
return (
<html lang="en">
<body>
<AuthProvider
// To toggle between the REST and GraphQL APIs,
// change the `api` prop to either `rest` or `gql`
api="rest" // change this to `gql` to use the GraphQL API
>
<Header />
<main>{children}</main>
</AuthProvider>
</body>
</html>
)
}

View File

@@ -0,0 +1,22 @@
@import "../../_css/common";
.form {
margin-bottom: var(--base);
display: flex;
flex-direction: column;
gap: calc(var(--base) / 2);
align-items: flex-start;
width: 66.66%;
@include mid-break {
width: 100%;
}
}
.submit {
margin-top: calc(var(--base) / 2);
}
.message {
margin-bottom: var(--base);
}

View File

@@ -0,0 +1,96 @@
'use client'
import React, { useCallback, useRef } from 'react'
import { useForm } from 'react-hook-form'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '../../_components/Button'
import { Input } from '../../_components/Input'
import { Message } from '../../_components/Message'
import { useAuth } from '../../_providers/Auth'
import classes from './index.module.scss'
type FormData = {
email: string
password: string
}
export const LoginForm: React.FC = () => {
const searchParams = useSearchParams()
const allParams = searchParams.toString() ? `?${searchParams.toString()}` : ''
const redirect = useRef(searchParams.get('redirect'))
const { login } = useAuth()
const router = useRouter()
const [error, setError] = React.useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors, isLoading },
} = useForm<FormData>({
defaultValues: {
email: 'demo@payloadcms.com',
password: 'demo',
},
})
const onSubmit = useCallback(
async (data: FormData) => {
try {
await login(data)
if (redirect?.current) router.push(redirect.current as string)
else router.push('/account')
} catch (_) {
setError('There was an error with the credentials provided. Please try again.')
}
},
[login, router],
)
return (
<form onSubmit={handleSubmit(onSubmit)} className={classes.form}>
<p>
{'To log in, use the email '}
<b>demo@payloadcms.com</b>
{' with the password '}
<b>demo</b>
{'. To manage your users, '}
<Link href={`${process.env.NEXT_PUBLIC_CMS_URL}/admin/collections/users`}>
login to the admin dashboard
</Link>
.
</p>
<Message error={error} className={classes.message} />
<Input
name="email"
label="Email Address"
required
register={register}
error={errors.email}
type="email"
/>
<Input
name="password"
type="password"
label="Password"
required
register={register}
error={errors.password}
/>
<Button
type="submit"
disabled={isLoading}
className={classes.submit}
label={isLoading ? 'Processing' : 'Login'}
appearance="primary"
/>
<div>
<Link href={`/create-account${allParams}`}>Create an account</Link>
<br />
<Link href={`/recover-password${allParams}`}>Recover your password</Link>
</div>
</form>
)
}

View File

@@ -0,0 +1,9 @@
@import "../_css/common";
.login {
margin-bottom: var(--block-padding);
}
.params {
margin-top: var(--base);
}

View File

@@ -0,0 +1,22 @@
import React from 'react'
import { Gutter } from '../_components/Gutter'
import { RenderParams } from '../_components/RenderParams'
import { getMeUser } from '../_utilities/getMeUser'
import { LoginForm } from './LoginForm'
import classes from './index.module.scss'
export default async function Login() {
await getMeUser({
validUserRedirect: `/account?message=${encodeURIComponent('You are already logged in.')}`,
})
return (
<Gutter className={classes.login}>
<RenderParams className={classes.params} />
<h1>Log in</h1>
<LoginForm />
</Gutter>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import React, { Fragment, useEffect, useState } from 'react'
import Link from 'next/link'
import { useAuth } from '../../_providers/Auth'
export const LogoutPage: React.FC = props => {
const { logout } = useAuth()
const [success, setSuccess] = useState('')
const [error, setError] = useState('')
useEffect(() => {
const performLogout = async () => {
try {
await logout()
setSuccess('Logged out successfully.')
} catch (_) {
setError('You are already logged out.')
}
}
performLogout()
}, [logout])
return (
<Fragment>
{(error || success) && (
<div>
<h1>{error || success}</h1>
<p>
{'What would you like to do next? '}
<Link href="/">Click here</Link>
{` to go to the home page. To log back in, `}
<Link href="login">click here</Link>
{'.'}
</p>
</div>
)}
</Fragment>
)
}

View File

@@ -0,0 +1,3 @@
.logout {
margin-bottom: var(--block-padding);
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
import { Gutter } from '../_components/Gutter'
import { LogoutPage } from './LogoutPage'
import classes from './index.module.scss'
export default async function Logout() {
return (
<Gutter className={classes.logout}>
<LogoutPage />
</Gutter>
)
}

View File

@@ -0,0 +1,45 @@
import Link from 'next/link'
import { Gutter } from './_components/Gutter'
export default function Home() {
return (
<Gutter>
<h1>Payload Auth Example</h1>
<p>
{'This is a '}
<Link href="https://payloadcms.com" target="_blank" rel="noopener noreferrer">
Payload
</Link>
{' + '}
<Link href="https://nextjs.org" target="_blank" rel="noopener noreferrer">
Next.js
</Link>
{' app using the '}
<Link href="https://nextjs.org/docs/app" target="_blank" rel="noopener noreferrer">
App Router
</Link>
{' made explicitly for the '}
<Link href="https://github.com/payloadcms/payload/tree/master/examples/auth">
Payload Auth Example
</Link>
{". This example demonstrates how to implement Payload's "}
<Link href="https://payloadcms.com/docs/authentication/overview">Authentication</Link>
{
' strategies in both the REST and GraphQL APIs. To toggle between these APIs, see `_layout.tsx`.'
}
</p>
<p>
{'Visit the '}
<Link href="/login">login page</Link>
{' to start the authentication flow. Once logged in, you will be redirected to the '}
<Link href="/account">account page</Link>
{` which is restricted to users only. To manage all users, `}
<Link href={`${process.env.NEXT_PUBLIC_CMS_URL}/admin/collections/users`}>
login to the admin dashboard
</Link>
{'.'}
</p>
</Gutter>
)
}

View File

@@ -1,4 +1,5 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
@@ -16,12 +17,14 @@ export interface User {
firstName?: string;
lastName?: string;
roles?: ('admin' | 'user')[];
email?: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
salt?: string;
hash?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
password?: string;
}

View File

@@ -0,0 +1,23 @@
@import "../../_css/common";
.error {
color: red;
margin-bottom: 15px;
}
.formWrapper {
width: 66.66%;
@include mid-break {
width: 100%;
}
}
.submit {
margin-top: var(--base);
}
.message {
margin-bottom: var(--base);
}

View File

@@ -0,0 +1,88 @@
'use client'
import React, { Fragment, useCallback, useState } from 'react'
import { useForm } from 'react-hook-form'
import Link from 'next/link'
import { Button } from '../../_components/Button'
import { Input } from '../../_components/Input'
import { Message } from '../../_components/Message'
import classes from './index.module.scss'
type FormData = {
email: string
}
export const RecoverPasswordForm: React.FC = () => {
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>()
const onSubmit = useCallback(async (data: FormData) => {
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/forgot-password`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) {
setSuccess(true)
setError('')
} else {
setError(
'There was a problem while attempting to send you a password reset email. Please try again.',
)
}
}, [])
return (
<Fragment>
{!success && (
<React.Fragment>
<h1>Recover Password</h1>
<div className={classes.formWrapper}>
<p>
{`Please enter your email below. You will receive an email message with instructions on
how to reset your password. To manage your all users, `}
<Link href={`${process.env.NEXT_PUBLIC_CMS_URL}/admin/collections/users`}>
login to the admin dashboard
</Link>
{'.'}
</p>
<form onSubmit={handleSubmit(onSubmit)} className={classes.form}>
<Message error={error} className={classes.message} />
<Input
name="email"
label="Email Address"
required
register={register}
error={errors.email}
type="email"
/>
<Button
type="submit"
className={classes.submit}
label="Recover Password"
appearance="primary"
/>
</form>
</div>
</React.Fragment>
)}
{success && (
<React.Fragment>
<h1>Request submitted</h1>
<p>Check your email for a link that will allow you to securely reset your password.</p>
</React.Fragment>
)}
</Fragment>
)
}

View File

@@ -0,0 +1,5 @@
@import "../_css/common";
.recoverPassword {
margin-bottom: var(--block-padding);
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
import { Gutter } from '../_components/Gutter'
import { RecoverPasswordForm } from './RecoverPasswordForm'
import classes from './index.module.scss'
export default async function RecoverPassword() {
return (
<Gutter className={classes.recoverPassword}>
<RecoverPasswordForm />
</Gutter>
)
}

View File

@@ -0,0 +1,13 @@
@import "../../_css/common";
.form {
width: 66.66%;
@include mid-break {
width: 100%;
}
}
.submit {
margin-top: var(--base);
}

View File

@@ -0,0 +1,84 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '../../_components/Button'
import { Input } from '../../_components/Input'
import { Message } from '../../_components/Message'
import { useAuth } from '../../_providers/Auth'
import classes from './index.module.scss'
type FormData = {
password: string
token: string
}
export const ResetPasswordForm: React.FC = () => {
const [error, setError] = useState('')
const { login } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<FormData>()
const onSubmit = useCallback(
async (data: FormData) => {
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/reset-password`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) {
const json = await response.json()
// Automatically log the user in after they successfully reset password
await login({ email: json.user.email, password: data.password })
// Redirect them to `/account` with success message in URL
router.push('/account?success=Password reset successfully.')
} else {
setError('There was a problem while resetting your password. Please try again later.')
}
},
[router, login],
)
// when Next.js populates token within router,
// reset form with new token value
useEffect(() => {
reset({ token: token || undefined })
}, [reset, token])
return (
<form onSubmit={handleSubmit(onSubmit)} className={classes.form}>
<Message error={error} className={classes.message} />
<Input
name="password"
type="password"
label="New Password"
required
register={register}
error={errors.password}
/>
<input type="hidden" {...register('token')} />
<Button
type="submit"
className={classes.submit}
label="Reset Password"
appearance="primary"
/>
</form>
)
}

View File

@@ -0,0 +1,3 @@
.resetPassword {
margin-bottom: var(--block-padding);
}

View File

@@ -0,0 +1,16 @@
import React from 'react'
import { Gutter } from '../_components/Gutter'
import { ResetPasswordForm } from './ResetPasswordForm'
import classes from './index.module.scss'
export default async function ResetPassword() {
return (
<Gutter className={classes.resetPassword}>
<h1>Reset Password</h1>
<p>Please enter a new password below.</p>
<ResetPasswordForm />
</Gutter>
)
}

View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

View File

@@ -0,0 +1,40 @@
{
"name": "payload-nextjs-auth-example",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"lint": "next lint"
},
"dependencies": {
"escape-html": "^1.0.3",
"next": "^13.4.8",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.45.1"
},
"devDependencies": {
"@next/eslint-plugin-next": "^13.4.8",
"@payloadcms/eslint-config": "^0.0.2",
"@types/escape-html": "^1.0.2",
"@types/node": "18.11.3",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"eslint": "8.41.0",
"eslint-config-next": "13.4.3",
"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",
"prettier": "^2.7.1",
"sass": "^1.62.1",
"slate": "^0.82.0",
"typescript": "^4.8.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,15 @@
<svg width="260" height="260" viewBox="0 0 260 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
path {
fill: #0F0F0F;
}
@media (prefers-color-scheme: dark) {
path {
fill: white;
}
}
</style>
<path d="M120.59 8.5824L231.788 75.6142V202.829L148.039 251.418V124.203L36.7866 57.2249L120.59 8.5824Z" />
<path d="M112.123 244.353V145.073L28.2114 193.769L112.123 244.353Z" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
NEXT_PUBLIC_CMS_URL=http://localhost:3000

View File

@@ -0,0 +1,41 @@
# Payload Auth Example Front-End
This is a [Payload](https://payloadcms.com) + [Next.js](https://nextjs.org) app using the [Pages Router](https://nextjs.org/docs/pages) made explicitly for the [Payload Auth Example](https://github.com/payloadcms/payload/tree/master/examples/auth). It demonstrates how to authenticate your Next.js app using [Payload Authentication](https://payloadcms.com/docs/authentication/overview).
> This example uses the Pages Router, the legacy API of Next.js. If your app is using the latest [App Router](https://nextjs.org/docs/pages), check out the official [App Router Example](https://github.com/payloadcms/payload/tree/master/examples/auth/next-app).
## Getting Started
### Payload
First you'll need a running Payload app. If you have not done so already, clone down the [`cms`](../cms) folder and follow the setup instructions there to get it up and running. This will provide all the necessary APIs that your Next.js app will be using for authentication.
### Next.js
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
5. `open http://localhost:3001` to see the result
Once running, a user is automatically seeded in your local environment with some basic instructions. See the [Payload Auth Example](https://github.com/payloadcms/payload/tree/master/examples/auth) for full details.
## Learn More
To learn more about Payload and Next.js, take a look at the following resources:
- [Payload Documentation](https://payloadcms.com/docs) - learn about Payload features and API.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Payload GitHub repository](https://github.com/payloadcms/payload) as well as [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deployment
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new) from the creators of Next.js. You could also combine this app into a [single Express server](https://github.com/payloadcms/payload/tree/master/examples/custom-server) and deploy in to [Payload Cloud](https://payloadcms.com/new/import).
Check out our [Payload deployment documentation](https://payloadcms.com/docs/production/deployment) or the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
## Questions
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).

View File

@@ -1,8 +1,8 @@
{
"name": "next-auth-frontend",
"name": "payload-nextjs-auth-example",
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "next lint",
@@ -10,19 +10,19 @@
},
"dependencies": {
"escape-html": "^1.0.3",
"next": "^13.1.6",
"next": "^13.4.8",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.34.2",
"slate": "^0.82.0"
"react-hook-form": "^7.34.2"
},
"devDependencies": {
"@next/eslint-plugin-next": "^13.1.6",
"@types/node": "18.11.3",
"@types/react": "18.0.21",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"@payloadcms/eslint-config": "^0.0.1",
"@payloadcms/eslint-config": "^0.0.2",
"eslint": "8.25.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-filenames": "^1.3.2",
@@ -31,6 +31,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"prettier": "^2.7.1",
"slate": "^0.82.0",
"typescript": "4.8.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,15 @@
<svg width="260" height="260" viewBox="0 0 260 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
path {
fill: #0F0F0F;
}
@media (prefers-color-scheme: dark) {
path {
fill: white;
}
}
</style>
<path d="M120.59 8.5824L231.788 75.6142V202.829L148.039 251.418V124.203L36.7866 57.2249L120.59 8.5824Z" />
<path d="M112.123 244.353V145.073L28.2114 193.769L112.123 244.353Z" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

View File

@@ -0,0 +1,40 @@
@import '../../css/type.scss';
.button {
border: none;
cursor: pointer;
display: inline-flex;
justify-content: center;
background-color: transparent;
text-decoration: none;
display: inline-flex;
padding: 12px 24px;
}
.content {
display: flex;
align-items: center;
justify-content: space-around;
}
.label {
@extend %label;
text-align: center;
display: flex;
align-items: center;
}
.appearance--primary {
background-color: var(--theme-elevation-1000);
color: var(--theme-elevation-0);
}
.appearance--secondary {
background-color: transparent;
box-shadow: inset 0 0 0 1px var(--theme-elevation-1000);
}
.appearance--default {
padding: 0;
color: var(--theme-text);
}

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