chore: updates auth example (#3026)

This commit is contained in:
Jacob Fletcher
2023-07-20 14:06:26 -04:00
committed by GitHub
parent 41d3eee35f
commit 2a932ea28e
106 changed files with 3298 additions and 1129 deletions

View File

@@ -1,8 +1,11 @@
# Payload Auth Example # 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](../next-app). 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 ## Quick Start

View File

@@ -1,6 +1,6 @@
# Payload Auth Example Front-End # Payload Auth Example Front-End
This is a [Next.js](https://nextjs.org) app using the [App Router](https://nextjs.org/docs/app). It was made explicitly for Payload's [Auth Example](https://github.com/payloadcms/payload/tree/master/examples/auth/cms). 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). > 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).
@@ -8,7 +8,7 @@ This is a [Next.js](https://nextjs.org) app using the [App Router](https://nextj
### Payload ### Payload
First you'll need a running [Payload](https://github.com/payloadcms/payload) app. If you have not done so already, open up the `cms` folder and follow the setup instructions. Take note of your `serverURL`, you'll need this in the next step. 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 ### Next.js
@@ -18,7 +18,7 @@ First you'll need a running [Payload](https://github.com/payloadcms/payload) app
4. `yarn dev` or `npm run dev` to start the server 4. `yarn dev` or `npm run dev` to start the server
5. `open http://localhost:3001` to see the result 5. `open http://localhost:3001` to see the result
Once running you will find a couple seeded pages on your local environment with some basic instructions. You can also start editing the pages by modifying the documents within Payload. See the [Auth Example](https://github.com/payloadcms/payload/tree/master/examples/auth/cms) for full details. 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 ## Learn More
@@ -35,3 +35,7 @@ You can check out [the Payload GitHub repository](https://github.com/payloadcms/
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). 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. 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

@@ -1,7 +1,7 @@
.gutter { .gutter {
max-width: var(--max-width); max-width: 1920px;
width: 100%; margin-left: auto;
margin: auto; margin-right: auto;
} }
.gutterLeft { .gutterLeft {

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

@@ -1,31 +0,0 @@
'use client'
import React, { Fragment } from 'react'
import Link from 'next/link'
import { useAuth } from '../Auth'
import classes from './index.module.scss'
export function HeaderClient() {
const { user } = useAuth()
return (
<nav className={classes.nav}>
{!user && (
<Fragment>
<Link href="/login">Login</Link>
<Link href="/create-account">Create Account</Link>
</Fragment>
)}
{user && (
<Fragment>
<Link href="/account">Account</Link>
<Link href="/logout">Logout</Link>
</Fragment>
)}
</nav>
)
}
export default HeaderClient

View File

@@ -1,3 +1,5 @@
@use '../../_css/queries.scss' as *;
.header { .header {
padding: var(--base) 0; padding: var(--base) 0;
} }
@@ -5,28 +7,16 @@
.wrap { .wrap {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: calc(var(--base) / 2);
flex-wrap: wrap; flex-wrap: wrap;
gap: calc(var(--base) / 2) var(--base);
} }
.logo { .logo {
flex-shrink: 0; width: 150px;
} }
.nav { :global([data-theme="light"]) {
display: flex; .logo {
align-items: center; filter: invert(1);
gap: var(--base);
white-space: nowrap;
overflow: hidden;
flex-wrap: wrap;
a {
display: block;
text-decoration: none;
}
@media (max-width: 1000px) {
gap: 0 calc(var(--base) / 2);
} }
} }

View File

@@ -3,7 +3,7 @@ import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { Gutter } from '../Gutter' import { Gutter } from '../Gutter'
import { HeaderClient } from './index.client' import { HeaderNav } from './Nav'
import classes from './index.module.scss' import classes from './index.module.scss'
@@ -25,7 +25,7 @@ export function Header() {
/> />
</picture> </picture>
</Link> </Link>
<HeaderClient /> <HeaderNav />
</Gutter> </Gutter>
</header> </header>
) )

View File

@@ -1,23 +0,0 @@
.input {
margin-bottom: 15px;
}
.input input {
width: 100%;
font-family: system-ui;
border-radius: 0;
box-shadow: 0;
border: 1px solid #d8d8d8;
height: 30px;
line-height: 30px;
}
.label {
margin-bottom: 10px;
display: block;
}
.error {
margin-top: 5px;
color: red;
}

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

@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { FieldValues, UseFormRegister } from 'react-hook-form' import { FieldValues, UseFormRegister, Validate } from 'react-hook-form'
import classes from './index.module.css' import classes from './index.module.scss'
type Props = { type Props = {
name: string name: string
@@ -9,7 +9,8 @@ type Props = {
register: UseFormRegister<FieldValues & any> register: UseFormRegister<FieldValues & any>
required?: boolean required?: boolean
error: any error: any
type?: 'text' | 'number' | 'password' type?: 'text' | 'number' | 'password' | 'email'
validate?: (value: string) => boolean | string
} }
export const Input: React.FC<Props> = ({ export const Input: React.FC<Props> = ({
@@ -19,14 +20,36 @@ export const Input: React.FC<Props> = ({
register, register,
error, error,
type = 'text', type = 'text',
validate,
}) => { }) => {
return ( return (
<div className={classes.input}> <div className={classes.inputWrap}>
<label htmlFor="name" className={classes.label}> <label htmlFor="name" className={classes.label}>
{label} {`${label} ${required ? '*' : ''}`}
</label> </label>
<input {...{ type }} {...register(name, { required })} /> <input
{error && <div className={classes.error}>This field is required</div>} 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> </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

@@ -0,0 +1,117 @@
@use './queries.scss' as *;
@use './colors.scss' as *;
@use './type.scss' as *;
@import "./theme.scss";
:root {
--base: 24px;
--font-body: system-ui;
--font-mono: 'Roboto Mono', monospace;
--gutter-h: 180px;
--block-padding: 120px;
@include large-break {
--gutter-h: 144px;
--block-padding: 96px;
}
@include mid-break {
--gutter-h: 24px;
--block-padding: 60px;
}
}
* {
box-sizing: border-box;
}
html {
@extend %body;
background: var(--theme-bg);
-webkit-font-smoothing: antialiased;
}
html,
body,
#app {
height: 100%;
}
body {
font-family: var(--font-body);
margin: 0;
color: var(--theme-text);
}
::selection {
background: var(--theme-success-500);
color: var(--color-base-800);
}
::-moz-selection {
background: var(--theme-success-500);
color: var(--color-base-800);
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1 {
@extend %h1;
}
h2 {
@extend %h2;
}
h3 {
@extend %h3;
}
h4 {
@extend %h4;
}
h5 {
@extend %h5;
}
h6 {
@extend %h6;
}
p {
margin: var(--base) 0;
@include mid-break {
margin: calc(var(--base) * .75) 0;
}
}
ul,
ol {
padding-left: var(--base);
margin: 0 0 var(--base);
}
a {
color: currentColor;
&:focus {
opacity: .8;
outline: none;
}
&:active {
opacity: .7;
outline: none;
}
}
svg {
vertical-align: middle;
}

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

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

View File

@@ -0,0 +1,28 @@
$breakpoint-xs-width: 400px;
$breakpoint-s-width: 768px;
$breakpoint-m-width: 1024px;
$breakpoint-l-width: 1440px;
@mixin extra-small-break {
@media (max-width: #{$breakpoint-xs-width}) {
@content;
}
}
@mixin small-break {
@media (max-width: #{$breakpoint-s-width}) {
@content;
}
}
@mixin mid-break {
@media (max-width: #{$breakpoint-m-width}) {
@content;
}
}
@mixin large-break {
@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,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

@@ -1,17 +0,0 @@
.form {
margin-bottom: 30px;
}
.success,
.error,
.message {
margin-bottom: 30px;
}
.success {
color: green;
}
.error {
color: red;
}

View File

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

View File

@@ -1,111 +1,34 @@
'use client' import React from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import Link from 'next/link' import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '../_components/Auth' import { Button } from '../_components/Button'
import { Gutter } from '../_components/Gutter' import { Gutter } from '../_components/Gutter'
import { Input } from '../_components/Input' import { RenderParams } from '../_components/RenderParams'
import classes from './index.module.css' import { getMeUser } from '../_utilities/getMeUser'
import { AccountForm } from './AccountForm'
type FormData = { import classes from './index.module.scss'
email: string
firstName: string
lastName: string
}
const Account: React.FC = () => { export default async function Account() {
const [error, setError] = useState('') await getMeUser({
const [success, setSuccess] = useState('') nullUserRedirect: `/login?error=${encodeURIComponent(
const { user, setUser } = useAuth() 'You must be logged in to access your account.',
const router = useRouter() )}&redirect=${encodeURIComponent('/account')}`,
const params = useSearchParams()
const queryMsg = params.get('message')
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<FormData>()
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()
// Update the user in auth state with new values
setUser(json.doc)
// Set success message for user
setSuccess('Successfully updated account.')
// Clear any existing errors
setError('')
} else {
setError('There was a problem updating your account.')
}
}
},
[user, setUser],
)
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,
firstName: user.firstName,
lastName: user.lastName,
})
}
}, [user, reset, router])
useEffect(() => {
const success = params.get('success')
if (success) {
setSuccess(success)
}
}, [router, params])
return ( return (
<Gutter> <Gutter className={classes.account}>
<RenderParams className={classes.params} />
<h1>Account</h1> <h1>Account</h1>
{queryMsg && <div className={classes.message}>{queryMsg}</div>} <p>
{error && <div className={classes.error}>{error}</div>} {`This is your account dashboard. Here you can update your account information and more. To manage all users, `}
{success && <div className={classes.success}>{success}</div>} <Link href={`${process.env.NEXT_PUBLIC_CMS_URL}/admin/collections/users`}>
<form onSubmit={handleSubmit(onSubmit)} className={classes.form}> login to the admin dashboard
<Input </Link>
name="email" {'.'}
label="Email Address" </p>
required <AccountForm />
register={register} <Button href="/logout" appearance="secondary" label="Log out" />
error={errors.email}
/>
<Input name="firstName" label="First Name" register={register} error={errors.firstName} />
<Input name="lastName" label="Last Name" register={register} error={errors.lastName} />
<button type="submit">Update account</button>
</form>
<Link href="/logout">Log out</Link>
</Gutter> </Gutter>
) )
} }
export default Account

View File

@@ -1,107 +0,0 @@
$breakpoint: 1000px;
:root {
--max-width: 1600px;
--foreground-rgb: 0, 0, 0;
--background-rgb: 255, 255, 255;
--block-spacing: 2rem;
--gutter-h: 4rem;
--base: 1rem;
@media (max-width: $breakpoint) {
--block-spacing: 1rem;
--gutter-h: 2rem;
--base: 0.75rem;
}
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-rgb: 7, 7, 7;
}
}
* {
box-sizing: border-box;
}
html {
font-size: 20px;
line-height: 1.5;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
@media (max-width: $breakpoint) {
font-size: 16px;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
margin: 0;
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
}
img {
height: auto;
max-width: 100%;
display: block;
}
h1 {
font-size: 4.5rem;
line-height: 1.2;
margin: 0 0 2.5rem 0;
@media (max-width: $breakpoint) {
font-size: 3rem;
margin: 0 0 1.5rem 0;
}
}
h2 {
font-size: 3.5rem;
line-height: 1.2;
margin: 0 0 2.5rem 0;
}
h3 {
font-size: 2.5rem;
line-height: 1.2;
margin: 0 0 2rem 0;
}
h4 {
font-size: 1.5rem;
line-height: 1.2;
margin: 0 0 1rem 0;
}
h5 {
font-size: 1.25rem;
line-height: 1.2;
margin: 0 0 1rem 0;
}
h6 {
font-size: 1rem;
line-height: 1.2;
margin: 0 0 1rem 0;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

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

@@ -1,8 +0,0 @@
.form {
margin-bottom: 30px;
}
.error {
color: red;
margin-bottom: 30px;
}

View File

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

View File

@@ -1,92 +1,24 @@
'use client' import React from 'react'
import React, { useCallback, useState } from 'react'
import { useForm } from 'react-hook-form'
import Link from 'next/link'
import { useAuth } from '../_components/Auth'
import { Gutter } from '../_components/Gutter' import { Gutter } from '../_components/Gutter'
import { Input } from '../_components/Input' import { RenderParams } from '../_components/RenderParams'
import classes from './index.module.css' import { getMeUser } from '../_utilities/getMeUser'
import { CreateAccountForm } from './CreateAccountForm'
type FormData = { import classes from './index.module.scss'
email: string
password: string
firstName: string
lastName: string
}
const CreateAccount: React.FC = () => { export default async function CreateAccount() {
const [error, setError] = useState('') await getMeUser({
const [success, setSuccess] = useState(false) validUserRedirect: `/account?message=${encodeURIComponent(
const { login, create, user } = useAuth() 'Cannot create a new account while logged in, please log out and try again.',
)}`,
const { })
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>()
const onSubmit = useCallback(
async (data: FormData) => {
try {
await create(data as Parameters<typeof create>[0])
// Automatically log the user in after creating their account
await login({ email: data.email, password: data.password })
setSuccess(true)
} catch (err: any) {
setError(err?.message || 'An error occurred while attempting to create your account.')
}
},
[login, create],
)
return ( return (
<Gutter> <Gutter className={classes.createAccount}>
{!success && (
<React.Fragment>
<h1>Create Account</h1> <h1>Create Account</h1>
{error && <div className={classes.error}>{error}</div>} <RenderParams />
<form onSubmit={handleSubmit(onSubmit)} className={classes.form}> <CreateAccountForm />
<Input
name="email"
label="Email Address"
required
register={register}
error={errors.email}
/>
<Input
name="password"
type="password"
label="Password"
required
register={register}
error={errors.password}
/>
<Input
name="firstName"
label="First Name"
register={register}
error={errors.firstName}
/>
<Input name="lastName" label="Last Name" register={register} error={errors.lastName} />
<button type="submit">Create account</button>
</form>
<p>
{'Already have an account? '}
<Link href="/login">Login</Link>
</p>
</React.Fragment>
)}
{success && (
<React.Fragment>
<h1>Account created successfully</h1>
<p>You are now logged in.</p>
<Link href="/account">Go to your account</Link>
</React.Fragment>
)}
</Gutter> </Gutter>
) )
} }
export default CreateAccount

View File

@@ -1,3 +0,0 @@
.page {
margin-top: calc(var(--base) * 2);
}

View File

@@ -1,9 +1,7 @@
import { AuthProvider } from './_components/Auth'
import { Header } from './_components/Header' import { Header } from './_components/Header'
import { AuthProvider } from './_providers/Auth'
import './app.scss' import './_css/app.scss'
import classes from './index.module.scss'
export const metadata = { export const metadata = {
title: 'Payload Auth + Next.js App Router Example', title: 'Payload Auth + Next.js App Router Example',
@@ -16,9 +14,13 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body> <body>
<AuthProvider> <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 /> <Header />
<div className={classes.page}>{children}</div> <main>{children}</main>
</AuthProvider> </AuthProvider>
</body> </body>
</html> </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

@@ -1,8 +0,0 @@
.form {
margin-bottom: 30px;
}
.error {
color: red;
margin-bottom: 30px;
}

View File

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

View File

@@ -1,85 +1,22 @@
'use client' import React from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import Link from 'next/link'
import { redirect, useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '../_components/Auth'
import { Gutter } from '../_components/Gutter' import { Gutter } from '../_components/Gutter'
import { Input } from '../_components/Input' import { RenderParams } from '../_components/RenderParams'
import classes from './index.module.css' import { getMeUser } from '../_utilities/getMeUser'
import { LoginForm } from './LoginForm'
type FormData = { import classes from './index.module.scss'
email: string
password: string
}
const Login: React.FC = () => { export default async function Login() {
const [error, setError] = useState('') await getMeUser({
const router = useRouter() validUserRedirect: `/account?message=${encodeURIComponent('You are already logged in.')}`,
const params = useSearchParams() })
const { login, user } = useAuth()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>()
const onSubmit = useCallback(
async (data: FormData) => {
try {
await login(data)
router.push('/account')
} catch (err: any) {
setError(err?.message || 'An error occurred while attempting to login.')
}
},
[login, router],
)
useEffect(() => {
const unauthorized = params.get('unauthorized')
if (unauthorized) {
setError(`To visit the ${unauthorized} page, you need to be logged in.`)
}
}, [params])
if (user) {
redirect('/account')
}
return ( return (
<Gutter> <Gutter className={classes.login}>
<RenderParams className={classes.params} />
<h1>Log in</h1> <h1>Log in</h1>
<p> <LoginForm />
To log in, use the email <b>demo@payloadcms.com</b> with the password <b>demo</b>.
</p>
{error && <div className={classes.error}>{error}</div>}
<form onSubmit={handleSubmit(onSubmit)} className={classes.form}>
<Input
name="email"
label="Email Address"
required
register={register}
error={errors.email}
/>
<Input
name="password"
type="password"
label="Password"
required
register={register}
error={errors.password}
/>
<input type="submit" />
</form>
<Link href="/create-account">Create an account</Link>
<br />
<Link href="/recover-password">Recover your password</Link>
</Gutter> </Gutter>
) )
} }
export default Login

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

@@ -1,4 +0,0 @@
.error {
color: red;
margin-bottom: 30px;
}

View File

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

View File

@@ -1,44 +1,14 @@
'use client' import React from 'react'
import React, { Fragment, useEffect, useState } from 'react'
import Link from 'next/link'
import { useAuth } from '../_components/Auth'
import { Gutter } from '../_components/Gutter' import { Gutter } from '../_components/Gutter'
import classes from './index.module.css' import { LogoutPage } from './LogoutPage'
const Logout: React.FC = () => { import classes from './index.module.scss'
const { logout } = useAuth()
const [success, setSuccess] = useState('')
const [error, setError] = useState('')
useEffect(() => {
const performLogout = async () => {
try {
await logout()
setSuccess('Logged out successfully.')
} catch (err: any) {
setError(err?.message || 'An error occurred while attempting to logout.')
}
}
performLogout()
}, [logout])
export default async function Logout() {
return ( return (
<Gutter> <Gutter className={classes.logout}>
{success && <h1>{success}</h1>} <LogoutPage />
{error && <div className={classes.error}>{error}</div>}
<p>
{'What would you like to do next? '}
<Fragment>
{' To log back in, '}
<Link href={`/login`}>click here</Link>
{'.'}
</Fragment>
</p>
</Gutter> </Gutter>
) )
} }
export default Logout

View File

@@ -19,20 +19,26 @@ export default function Home() {
<Link href="https://nextjs.org/docs/app" target="_blank" rel="noopener noreferrer"> <Link href="https://nextjs.org/docs/app" target="_blank" rel="noopener noreferrer">
App Router App Router
</Link> </Link>
{" made explicitly for Payload's "} {' made explicitly for the '}
<Link href="https://github.com/payloadcms/payload/tree/master/examples/auth/cms"> <Link href="https://github.com/payloadcms/payload/tree/master/examples/auth">
Auth Example Payload Auth Example
</Link> </Link>
{". This example demonstrates how to implement Payload's "} {". This example demonstrates how to implement Payload's "}
<Link href="https://payloadcms.com/docs/authentication/overview">Authentication</Link> <Link href="https://payloadcms.com/docs/authentication/overview">Authentication</Link>
{' strategies in both the REST and GraphQL APIs.'} {
' strategies in both the REST and GraphQL APIs. To toggle between these APIs, see `_layout.tsx`.'
}
</p> </p>
<p> <p>
{'Visit the '} {'Visit the '}
<Link href="/login">Login</Link> <Link href="/login">login page</Link>
{' page to start the authentication flow. Once logged in, you will be redirected to the '} {' to start the authentication flow. Once logged in, you will be redirected to the '}
<Link href="/account">Account</Link> <Link href="/account">account page</Link>
{` page which is restricted to users only. To toggle APIs, simply toggle the "api" prop between "rest" and "gql" in "_app.tsx".`} {` 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> </p>
</Gutter> </Gutter>
) )

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

@@ -1,4 +0,0 @@
.error {
color: red;
margin-bottom: 30px;
}

View File

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

View File

@@ -1,74 +1,14 @@
'use client' import React from 'react'
import React, { useCallback, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useAuth } from '../_components/Auth'
import { Gutter } from '../_components/Gutter' import { Gutter } from '../_components/Gutter'
import { Input } from '../_components/Input' import { RecoverPasswordForm } from './RecoverPasswordForm'
import classes from './index.module.css'
type FormData = { import classes from './index.module.scss'
email: string
}
const RecoverPassword: React.FC = () => {
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const { forgotPassword } = useAuth()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>()
const onSubmit = useCallback(
async (data: FormData) => {
try {
const user = await forgotPassword(data as Parameters<typeof forgotPassword>[0])
if (user) {
setSuccess(true)
setError('')
}
} catch (err: any) {
setError(err?.message || 'An error occurred while attempting to recover password.')
}
},
[forgotPassword],
)
export default async function RecoverPassword() {
return ( return (
<Gutter> <Gutter className={classes.recoverPassword}>
{!success && ( <RecoverPasswordForm />
<React.Fragment>
<h1>Recover Password</h1>
<p>
Please enter your email below. You will receive an email message with instructions on
how to reset your password.
</p>
{error && <div className={classes.error}>{error}</div>}
<form onSubmit={handleSubmit(onSubmit)}>
<Input
name="email"
label="Email Address"
required
register={register}
error={errors.email}
/>
<button type="submit">Submit</button>
</form>
</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>
)}
</Gutter> </Gutter>
) )
} }
export default RecoverPassword

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

@@ -1,4 +0,0 @@
.error {
color: red;
margin-bottom: 30px;
}

View File

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

View File

@@ -1,75 +1,16 @@
'use client' import React from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '../_components/Auth'
import { Gutter } from '../_components/Gutter' import { Gutter } from '../_components/Gutter'
import { Input } from '../_components/Input' import { ResetPasswordForm } from './ResetPasswordForm'
import classes from './index.module.css'
type FormData = { import classes from './index.module.scss'
password: string
token: string | null
}
const ResetPassword: React.FC = () => {
const [error, setError] = useState('')
const { login, resetPassword } = useAuth()
const router = useRouter()
const params = useSearchParams()
const token = params.get('token')
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<FormData>()
const onSubmit = useCallback(
async (data: FormData) => {
try {
const user = await resetPassword(data as Parameters<typeof resetPassword>[0])
if (user) {
// Automatically log the user in after they successfully reset password
// Then redirect them to /account with success message in URL
await login({ email: user.email, password: data.password })
router.push('/account?success=Password reset successfully.')
}
} catch (err: any) {
setError(err?.message || 'An error occurred while attempting to reset password.')
}
},
[router, login, resetPassword],
)
// When Next.js populates token within router, reset form with new token value
useEffect(() => {
reset({ token })
}, [reset, token])
export default async function ResetPassword() {
return ( return (
<Gutter> <Gutter className={classes.resetPassword}>
<h1>Reset Password</h1> <h1>Reset Password</h1>
<p>Please enter a new password below.</p> <p>Please enter a new password below.</p>
{error && <div className={classes.error}>{error}</div>} <ResetPasswordForm />
<form onSubmit={handleSubmit(onSubmit)}>
<Input
name="password"
type="password"
label="New Password"
required
register={register}
error={errors.password}
/>
<input type="hidden" {...register('token')} />
<button type="submit">Submit</button>
</form>
</Gutter> </Gutter>
) )
} }
export default ResetPassword

View File

@@ -1,6 +1,6 @@
# Payload Auth Example Front-End # Payload Auth Example Front-End
This is a [Next.js](https://nextjs.org) app using the [Pages Router](https://nextjs.org/docs/pages). It was made explicitly for Payload's [Auth Example](https://github.com/payloadcms/payload/tree/master/examples/auth/cms). 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). > 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).
@@ -8,7 +8,7 @@ This is a [Next.js](https://nextjs.org) app using the [Pages Router](https://nex
### Payload ### Payload
First you'll need a running [Payload](https://github.com/payloadcms/payload) app. If you have not done so already, open up the `cms` folder and follow the setup instructions. Take note of your `serverURL`, you'll need this in the next step. 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 ### Next.js
@@ -18,20 +18,24 @@ First you'll need a running [Payload](https://github.com/payloadcms/payload) app
4. `yarn dev` or `npm run dev` to start the server 4. `yarn dev` or `npm run dev` to start the server
5. `open http://localhost:3001` to see the result 5. `open http://localhost:3001` to see the result
Once running you will find a couple seeded pages on your local environment with some basic instructions. You can also start editing the pages by modifying the documents within Payload. See the [Auth Example](https://github.com/payloadcms/payload/tree/master/examples/auth/cms) for full details. 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 ## Learn More
To learn more about PayloadCMS and Next.js, take a look at the following resources: 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. - [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. - [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. - [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! 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 ## 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). 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. 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,73 @@
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

@@ -1,7 +1,7 @@
.gutter { .gutter {
max-width: var(--max-width); max-width: 1920px;
width: 100%; margin-left: auto;
margin: auto; margin-right: auto;
} }
.gutterLeft { .gutterLeft {

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,36 @@
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

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

View File

@@ -1,15 +1,13 @@
import React, { Fragment } from 'react' import React from 'react'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { useAuth } from '../Auth'
import { Gutter } from '../Gutter' import { Gutter } from '../Gutter'
import { HeaderNav } from './Nav'
import classes from './index.module.scss' import classes from './index.module.scss'
export const Header: React.FC = () => { export const Header: React.FC = () => {
const { user } = useAuth()
return ( return (
<header className={classes.header}> <header className={classes.header}>
<Gutter className={classes.wrap}> <Gutter className={classes.wrap}>
@@ -27,20 +25,7 @@ export const Header: React.FC = () => {
/> />
</picture> </picture>
</Link> </Link>
<nav className={classes.nav}> <HeaderNav />
{!user && (
<Fragment>
<Link href="/login">Login</Link>
<Link href="/create-account">Create Account</Link>
</Fragment>
)}
{user && (
<Fragment>
<Link href="/account">Account</Link>
<Link href="/logout">Logout</Link>
</Fragment>
)}
</nav>
</Gutter> </Gutter>
</header> </header>
) )

View File

@@ -1,23 +0,0 @@
.input {
margin-bottom: 15px;
}
.input input {
width: 100%;
font-family: system-ui;
border-radius: 0;
box-shadow: 0;
border: 1px solid #d8d8d8;
height: 30px;
line-height: 30px;
}
.label {
margin-bottom: 10px;
display: block;
}
.error {
margin-top: 5px;
color: red;
}

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

@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { FieldValues, UseFormRegister } from 'react-hook-form' import { FieldValues, UseFormRegister, Validate } from 'react-hook-form'
import classes from './index.module.css' import classes from './index.module.scss'
type Props = { type Props = {
name: string name: string
@@ -9,7 +9,8 @@ type Props = {
register: UseFormRegister<FieldValues & any> register: UseFormRegister<FieldValues & any>
required?: boolean required?: boolean
error: any error: any
type?: 'text' | 'number' | 'password' type?: 'text' | 'number' | 'password' | 'email'
validate?: (value: string) => boolean | string
} }
export const Input: React.FC<Props> = ({ export const Input: React.FC<Props> = ({
@@ -19,14 +20,36 @@ export const Input: React.FC<Props> = ({
register, register,
error, error,
type = 'text', type = 'text',
validate,
}) => { }) => {
return ( return (
<div className={classes.input}> <div className={classes.inputWrap}>
<label htmlFor="name" className={classes.label}> <label htmlFor="name" className={classes.label}>
{label} {`${label} ${required ? '*' : ''}`}
</label> </label>
<input {...{ type }} {...register(name, { required })} /> <input
{error && <div className={classes.error}>This field is required</div>} 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> </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,25 @@
import { useRouter } from 'next/router'
import { Message } from '../Message'
export const RenderParams: React.FC<{
params?: string[]
message?: string
className?: string
}> = ({ params = ['error', 'message', 'success'], message, className }) => {
const router = useRouter()
const searchParams = new URLSearchParams(router.query as any)
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

@@ -0,0 +1,117 @@
@use './queries.scss' as *;
@use './colors.scss' as *;
@use './type.scss' as *;
@import "./theme.scss";
:root {
--base: 24px;
--font-body: system-ui;
--font-mono: 'Roboto Mono', monospace;
--gutter-h: 180px;
--block-padding: 120px;
@include large-break {
--gutter-h: 144px;
--block-padding: 96px;
}
@include mid-break {
--gutter-h: 24px;
--block-padding: 60px;
}
}
* {
box-sizing: border-box;
}
html {
@extend %body;
background: var(--theme-bg);
-webkit-font-smoothing: antialiased;
}
html,
body,
#app {
height: 100%;
}
body {
font-family: var(--font-body);
margin: 0;
color: var(--theme-text);
}
::selection {
background: var(--theme-success-500);
color: var(--color-base-800);
}
::-moz-selection {
background: var(--theme-success-500);
color: var(--color-base-800);
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1 {
@extend %h1;
}
h2 {
@extend %h2;
}
h3 {
@extend %h3;
}
h4 {
@extend %h4;
}
h5 {
@extend %h5;
}
h6 {
@extend %h6;
}
p {
margin: var(--base) 0;
@include mid-break {
margin: calc(var(--base) * .75) 0;
}
}
ul,
ol {
padding-left: var(--base);
margin: 0 0 var(--base);
}
a {
color: currentColor;
&:focus {
opacity: .8;
outline: none;
}
&:active {
opacity: .7;
outline: none;
}
}
svg {
vertical-align: middle;
}

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

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

View File

@@ -0,0 +1,28 @@
$breakpoint-xs-width: 400px;
$breakpoint-s-width: 768px;
$breakpoint-m-width: 1024px;
$breakpoint-l-width: 1440px;
@mixin extra-small-break {
@media (max-width: #{$breakpoint-xs-width}) {
@content;
}
}
@mixin small-break {
@media (max-width: #{$breakpoint-s-width}) {
@content;
}
}
@mixin mid-break {
@media (max-width: #{$breakpoint-m-width}) {
@content;
}
}
@mixin large-break {
@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

@@ -1,26 +1,24 @@
import type { AppProps } from 'next/app' import type { AppProps } from 'next/app'
import { AuthProvider } from '../components/Auth'
import { Header } from '../components/Header' import { Header } from '../components/Header'
import { AuthProvider } from '../providers/Auth'
import './app.scss' import '../css/app.scss'
import classes from './index.module.scss'
export default function MyApp({ Component, pageProps }: AppProps) { export default function MyApp({ Component, pageProps }: AppProps) {
return ( return (
// The `AuthProvider` can be used with either REST or GraphQL APIs <AuthProvider
// Just change the `api` prop to "graphql" or "rest", that's it! // To toggle between the REST and GraphQL APIs,
<AuthProvider api="rest"> // change the `api` prop to either `rest` or `gql`
api="rest" // change this to `gql` to use the GraphQL API
>
<Header /> <Header />
<div className={classes.page}>
{/* typescript flags this `@ts-expect-error` declaration as unneeded, but types are breaking the build process {/* typescript flags this `@ts-expect-error` declaration as unneeded, but types are breaking the build process
Remove these comments when the issue is resolved Remove these comments when the issue is resolved
See more here: https://github.com/facebook/react/issues/24304 See more here: https://github.com/facebook/react/issues/24304
*/} */}
{/* @ts-expect-error */} {/* @ts-expect-error */}
<Component {...pageProps} /> <Component {...pageProps} />
</div>
</AuthProvider> </AuthProvider>
) )
} }

View File

@@ -1,17 +0,0 @@
.form {
margin-bottom: 30px;
}
.success,
.error,
.message {
margin-bottom: 30px;
}
.success {
color: green;
}
.error {
color: red;
}

View File

@@ -0,0 +1,32 @@
@import "../../css/common";
.account {
margin-bottom: var(--block-padding);
}
.params {
margin-top: var(--base);
}
.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

@@ -1,32 +1,42 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useAuth } from '../../components/Auth' import { Button } from '../../components/Button'
import { Gutter } from '../../components/Gutter' import { Gutter } from '../../components/Gutter'
import { Input } from '../../components/Input' import { Input } from '../../components/Input'
import classes from './index.module.css' import { Message } from '../../components/Message'
import { RenderParams } from '../../components/RenderParams'
import { useAuth } from '../../providers/Auth'
import classes from './index.module.scss'
type FormData = { type FormData = {
email: string email: string
firstName: string name: string
lastName: string password: string
passwordConfirm: string
} }
const Account: React.FC = () => { const Account: React.FC = () => {
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = useState('') const [success, setSuccess] = useState('')
const { user, setUser } = useAuth() const { user, setUser } = useAuth()
const [changePassword, setChangePassword] = useState(false)
const router = useRouter() const router = useRouter()
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors, isLoading },
reset, reset,
watch,
} = useForm<FormData>() } = useForm<FormData>()
const password = useRef({})
password.current = watch('password', '')
const onSubmit = useCallback( const onSubmit = useCallback(
async (data: FormData) => { async (data: FormData) => {
if (user) { if (user) {
@@ -42,21 +52,22 @@ const Account: React.FC = () => {
if (response.ok) { if (response.ok) {
const json = await response.json() const json = await response.json()
// Update the user in auth state with new values
setUser(json.doc) setUser(json.doc)
// Set success message for user
setSuccess('Successfully updated account.') setSuccess('Successfully updated account.')
// Clear any existing errors
setError('') setError('')
setChangePassword(false)
reset({
email: json.doc.email,
name: json.doc.name,
password: '',
passwordConfirm: '',
})
} else { } else {
setError('There was a problem updating your account.') setError('There was a problem updating your account.')
} }
} }
}, },
[user, setUser], [user, setUser, reset],
) )
useEffect(() => { useEffect(() => {
@@ -68,37 +79,87 @@ const Account: React.FC = () => {
if (user) { if (user) {
reset({ reset({
email: user.email, email: user.email,
firstName: user.firstName, password: '',
lastName: user.lastName, passwordConfirm: '',
}) })
} }
}, [user, reset, router]) }, [user, router, reset, changePassword])
useEffect(() => {
if (typeof router.query.success === 'string') {
setSuccess(router.query.success)
}
}, [router])
return ( return (
<Gutter> <Gutter className={classes.account}>
<RenderParams className={classes.params} />
<h1>Account</h1> <h1>Account</h1>
{router.query.message && <div className={classes.message}>{router.query.message}</div>} <p>
{error && <div className={classes.error}>{error}</div>} {`This is your account dashboard. Here you can update your account information and more. To manage all users, `}
{success && <div className={classes.success}>{success}</div>} <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}> <form onSubmit={handleSubmit(onSubmit)} className={classes.form}>
<Message error={error} success={success} className={classes.message} />
{!changePassword ? (
<Fragment>
<p>
{'Change your account details below, or '}
<button
type="button"
className={classes.changePassword}
onClick={() => setChangePassword(!changePassword)}
>
click here
</button>
{' to change your password.'}
</p>
<Input <Input
name="email" name="email"
label="Email Address" label="Email Address"
required required
register={register} register={register}
error={errors.email} 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"
/> />
<Input name="firstName" label="First Name" register={register} error={errors.firstName} />
<Input name="lastName" label="Last Name" register={register} error={errors.lastName} />
<button type="submit">Update account</button>
</form> </form>
<Link href="/logout">Log out</Link> <Button href="/logout" appearance="secondary" label="Log out" />
</Gutter> </Gutter>
) )
} }

View File

@@ -1,107 +0,0 @@
$breakpoint: 1000px;
:root {
--max-width: 1600px;
--foreground-rgb: 0, 0, 0;
--background-rgb: 255, 255, 255;
--block-spacing: 2rem;
--gutter-h: 4rem;
--base: 1rem;
@media (max-width: $breakpoint) {
--block-spacing: 1rem;
--gutter-h: 2rem;
--base: 0.75rem;
}
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-rgb: 7, 7, 7;
}
}
* {
box-sizing: border-box;
}
html {
font-size: 20px;
line-height: 1.5;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
@media (max-width: $breakpoint) {
font-size: 16px;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
margin: 0;
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
}
img {
height: auto;
max-width: 100%;
display: block;
}
h1 {
font-size: 4.5rem;
line-height: 1.2;
margin: 0 0 2.5rem 0;
@media (max-width: $breakpoint) {
font-size: 3rem;
margin: 0 0 1.5rem 0;
}
}
h2 {
font-size: 3.5rem;
line-height: 1.2;
margin: 0 0 2.5rem 0;
}
h3 {
font-size: 2.5rem;
line-height: 1.2;
margin: 0 0 2rem 0;
}
h4 {
font-size: 1.5rem;
line-height: 1.2;
margin: 0 0 1rem 0;
}
h5 {
font-size: 1.25rem;
line-height: 1.2;
margin: 0 0 1rem 0;
}
h6 {
font-size: 1rem;
line-height: 1.2;
margin: 0 0 1rem 0;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

View File

@@ -1,8 +0,0 @@
.form {
margin-bottom: 30px;
}
.error {
color: red;
margin-bottom: 30px;
}

View File

@@ -0,0 +1,26 @@
@import "../../css/common";
.createAccount {
margin-bottom: var(--block-padding);
}
.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

@@ -1,57 +1,96 @@
import React, { useCallback, useState } from 'react' import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/router'
import { useAuth } from '../../components/Auth' import { Button } from '../../components/Button'
import { Gutter } from '../../components/Gutter' import { Gutter } from '../../components/Gutter'
import { Input } from '../../components/Input' import { Input } from '../../components/Input'
import classes from './index.module.css' import { Message } from '../../components/Message'
import { RenderParams } from '../../components/RenderParams'
import { useAuth } from '../../providers/Auth'
import classes from './index.module.scss'
type FormData = { type FormData = {
email: string email: string
password: string password: string
firstName: string passwordConfirm: string
lastName: string
} }
const CreateAccount: React.FC = () => { const CreateAccount: React.FC = () => {
const [error, setError] = useState('') const router = useRouter()
const [success, setSuccess] = useState(false) const searchParams = useMemo(() => new URLSearchParams(router.query as any), [router.query])
const { login, create, user } = useAuth() const allParams = searchParams.toString() ? `?${searchParams.toString()}` : ''
const { login } = useAuth()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
watch,
} = useForm<FormData>() } = useForm<FormData>()
const password = useRef({})
password.current = watch('password', '')
const onSubmit = useCallback( const onSubmit = useCallback(
async (data: FormData) => { 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 { try {
await create(data as Parameters<typeof create>[0]) await login(data)
// Automatically log the user in after creating their account clearTimeout(timer)
await login({ email: data.email, password: data.password }) if (redirect) router.push(redirect as string)
setSuccess(true) else router.push(`/account?success=${encodeURIComponent('Account created successfully')}`)
} catch (err) { } catch (_) {
setError(err?.message || 'An error occurred while attempting to create your account.') clearTimeout(timer)
setError('There was an error with the credentials provided. Please try again.')
} }
}, },
[login, create], [login, router, searchParams],
) )
return ( return (
<Gutter> <Gutter className={classes.createAccount}>
{!success && (
<React.Fragment>
<h1>Create Account</h1> <h1>Create Account</h1>
{error && <div className={classes.error}>{error}</div>} <RenderParams />
<form onSubmit={handleSubmit(onSubmit)} className={classes.form}> <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 <Input
name="email" name="email"
label="Email Address" label="Email Address"
required required
register={register} register={register}
error={errors.email} error={errors.email}
type="email"
/> />
<Input <Input
name="password" name="password"
@@ -62,27 +101,25 @@ const CreateAccount: React.FC = () => {
error={errors.password} error={errors.password}
/> />
<Input <Input
name="firstName" name="passwordConfirm"
label="First Name" type="password"
label="Confirm Password"
required
register={register} register={register}
error={errors.firstName} validate={value => value === password.current || 'The passwords do not match'}
error={errors.passwordConfirm}
/> />
<Input name="lastName" label="Last Name" register={register} error={errors.lastName} /> <Button
<button type="submit">Create account</button> type="submit"
</form> className={classes.submit}
<p> label={loading ? 'Processing' : 'Create Account'}
appearance="primary"
/>
<div>
{'Already have an account? '} {'Already have an account? '}
<Link href="/login">Login</Link> <Link href={`/login${allParams}`}>Login</Link>
</p> </div>
</React.Fragment> </form>
)}
{success && (
<React.Fragment>
<h1>Account created successfully</h1>
<p>You are now logged in.</p>
<Link href="/account">Go to your account</Link>
</React.Fragment>
)}
</Gutter> </Gutter>
) )
} }

View File

@@ -1,3 +0,0 @@
.page {
margin-top: calc(var(--base) * 2);
}

View File

@@ -1,9 +1,8 @@
import React from 'react'
import Link from 'next/link' import Link from 'next/link'
import { Gutter } from '../components/Gutter' import { Gutter } from '../components/Gutter'
const Home: React.FC = () => { export default function Home() {
return ( return (
<Gutter> <Gutter>
<h1>Payload Auth Example</h1> <h1>Payload Auth Example</h1>
@@ -13,40 +12,34 @@ const Home: React.FC = () => {
Payload Payload
</Link> </Link>
{' + '} {' + '}
<Link href="https://nextjs.org/" target="_blank" rel="noopener noreferrer"> <Link href="https://nextjs.org" target="_blank" rel="noopener noreferrer">
Next.js Next.js
</Link> </Link>
{' app using the '} {' app using the '}
<Link href="https://nextjs.org/docs/pages" target="_blank" rel="noopener noreferrer"> <Link href="https://nextjs.org/docs/pages" target="_blank" rel="noopener noreferrer">
Pages Router Pages Router
</Link> </Link>
{" made explicitly for Payload's "} {' made explicitly for the '}
<Link <Link href="https://github.com/payloadcms/payload/tree/master/examples/auth">
href="https://github.com/payloadcms/payload/tree/master/examples/auth/cms" Payload Auth Example
target="_blank"
rel="noopener noreferrer"
>
Auth Example
</Link> </Link>
{". This example demonstrates how to implement Payload's "} {". This example demonstrates how to implement Payload's "}
<Link <Link href="https://payloadcms.com/docs/authentication/overview">Authentication</Link>
href="https://payloadcms.com/docs/authentication/overview" {
target="_blank" ' strategies in both the REST and GraphQL APIs. To toggle between these APIs, see `_app.tsx`.'
rel="noopener noreferrer" }
>
Authentication
</Link>
{' strategies in both the REST and GraphQL APIs.'}
</p> </p>
<p> <p>
{'Visit the '} {'Visit the '}
<Link href="/login">Login</Link> <Link href="/login">login page</Link>
{' page to start the authentication flow. Once logged in, you will be redirected to the '} {' to start the authentication flow. Once logged in, you will be redirected to the '}
<Link href="/account">Account</Link> <Link href="/account">account page</Link>
{` page which is restricted to users only. To toggle APIs, simply toggle the "api" prop between "rest" and "gql" in "_app.tsx".`} {` 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> </p>
</Gutter> </Gutter>
) )
} }
export default Home

View File

@@ -1,8 +0,0 @@
.form {
margin-bottom: 30px;
}
.error {
color: red;
margin-bottom: 30px;
}

View File

@@ -0,0 +1,30 @@
@import "../../css/common";
.login {
margin-bottom: var(--block-padding);
}
.params {
margin-top: var(--base);
}
.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

@@ -1,12 +1,16 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useMemo, useRef } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useAuth } from '../../components/Auth' import { Button } from '../../components/Button'
import { Gutter } from '../../components/Gutter' import { Gutter } from '../../components/Gutter'
import { Input } from '../../components/Input' import { Input } from '../../components/Input'
import classes from './index.module.css' import { Message } from '../../components/Message'
import { RenderParams } from '../../components/RenderParams'
import { useAuth } from '../../providers/Auth'
import classes from './index.module.scss'
type FormData = { type FormData = {
email: string email: string
@@ -14,52 +18,61 @@ type FormData = {
} }
const Login: React.FC = () => { const Login: React.FC = () => {
const [error, setError] = useState('')
const router = useRouter() const router = useRouter()
const { login, user } = useAuth() const searchParams = useMemo(() => new URLSearchParams(router.query as any), [router.query])
const allParams = searchParams.toString() ? `?${searchParams.toString()}` : ''
const redirect = useRef(searchParams.get('redirect'))
const { login } = useAuth()
const [error, setError] = React.useState<string | null>(null)
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors, isLoading },
} = useForm<FormData>() } = useForm<FormData>({
defaultValues: {
email: 'demo@payloadcms.com',
password: 'demo',
},
})
const onSubmit = useCallback( const onSubmit = useCallback(
async (data: FormData) => { async (data: FormData) => {
try { try {
await login(data) await login(data)
router.push('/account') if (redirect?.current) router.push(redirect.current as string)
} catch (err) { else router.push('/account')
setError(err?.message || 'An error occurred while attempting to login.') } catch (_) {
setError('There was an error with the credentials provided. Please try again.')
} }
}, },
[login, router], [login, router],
) )
useEffect(() => {
if (router.query.unauthorized) {
setError(`To visit the ${router.query.unauthorized} page, you need to be logged in.`)
}
}, [router])
if (user) {
router.push('/account')
}
return ( return (
<Gutter> <Gutter className={classes.login}>
<RenderParams className={classes.params} />
<h1>Log in</h1> <h1>Log in</h1>
<p>
To log in, use the email <b>demo@payloadcms.com</b> with the password <b>demo</b>.
</p>
{error && <div className={classes.error}>{error}</div>}
<form onSubmit={handleSubmit(onSubmit)} className={classes.form}> <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 <Input
name="email" name="email"
label="Email Address" label="Email Address"
required required
register={register} register={register}
error={errors.email} error={errors.email}
type="email"
/> />
<Input <Input
name="password" name="password"
@@ -69,11 +82,19 @@ const Login: React.FC = () => {
register={register} register={register}
error={errors.password} error={errors.password}
/> />
<input type="submit" /> <Button
</form> type="submit"
<Link href="/create-account">Create an account</Link> disabled={isLoading}
className={classes.submit}
label={isLoading ? 'Processing' : 'Login'}
appearance="primary"
/>
<div>
<Link href={`/create-account${allParams}`}>Create an account</Link>
<br /> <br />
<Link href="/recover-password">Recover your password</Link> <Link href={`/recover-password${allParams}`}>Recover your password</Link>
</div>
</form>
</Gutter> </Gutter>
) )
} }

View File

@@ -1,4 +0,0 @@
.error {
color: red;
margin-bottom: 30px;
}

View File

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

View File

@@ -1,9 +1,10 @@
import React, { Fragment, useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useAuth } from '../../components/Auth'
import { Gutter } from '../../components/Gutter' import { Gutter } from '../../components/Gutter'
import classes from './index.module.css' import { useAuth } from '../../providers/Auth'
import classes from './index.module.scss'
const Logout: React.FC = () => { const Logout: React.FC = () => {
const { logout } = useAuth() const { logout } = useAuth()
@@ -15,8 +16,8 @@ const Logout: React.FC = () => {
try { try {
await logout() await logout()
setSuccess('Logged out successfully.') setSuccess('Logged out successfully.')
} catch (err) { } catch (_) {
setError(err?.message || 'An error occurred while attempting to logout.') setError('You are already logged out.')
} }
} }
@@ -24,17 +25,19 @@ const Logout: React.FC = () => {
}, [logout]) }, [logout])
return ( return (
<Gutter> <Gutter className={classes.logout}>
{success && <h1>{success}</h1>} {(error || success) && (
{error && <div className={classes.error}>{error}</div>} <div>
<h1>{error || success}</h1>
<p> <p>
{'What would you like to do next? '} {'What would you like to do next? '}
<Fragment> <Link href="/">Click here</Link>
{' To log back in, '} {` to go to the home page. To log back in, `}
<Link href={`/login`}>click here</Link> <Link href="login">click here</Link>
{'.'} {'.'}
</Fragment>
</p> </p>
</div>
)}
</Gutter> </Gutter>
) )
} }

View File

@@ -1,4 +0,0 @@
.error {
color: red;
margin-bottom: 30px;
}

View File

@@ -0,0 +1,27 @@
@import "../../css/common";
.recoverPassword {
margin-bottom: var(--block-padding);
}
.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

@@ -1,10 +1,13 @@
import React, { useCallback, useState } from 'react' import React, { useCallback, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import Link from 'next/link'
import { useAuth } from '../../components/Auth' import { Button } from '../../components/Button'
import { Gutter } from '../../components/Gutter' import { Gutter } from '../../components/Gutter'
import { Input } from '../../components/Input' import { Input } from '../../components/Input'
import classes from './index.module.css' import { Message } from '../../components/Message'
import classes from './index.module.scss'
type FormData = { type FormData = {
email: string email: string
@@ -13,7 +16,6 @@ type FormData = {
const RecoverPassword: React.FC = () => { const RecoverPassword: React.FC = () => {
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
const { forgotPassword } = useAuth()
const { const {
register, register,
@@ -21,42 +23,57 @@ const RecoverPassword: React.FC = () => {
formState: { errors }, formState: { errors },
} = useForm<FormData>() } = useForm<FormData>()
const onSubmit = useCallback( const onSubmit = useCallback(async (data: FormData) => {
async (data: FormData) => { const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/forgot-password`, {
try { method: 'POST',
const user = await forgotPassword(data as Parameters<typeof forgotPassword>[0]) body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
})
if (user) { if (response.ok) {
setSuccess(true) setSuccess(true)
setError('') setError('')
} } else {
} catch (err) { setError(
setError(err?.message || 'An error occurred while attempting to recover password.') 'There was a problem while attempting to send you a password reset email. Please try again.',
}
},
[forgotPassword],
) )
}
}, [])
return ( return (
<Gutter> <Gutter className={classes.recoverPassword}>
{!success && ( {!success && (
<React.Fragment> <React.Fragment>
<h1>Recover Password</h1> <h1>Recover Password</h1>
<div className={classes.formWrapper}>
<p> <p>
Please enter your email below. You will receive an email message with instructions on {`Please enter your email below. You will receive an email message with instructions on
how to reset your password. 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> </p>
{error && <div className={classes.error}>{error}</div>} <form onSubmit={handleSubmit(onSubmit)} className={classes.form}>
<form onSubmit={handleSubmit(onSubmit)}> <Message error={error} className={classes.message} />
<Input <Input
name="email" name="email"
label="Email Address" label="Email Address"
required required
register={register} register={register}
error={errors.email} error={errors.email}
type="email"
/>
<Button
type="submit"
className={classes.submit}
label="Recover Password"
appearance="primary"
/> />
<button type="submit">Submit</button>
</form> </form>
</div>
</React.Fragment> </React.Fragment>
)} )}
{success && ( {success && (

View File

@@ -1,4 +0,0 @@
.error {
color: red;
margin-bottom: 30px;
}

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