feat: adds gql auth example (#2115)

This commit is contained in:
Jacob Fletcher
2023-02-16 22:31:41 -05:00
committed by GitHub
parent ebdfd8f69a
commit fa32c27716
18 changed files with 316 additions and 132 deletions

View File

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

View File

@@ -1,67 +1,157 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { User } from '../../payload-types'
type Login = (args: { email: string; password: string }) => Promise<void> // eslint-disable-line no-unused-vars
type Logout = () => Promise<void>
type AuthContext = {
user?: User | null
setUser: (user: User | null) => void // eslint-disable-line no-unused-vars
logout: Logout
login: Login
}
import { gql, USER } from './gql'
import { rest } from './rest'
import { AuthContext, Create, ForgotPassword, Login, Logout, ResetPassword } from './types'
const Context = createContext({} as AuthContext)
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
export const AuthProvider: React.FC<{ children: React.ReactNode; api?: 'rest' | 'gql' }> = ({
children,
api = 'rest',
}) => {
const [user, setUser] = useState<User | null>()
const login = useCallback<Login>(async args => {
const res = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/login`, {
method: 'POST',
body: JSON.stringify(args),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
const create = useCallback<Create>(
async args => {
if (api === 'rest') {
const user = await rest(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users`, args)
setUser(user)
return user
}
if (res.ok) {
const json = await res.json()
setUser(json.user)
} else {
throw new Error('Invalid login')
}
}, [])
if (api === 'gql') {
const { createUser: user } = await gql(`mutation {
createUser(data: { email: "${args.email}", password: "${args.password}", firstName: "${args.firstName}", lastName: "${args.lastName}" }) {
${USER}
}
}`)
setUser(user)
return user
}
},
[api],
)
const login = useCallback<Login>(
async args => {
if (api === 'rest') {
const user = await rest(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/login`, args)
setUser(user)
return user
}
if (api === 'gql') {
const { loginUser } = await gql(`mutation {
loginUser(email: "${args.email}", password: "${args.password}") {
user {
${USER}
}
exp
}
}`)
setUser(loginUser?.user)
return loginUser?.user
}
},
[api],
)
const logout = useCallback<Logout>(async () => {
const res = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/logout`, {
method: 'POST',
// Make sure to include cookies with fetch
credentials: 'include',
})
if (res.ok) {
if (api === 'rest') {
await rest(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/logout`)
setUser(null)
} else {
throw new Error('There was a problem while logging out.')
return
}
}, [])
if (api === 'gql') {
await gql(`mutation {
logoutUser
}`)
setUser(null)
}
}, [api])
// On mount, get user and set
useEffect(() => {
const fetchMe = async () => {
const result = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/me`, {
// Make sure to include cookies with fetch
credentials: 'include',
}).then(req => req.json())
setUser(result.user || null)
if (api === 'rest') {
const user = await rest(
`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/me`,
{},
{
method: 'GET',
},
)
setUser(user)
}
if (api === 'gql') {
const { meUser } = await gql(`query {
meUser {
user {
${USER}
}
exp
}
}`)
setUser(meUser.user)
}
}
fetchMe()
}, [])
}, [api])
const forgotPassword = useCallback<ForgotPassword>(
async args => {
if (api === 'rest') {
const user = await rest(
`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/forgot-password`,
args,
)
setUser(user)
return user
}
if (api === 'gql') {
const { forgotPasswordUser } = await gql(`mutation {
forgotPasswordUser(email: "${args.email}")
}`)
return forgotPasswordUser
}
},
[api],
)
const resetPassword = useCallback<ResetPassword>(
async args => {
if (api === 'rest') {
const user = await rest(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/reset-password`, args)
setUser(user)
return user
}
if (api === 'gql') {
const { resetPasswordUser } = await gql(`mutation {
resetPasswordUser(password: "${args.password}", token: "${args.token}") {
user {
${USER}
}
}
}`)
setUser(resetPasswordUser.user)
return resetPasswordUser.user
}
},
[api],
)
return (
<Context.Provider
@@ -70,6 +160,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setUser,
login,
logout,
create,
resetPassword,
forgotPassword,
}}
>
{children}

View File

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

View File

@@ -0,0 +1,31 @@
import type { User } from '../../payload-types'
// eslint-disable-next-line no-unused-vars
export type ResetPassword = (args: {
password: string
passwordConfirm: string
token: string
}) => Promise<User>
export type ForgotPassword = (args: { email: string }) => Promise<User> // eslint-disable-line no-unused-vars
export type Create = (args: {
email: string
password: string
firstName: string
lastName: string
}) => Promise<User> // eslint-disable-line no-unused-vars
export type Login = (args: { email: string; password: string }) => Promise<User> // eslint-disable-line no-unused-vars
export type Logout = () => Promise<void>
export interface AuthContext {
user?: User | null
setUser: (user: User | null) => void // eslint-disable-line no-unused-vars
logout: Logout
login: Login
create: Create
resetPassword: ResetPassword
forgotPassword: ForgotPassword
}

View File

@@ -1,18 +1,25 @@
import React from 'react';
import { FieldValues, UseFormRegister } from 'react-hook-form';
import React from 'react'
import { FieldValues, UseFormRegister } from 'react-hook-form'
import classes from './index.module.css';
import classes from './index.module.css'
type Props = {
name: string;
label: string;
register: UseFormRegister<FieldValues & any>;
required?: boolean;
error: any;
type?: 'text' | 'number' | 'password';
};
name: string
label: string
register: UseFormRegister<FieldValues & any>
required?: boolean
error: any
type?: 'text' | 'number' | 'password'
}
export const Input: React.FC<Props> = ({ name, label, required, register, error, type = 'text' }) => {
export const Input: React.FC<Props> = ({
name,
label,
required,
register,
error,
type = 'text',
}) => {
return (
<div className={classes.input}>
<label htmlFor="name" className={classes.label}>
@@ -21,5 +28,5 @@ export const Input: React.FC<Props> = ({ name, label, required, register, error,
<input {...{ type }} {...register(name, { required })} />
{error && <div className={classes.error}>This field is required</div>}
</div>
);
};
)
}

View File

@@ -7,7 +7,9 @@ import '../css/app.scss'
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<AuthProvider>
// The `AuthProvider` can be used with either REST or GraphQL APIs
// Just change the `api` prop to "graphql" or "rest", that's it!
<AuthProvider api="rest">
<Header />
<Component {...pageProps} />
</AuthProvider>

View File

@@ -3,8 +3,9 @@
}
.success,
.error {
margin-bottom: 15px;
.error,
.message {
margin-bottom: 30px;
}
.success {
@@ -13,4 +14,4 @@
.error {
color: red;
}
}

View File

@@ -83,6 +83,7 @@ const Account: React.FC = () => {
return (
<Gutter>
<h1>Account</h1>
{router.query.message && <div className={classes.message}>{router.query.message}</div>}
{error && <div className={classes.error}>{error}</div>}
{success && <div className={classes.success}>{success}</div>}
<form onSubmit={handleSubmit(onSubmit)} className={classes.form}>

View File

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

View File

@@ -17,7 +17,7 @@ type FormData = {
const CreateAccount: React.FC = () => {
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const { login } = useAuth()
const { login, create, user } = useAuth()
const {
register,
@@ -27,28 +27,16 @@ const CreateAccount: React.FC = () => {
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) {
// Automatically log the user in
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 })
// Set success message for user
setSuccess(true)
// Clear any existing errors
setError('')
} else {
setError('There was a problem creating your account. Please try again later.')
} catch (err) {
setError(err?.message || 'An error occurred while attempting to create your account.')
}
},
[login],
[login, create],
)
return (

View File

@@ -16,14 +16,14 @@ const Home: React.FC = () => {
</Link>
{". This example demonstrates how to implement Payload's "}
<Link href="https://payloadcms.com/docs/authentication/overview">Authentication</Link>
{' strategies.'}
{' strategies in both the REST and GraphQL APIs.'}
</p>
<p>
{'Visit the '}
<Link href="/login">Login</Link>
{' page to start the authentication flow. Once logged in, you will be redirected to the '}
<Link href="/account">Account</Link>
{" page which is restricted to user's only."}
{` page which is restricted to user's only. To toggle APIs, simply toggle the "api" prop in _app.tsx between "rest" and "gql".`}
</p>
</Gutter>
)

View File

@@ -29,8 +29,8 @@ const Login: React.FC = () => {
try {
await login(data)
router.push('/account')
} catch (_) {
setError('There was an error with the credentials provided. Please try again.')
} catch (err) {
setError(err?.message || 'An error occurred while attempting to login.')
}
},
[login, router],

View File

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

View File

@@ -15,8 +15,8 @@ const Logout: React.FC = () => {
try {
await logout()
setSuccess('Logged out successfully.')
} catch (_) {
setError('You are already logged out.')
} catch (err) {
setError(err?.message || 'An error occurred while attempting to logout.')
}
}

View File

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

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useAuth } from '../../components/Auth'
import { Gutter } from '../../components/Gutter'
import { Input } from '../../components/Input'
import classes from './index.module.css'
@@ -12,6 +13,7 @@ type FormData = {
const RecoverPassword: React.FC = () => {
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const { forgotPassword } = useAuth()
const {
register,
@@ -19,27 +21,21 @@ const RecoverPassword: React.FC = () => {
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',
},
})
const onSubmit = useCallback(
async (data: FormData) => {
try {
const user = await forgotPassword(data as Parameters<typeof forgotPassword>[0])
if (response.ok) {
// Set success message for user
setSuccess(true)
// Clear any existing errors
setError('')
} else {
setError(
'There was a problem while attempting to send you a password reset email. Please try again later.',
)
}
}, [])
if (user) {
setSuccess(true)
setError('')
}
} catch (err) {
setError(err?.message || 'An error occurred while attempting to recover password.')
}
},
[forgotPassword],
)
return (
<Gutter>

View File

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

View File

@@ -14,7 +14,7 @@ type FormData = {
const ResetPassword: React.FC = () => {
const [error, setError] = useState('')
const { login } = useAuth()
const { login, resetPassword } = useAuth()
const router = useRouter()
const token = typeof router.query.token === 'string' ? router.query.token : undefined
@@ -28,31 +28,23 @@ const ResetPassword: React.FC = () => {
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',
},
})
try {
const user = await resetPassword(data as Parameters<typeof resetPassword>[0])
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.')
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) {
setError(err?.message || 'An error occurred while attempting to reset password.')
}
},
[router, login],
[router, login, resetPassword],
)
// when NextJS populates token within router,
// reset form with new token value
// When Next.js populates token within router, reset form with new token value
useEffect(() => {
reset({ token })
}, [reset, token])