From fa32c2771637af11d7ef0fb21b2f1f3cceae1ead Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 16 Feb 2023 22:31:41 -0500 Subject: [PATCH] feat: adds gql auth example (#2115) --- examples/auth/nextjs/components/Auth/gql.ts | 34 ++++ .../auth/nextjs/components/Auth/index.tsx | 181 +++++++++++++----- examples/auth/nextjs/components/Auth/rest.ts | 34 ++++ examples/auth/nextjs/components/Auth/types.ts | 31 +++ .../auth/nextjs/components/Input/index.tsx | 33 ++-- examples/auth/nextjs/pages/_app.tsx | 4 +- .../nextjs/pages/account/index.module.css | 7 +- examples/auth/nextjs/pages/account/index.tsx | 1 + .../pages/create-account/index.module.css | 3 +- .../nextjs/pages/create-account/index.tsx | 26 +-- examples/auth/nextjs/pages/index.tsx | 4 +- examples/auth/nextjs/pages/login/index.tsx | 4 +- .../auth/nextjs/pages/logout/index.module.css | 4 +- examples/auth/nextjs/pages/logout/index.tsx | 4 +- .../pages/recover-password/index.module.css | 4 +- .../nextjs/pages/recover-password/index.tsx | 36 ++-- .../pages/reset-password/index.module.css | 4 + .../nextjs/pages/reset-password/index.tsx | 34 ++-- 18 files changed, 316 insertions(+), 132 deletions(-) create mode 100644 examples/auth/nextjs/components/Auth/gql.ts create mode 100644 examples/auth/nextjs/components/Auth/rest.ts create mode 100644 examples/auth/nextjs/components/Auth/types.ts diff --git a/examples/auth/nextjs/components/Auth/gql.ts b/examples/auth/nextjs/components/Auth/gql.ts new file mode 100644 index 0000000000..4e64766c73 --- /dev/null +++ b/examples/auth/nextjs/components/Auth/gql.ts @@ -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 => { + 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) + } +} diff --git a/examples/auth/nextjs/components/Auth/index.tsx b/examples/auth/nextjs/components/Auth/index.tsx index 11dbb2f6ac..0a9c7453bf 100644 --- a/examples/auth/nextjs/components/Auth/index.tsx +++ b/examples/auth/nextjs/components/Auth/index.tsx @@ -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 // eslint-disable-line no-unused-vars - -type Logout = () => Promise - -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() - const login = useCallback(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( + 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( + 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(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( + 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( + 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 ( = ({ children setUser, login, logout, + create, + resetPassword, + forgotPassword, }} > {children} diff --git a/examples/auth/nextjs/components/Auth/rest.ts b/examples/auth/nextjs/components/Auth/rest.ts new file mode 100644 index 0000000000..0ba3f571ad --- /dev/null +++ b/examples/auth/nextjs/components/Auth/rest.ts @@ -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 => { + 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) + } +} diff --git a/examples/auth/nextjs/components/Auth/types.ts b/examples/auth/nextjs/components/Auth/types.ts new file mode 100644 index 0000000000..7911d6a4f3 --- /dev/null +++ b/examples/auth/nextjs/components/Auth/types.ts @@ -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 + +export type ForgotPassword = (args: { email: string }) => Promise // eslint-disable-line no-unused-vars + +export type Create = (args: { + email: string + password: string + firstName: string + lastName: string +}) => Promise // eslint-disable-line no-unused-vars + +export type Login = (args: { email: string; password: string }) => Promise // eslint-disable-line no-unused-vars + +export type Logout = () => Promise + +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 +} diff --git a/examples/auth/nextjs/components/Input/index.tsx b/examples/auth/nextjs/components/Input/index.tsx index b833a93402..8d4aaf5b4e 100644 --- a/examples/auth/nextjs/components/Input/index.tsx +++ b/examples/auth/nextjs/components/Input/index.tsx @@ -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; - required?: boolean; - error: any; - type?: 'text' | 'number' | 'password'; -}; + name: string + label: string + register: UseFormRegister + required?: boolean + error: any + type?: 'text' | 'number' | 'password' +} -export const Input: React.FC = ({ name, label, required, register, error, type = 'text' }) => { +export const Input: React.FC = ({ + name, + label, + required, + register, + error, + type = 'text', +}) => { return (
- ); -}; + ) +} diff --git a/examples/auth/nextjs/pages/_app.tsx b/examples/auth/nextjs/pages/_app.tsx index 5e7f0c6ded..0b61139c4a 100644 --- a/examples/auth/nextjs/pages/_app.tsx +++ b/examples/auth/nextjs/pages/_app.tsx @@ -7,7 +7,9 @@ import '../css/app.scss' export default function MyApp({ Component, pageProps }: AppProps) { return ( - + // The `AuthProvider` can be used with either REST or GraphQL APIs + // Just change the `api` prop to "graphql" or "rest", that's it! +
diff --git a/examples/auth/nextjs/pages/account/index.module.css b/examples/auth/nextjs/pages/account/index.module.css index 4c74f26f6a..9a5decbddb 100644 --- a/examples/auth/nextjs/pages/account/index.module.css +++ b/examples/auth/nextjs/pages/account/index.module.css @@ -3,8 +3,9 @@ } .success, -.error { - margin-bottom: 15px; +.error, +.message { + margin-bottom: 30px; } .success { @@ -13,4 +14,4 @@ .error { color: red; -} \ No newline at end of file +} diff --git a/examples/auth/nextjs/pages/account/index.tsx b/examples/auth/nextjs/pages/account/index.tsx index e019d08ce2..b2678c71b2 100644 --- a/examples/auth/nextjs/pages/account/index.tsx +++ b/examples/auth/nextjs/pages/account/index.tsx @@ -83,6 +83,7 @@ const Account: React.FC = () => { return (

Account

+ {router.query.message &&
{router.query.message}
} {error &&
{error}
} {success &&
{success}
}
diff --git a/examples/auth/nextjs/pages/create-account/index.module.css b/examples/auth/nextjs/pages/create-account/index.module.css index 8df9d624ae..c873f1ac88 100644 --- a/examples/auth/nextjs/pages/create-account/index.module.css +++ b/examples/auth/nextjs/pages/create-account/index.module.css @@ -4,4 +4,5 @@ .error { color: red; -} \ No newline at end of file + margin-bottom: 30px; +} diff --git a/examples/auth/nextjs/pages/create-account/index.tsx b/examples/auth/nextjs/pages/create-account/index.tsx index d4eec81672..217b1d99e7 100644 --- a/examples/auth/nextjs/pages/create-account/index.tsx +++ b/examples/auth/nextjs/pages/create-account/index.tsx @@ -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[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 ( diff --git a/examples/auth/nextjs/pages/index.tsx b/examples/auth/nextjs/pages/index.tsx index 30bcaeb6bc..9be8a3cdc4 100644 --- a/examples/auth/nextjs/pages/index.tsx +++ b/examples/auth/nextjs/pages/index.tsx @@ -16,14 +16,14 @@ const Home: React.FC = () => { {". This example demonstrates how to implement Payload's "} Authentication - {' strategies.'} + {' strategies in both the REST and GraphQL APIs.'}

{'Visit the '} Login {' page to start the authentication flow. Once logged in, you will be redirected to the '} Account - {" 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".`}

) diff --git a/examples/auth/nextjs/pages/login/index.tsx b/examples/auth/nextjs/pages/login/index.tsx index d42153ca02..dec13ecdd7 100644 --- a/examples/auth/nextjs/pages/login/index.tsx +++ b/examples/auth/nextjs/pages/login/index.tsx @@ -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], diff --git a/examples/auth/nextjs/pages/logout/index.module.css b/examples/auth/nextjs/pages/logout/index.module.css index b4fefe9e4c..7d2f0a0af1 100644 --- a/examples/auth/nextjs/pages/logout/index.module.css +++ b/examples/auth/nextjs/pages/logout/index.module.css @@ -1,4 +1,4 @@ .error { color: red; - margin-bottom: 15px; -} \ No newline at end of file + margin-bottom: 30px; +} diff --git a/examples/auth/nextjs/pages/logout/index.tsx b/examples/auth/nextjs/pages/logout/index.tsx index 2f7b5ac10a..bebbef3435 100644 --- a/examples/auth/nextjs/pages/logout/index.tsx +++ b/examples/auth/nextjs/pages/logout/index.tsx @@ -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.') } } diff --git a/examples/auth/nextjs/pages/recover-password/index.module.css b/examples/auth/nextjs/pages/recover-password/index.module.css index b4fefe9e4c..7d2f0a0af1 100644 --- a/examples/auth/nextjs/pages/recover-password/index.module.css +++ b/examples/auth/nextjs/pages/recover-password/index.module.css @@ -1,4 +1,4 @@ .error { color: red; - margin-bottom: 15px; -} \ No newline at end of file + margin-bottom: 30px; +} diff --git a/examples/auth/nextjs/pages/recover-password/index.tsx b/examples/auth/nextjs/pages/recover-password/index.tsx index c254d45589..fac6f3099b 100644 --- a/examples/auth/nextjs/pages/recover-password/index.tsx +++ b/examples/auth/nextjs/pages/recover-password/index.tsx @@ -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() - 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[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 ( diff --git a/examples/auth/nextjs/pages/reset-password/index.module.css b/examples/auth/nextjs/pages/reset-password/index.module.css index e69de29bb2..7d2f0a0af1 100644 --- a/examples/auth/nextjs/pages/reset-password/index.module.css +++ b/examples/auth/nextjs/pages/reset-password/index.module.css @@ -0,0 +1,4 @@ +.error { + color: red; + margin-bottom: 30px; +} diff --git a/examples/auth/nextjs/pages/reset-password/index.tsx b/examples/auth/nextjs/pages/reset-password/index.tsx index b3c40bb52e..1030e5bb53 100644 --- a/examples/auth/nextjs/pages/reset-password/index.tsx +++ b/examples/auth/nextjs/pages/reset-password/index.tsx @@ -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[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])