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 React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { User } from '../../payload-types' import { User } from '../../payload-types'
import { gql, USER } from './gql'
type Login = (args: { email: string; password: string }) => Promise<void> // eslint-disable-line no-unused-vars import { rest } from './rest'
import { AuthContext, Create, ForgotPassword, Login, Logout, ResetPassword } from './types'
type Logout = () => Promise<void>
type AuthContext = {
user?: User | null
setUser: (user: User | null) => void // eslint-disable-line no-unused-vars
logout: Logout
login: Login
}
const Context = createContext({} as AuthContext) 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 [user, setUser] = useState<User | null>()
const login = useCallback<Login>(async args => { const create = useCallback<Create>(
const res = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/login`, { async args => {
method: 'POST', if (api === 'rest') {
body: JSON.stringify(args), const user = await rest(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users`, args)
credentials: 'include', setUser(user)
headers: { return user
'Content-Type': 'application/json', }
},
})
if (res.ok) { if (api === 'gql') {
const json = await res.json() const { createUser: user } = await gql(`mutation {
setUser(json.user) createUser(data: { email: "${args.email}", password: "${args.password}", firstName: "${args.firstName}", lastName: "${args.lastName}" }) {
} else { ${USER}
throw new Error('Invalid login') }
} }`)
}, [])
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 logout = useCallback<Logout>(async () => {
const res = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/logout`, { if (api === 'rest') {
method: 'POST', await rest(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/logout`)
// Make sure to include cookies with fetch
credentials: 'include',
})
if (res.ok) {
setUser(null) setUser(null)
} else { return
throw new Error('There was a problem while logging out.')
} }
}, [])
if (api === 'gql') {
await gql(`mutation {
logoutUser
}`)
setUser(null)
}
}, [api])
// On mount, get user and set // On mount, get user and set
useEffect(() => { useEffect(() => {
const fetchMe = async () => { const fetchMe = async () => {
const result = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/me`, { if (api === 'rest') {
// Make sure to include cookies with fetch const user = await rest(
credentials: 'include', `${process.env.NEXT_PUBLIC_CMS_URL}/api/users/me`,
}).then(req => req.json()) {},
setUser(result.user || null) {
method: 'GET',
},
)
setUser(user)
}
if (api === 'gql') {
const { meUser } = await gql(`query {
meUser {
user {
${USER}
}
exp
}
}`)
setUser(meUser.user)
}
} }
fetchMe() 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 ( return (
<Context.Provider <Context.Provider
@@ -70,6 +160,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setUser, setUser,
login, login,
logout, logout,
create,
resetPassword,
forgotPassword,
}} }}
> >
{children} {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 React from 'react'
import { FieldValues, UseFormRegister } from 'react-hook-form'; import { FieldValues, UseFormRegister } from 'react-hook-form'
import classes from './index.module.css'; import classes from './index.module.css'
type Props = { type Props = {
name: string; name: string
label: string; label: string
register: UseFormRegister<FieldValues & any>; register: UseFormRegister<FieldValues & any>
required?: boolean; required?: boolean
error: any; error: any
type?: 'text' | 'number' | 'password'; 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 ( return (
<div className={classes.input}> <div className={classes.input}>
<label htmlFor="name" className={classes.label}> <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 })} /> <input {...{ type }} {...register(name, { required })} />
{error && <div className={classes.error}>This field is required</div>} {error && <div className={classes.error}>This field is required</div>}
</div> </div>
); )
}; }

View File

@@ -7,7 +7,9 @@ import '../css/app.scss'
export default function MyApp({ Component, pageProps }: AppProps) { export default function MyApp({ Component, pageProps }: AppProps) {
return ( 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 /> <Header />
<Component {...pageProps} /> <Component {...pageProps} />
</AuthProvider> </AuthProvider>

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ type FormData = {
const CreateAccount: React.FC = () => { const CreateAccount: React.FC = () => {
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
const { login } = useAuth() const { login, create, user } = useAuth()
const { const {
register, register,
@@ -27,28 +27,16 @@ const CreateAccount: React.FC = () => {
const onSubmit = useCallback( const onSubmit = useCallback(
async (data: FormData) => { async (data: FormData) => {
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users`, { try {
method: 'POST', await create(data as Parameters<typeof create>[0])
body: JSON.stringify(data), // Automatically log the user in after creating their account
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) {
// Automatically log the user in
await login({ email: data.email, password: data.password }) await login({ email: data.email, password: data.password })
// Set success message for user
setSuccess(true) setSuccess(true)
} catch (err) {
// Clear any existing errors setError(err?.message || 'An error occurred while attempting to create your account.')
setError('')
} else {
setError('There was a problem creating your account. Please try again later.')
} }
}, },
[login], [login, create],
) )
return ( return (

View File

@@ -16,14 +16,14 @@ const Home: React.FC = () => {
</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.'} {' 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</Link>
{' page to start the authentication flow. Once logged in, you will be redirected to the '} {' page to start the authentication flow. Once logged in, you will be redirected to the '}
<Link href="/account">Account</Link> <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> </p>
</Gutter> </Gutter>
) )

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
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 { useAuth } from '../../components/Auth'
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 classes from './index.module.css'
@@ -12,6 +13,7 @@ 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,
@@ -19,27 +21,21 @@ const RecoverPassword: React.FC = () => {
formState: { errors }, formState: { errors },
} = useForm<FormData>() } = useForm<FormData>()
const onSubmit = useCallback(async (data: FormData) => { const onSubmit = useCallback(
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/forgot-password`, { async (data: FormData) => {
method: 'POST', try {
body: JSON.stringify(data), const user = await forgotPassword(data as Parameters<typeof forgotPassword>[0])
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) { if (user) {
// Set success message for user setSuccess(true)
setSuccess(true) setError('')
}
// Clear any existing errors } catch (err) {
setError('') setError(err?.message || 'An error occurred while attempting to recover password.')
} else { }
setError( },
'There was a problem while attempting to send you a password reset email. Please try again later.', [forgotPassword],
) )
}
}, [])
return ( return (
<Gutter> <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 ResetPassword: React.FC = () => {
const [error, setError] = useState('') const [error, setError] = useState('')
const { login } = useAuth() const { login, resetPassword } = useAuth()
const router = useRouter() const router = useRouter()
const token = typeof router.query.token === 'string' ? router.query.token : undefined const token = typeof router.query.token === 'string' ? router.query.token : undefined
@@ -28,31 +28,23 @@ const ResetPassword: React.FC = () => {
const onSubmit = useCallback( const onSubmit = useCallback(
async (data: FormData) => { async (data: FormData) => {
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/reset-password`, { try {
method: 'POST', const user = await resetPassword(data as Parameters<typeof resetPassword>[0])
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) { if (user) {
const json = await response.json() // Automatically log the user in after they successfully reset password
// Then redirect them to /account with success message in URL
// Automatically log the user in after they successfully reset password await login({ email: user.email, password: data.password })
await login({ email: json.user.email, password: data.password }) router.push('/account?success=Password reset successfully.')
}
// Redirect them to /account with success message in URL } catch (err) {
router.push('/account?success=Password reset successfully.') setError(err?.message || 'An error occurred while attempting to reset password.')
} else {
setError('There was a problem while resetting your password. Please try again later.')
} }
}, },
[router, login], [router, login, resetPassword],
) )
// when NextJS populates token within router, // When Next.js populates token within router, reset form with new token value
// reset form with new token value
useEffect(() => { useEffect(() => {
reset({ token }) reset({ token })
}, [reset, token]) }, [reset, token])