Account Functionality
Used in this guide:Introduction
Payload's authentication functionality is tied to its email features, allowing you to send emails for verification, password resets, and more. Payload provides default email templates, but you can customize them to match your brand. Email verification helps reduce spam accounts and ensures users can access the email address they use to authenticate.
For more info, visit: Payload Email Functionality
Requirement
Before you proceed you should know how to properly setup Payload in your existing Next.js project.
Visit: Add Payload to existing Next.js project
And : Integrate PostgreSQL with Payload
And : Define Separated Users Collection
1. Install the Nodemailer Email Adapter
First, install the Nodemailer adapter:
npm install @payloadcms/email-nodemailer nodemailer2. Configure the Email Adapter in Payload Config
You need to pass an email adapter into the email property of your Payload Config. The Nodemailer adapter allows you to use various transports, including SMTP.
Here's how to modify your payload.config.ts to include the Nodemailer adapter with SMTP transport options. This will allow Payload to send authentication-related emails, like verification and password resets.
For more info about setting up SMTP, check out Nodemailer Setup Tutorial
...
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
export default buildConfig({
...
email: nodemailerAdapter({
// These will be seen as the 'From' name and address in the sent emails
defaultFromName: 'Your Website Name',
defaultFromAddress: 'no-reply@yourdomain.com',
// Configure the transport options for Nodemailer
transportOptions: {
host: process.env.SMTP_HOST, // Your SMTP server host (e.g., smtp.gmail.com)
port: parseInt(process.env.SMTP_PORT || "587"), // 587 for TLS, 465 for SSL
secure: process.env.SMTP_SECURE === 'true', // Set to true if port is 465, false for 587 or 25
auth: {
user: process.env.SMTP_USER, // Your SMTP username (email)
pass: process.env.SMTP_PASS, // Your SMTP password
},
},
}),
})Your .env.local might look like this:
DATABASE_URI=postgresql://db_user:db_user_password@localhost:5433/db_database_name
PAYLOAD_SECRET=randomSecretString
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your_email@gmail.com
SMTP_PASS=zxcvbnmasdfghjkqTips
For a smooth developing flow, you can set autoLogin in your Payload as follows:
admin: {
autoLogin: process.env.NODE_ENV === "development"
? {
email: "youremail@email.com",
password: "yourpassword",
prefillOnly: true,
}
: false
},For more info, visit: Payload Auto-Login
3. Enable and Customize Email Verification in Auth Collection
First let's create a helper function:
export function getRootUrl() {
if (typeof window !== 'undefined') return ''; // keep client requests relative
const url = process.env.NODE_ENV === 'development'
? process.env.NEXT_PUBLIC_ROOT_URL_DEV
: process.env.NEXT_PUBLIC_ROOT_URL_PROD;
return url?.replace(/\/+$/, '') ?? '';
}Make sure the NEXT_PUBLIC_ROOT_URL_PROD has no www (use apex domain). And when you use your paid domain name in production, makes sure to make apex domain (no www) a production and point all additional domains to apex in Vercel. Otherwise you will get CORS error because of different origin.
To enable email verification, use the auth.verify property on your Collection Config. Here's a refined version of the Users collection with updated verification URLs:
const Users: CollectionConfig = ...
...
auth: {
depth: 1,
tokenExpiration: 604800,
maxLoginAttempts: 5,
lockTime: 600,
verify: {
generateEmailHTML: ({ token, user }) => {
const userName = user?.name ?? user?.email ?? "friend";
const verificationURL = getRootUrl() + `/verify?token=${token}`;
return `Hi ${userName}, please verify your email by clicking here: <a href="${verificationURL}">${verificationURL}</a>`;
},
generateEmailSubject: ({ user }) => {
const userName = user?.name ?? "friend";
return `Hi ${userName}, please verify your email for mysite.com`;
},
},
forgotPassword: {
generateEmailHTML: (args) => {
const token = args?.token ?? "";
const userName = args?.user?.name ?? args?.user?.email ?? "friend";
const resetPasswordURL = getRootUrl() + `/reset-password?token=${token}`;
return `
<div>
<h1>Reset your password</h1>
<p>Hi, ${userName}</p>
<p>Click the link below to reset your password.</p>
<p>
<a href="${resetPasswordURL}">Reset Password</a>
<p>
</div>
`
},
generateEmailSubject: (args) => {
const userName = args?.user?.name ?? args?.user?.email ?? "friend";
return `Hi ${userName}, reset your password for mysite.com`
}
}
},
... Since we just enabled verification system, you have to enter Payload admin to trigger it. Now, you'll see that you can't log in. That's because you're not verified. You can enter your database (with pgAdmin) directly and set the verify column to true.
For the full (and final) collection reference, visit: Users Collection
4. Create Profiles Collection
import { CollectionConfig } from "payload";
const Profiles: CollectionConfig = {
slug: "profiles",
admin: {
useAsTitle: "email"
},
fields: [
{
name: "email",
label: "Email",
type: "email",
unique: true,
required: true,
admin: {
readOnly: true,
}
},
{
name: "firstName",
label: "First Name",
type: "text",
required: false,
},
{
name: "lastName",
label: "Last Name",
type: "text",
required: false,
},
{
name: "profileImage",
label: "Profile Image",
type: "text", // will change to upload later
required: false,
},
{
name: "gender",
label: "Gender",
type: "select",
required: false,
options: [
{
label: "Male",
value: "Male",
},
{
label: "Female",
value: "Female",
},
{
label: "Unspecified",
value: "Unspecified",
},
]
},
{
name: "address1",
label: "Address Line 1",
type: "textarea",
required: false,
},
{
name: "address2",
label: "Address Line 2",
type: "textarea",
required: false,
},
{
name: "city",
label: "City",
type: "text",
required: false,
},
{
name: "state",
label: "State/Province/Region",
type: "text",
required: false,
},
{
name: "zipcode",
label: "Zip/Postal Code",
type: "number",
required: false,
},
{
name: "country",
label: "Country",
type: "text",
required: false,
}
]
}
export default Profiles;Run dev, to update the type for Profiles collection. Don't forget to add Profiles collection to payload.config.ts
Turn off dev
Add Payload functions
import { FieldHook } from "payload";
export const populateName: FieldHook = async ({ data, value }) => {
if (!value && data?.email) {
return data.email.split("@")[0];
}
return value;
}Update Users Collection
import { getRootUrl } from "@/lib/utility";
import { CollectionConfig } from "payload";
import { populateName } from "../functions/utility";
const Users: CollectionConfig = {
slug: "users",
auth: {
depth: 1,
tokenExpiration: 604800,
maxLoginAttempts: 5,
lockTime: 600,
verify: {
generateEmailHTML: ({ token, user }) => {
const userName = user?.name ?? user?.email ?? "friend";
const verificationURL = getRootUrl() + `/verify?token=${token}`;
return `Hi ${userName}, please verify your email by clicking here: <a href="${verificationURL}">${verificationURL}</a>`;
},
generateEmailSubject: ({ user }) => {
const userName = user?.name ?? "friend";
return `Hi ${userName}, please verify your email for mysite.com`;
},
},
forgotPassword: {
generateEmailHTML: (args) => {
const token = args?.token ?? "";
const userName = args?.user?.name ?? args?.user?.email ?? "friend";
const resetPasswordURL = getRootUrl() + `/reset-password?token=${token}`;
return `
<div>
<h1>Reset your password</h1>
<p>Hi, ${userName}</p>
<p>Click the link below to reset your password.</p>
<p>
<a href="${resetPasswordURL}">Reset Password</a>
<p>
</div>
`
},
generateEmailSubject: (args) => {
const userName = args?.user?.name ?? args?.user?.email ?? "friend";
return `Hi ${userName}, reset your password for mysite.com`
}
}
},
admin: {
useAsTitle: "name"
},
fields: [
{
name: "name",
type: "text",
required: false,
hooks: {
afterRead: [populateName],
beforeChange: [populateName],
}
},
{
name: "role",
type: "select",
options: [
"admin",
"editor",
"web-user"
],
defaultValue: "web-user",
},
{
name: "profile",
type: "relationship",
relationTo: "profiles",
hasMany: false,
admin: {
readOnly: true,
},
access: {
create: () => false,
update: () => false,
}
}
]
}
export default Users;Run dev and let it auto update fields. IMPORTANT: Give yourself the admin role.
Turn off dev
Add Access Control Functions:
import { PayloadRequest } from "payload";
export const isAdminUser = (req: PayloadRequest) => {
if (req.user?.collection === "users" && req.user?.role === "admin") {
return true;
} else {
return false;
}
}
export const isStaffUser = (req: PayloadRequest) => {
if (
req.user?.collection === "users" &&
(req.user?.role === "admin" || req.user?.role === "editor")
) {
return true;
} else {
return false;
}
}
export const isAdminAndSelf = (req: PayloadRequest) => {
if (req.user?.collection === "users" && req.user?.role === "admin") {
return true;
}
if (req.user && req.user.collection === "users") {
return {
id: {
equals: req.user.id,
}
}
}
else {
return false;
}
}Update Users Collection with Profile Relationship Field and Access Control Functions
import { getRootUrl } from "@/lib/utility";
import { CollectionAfterChangeHook, CollectionBeforeValidateHook, CollectionConfig, FieldHook } from "payload";
import { isAdminAndSelf, isAdminUser } from "../functions/accessControl";
import { populateName } from "../functions/utility";
const autoLinkProfile: FieldHook = async ({
operation, value, previousValue, siblingData, req,
}) => {
if (operation !== "create" && operation !== "update") return value ?? previousValue;
if (value ?? previousValue) return value ?? previousValue;
if (!siblingData?.email) {
throw new Error("Cannot create profile: user email is missing");
}
try {
const profile = await req.payload.create({
collection: "profiles",
data: {
email: siblingData.email,
}
});
return profile.id;
} catch (error) {
req.payload.logger.error(`Failed to create profile for ${siblingData.email}:
${(error as Error).message}`)
return previousValue ?? value ?? null;
}
}
const attachProfileOnSignup: CollectionBeforeValidateHook = async ({
operation, data, req
}) => {
if (operation !== "create") return data;
if (!data) {
throw new Error("No data provided");
}
const email = data?.email;
const firstName = data?.firstName;
const lastName = data?.lastName;
if (!email) return data;
const existing = await req.payload.find({
collection: "profiles",
where: { email: { equals: email }},
limit: 1,
});
let profileId: string | number;
if (existing.docs.length) {
profileId = existing.docs[0].id;
await req.payload.update({
collection: "profiles",
id: profileId,
data: {
firstName: firstName,
lastName: lastName,
}
})
} else {
const created = await req.payload.create({
collection: "profiles",
data: {
email: email,
firstName: firstName ?? data?.name,
lastName: lastName,
}
});
profileId = created.id;
}
delete (data as Record<string, unknown>).firstName;
delete (data as Record<string, unknown>).lastName;
const next = { ...data, profile: profileId };
return next;
}
const autoUpdateProfile: CollectionAfterChangeHook = async ({
operation, doc, previousDoc, req
}) => {
if (operation !== "update") return;
if (!doc.profile) return;
if (doc.email === previousDoc.email) return;
const profileId = typeof doc.profile === "object"
? doc.profile.id
: doc.profile;
await req.payload.update({
collection: "profiles",
id: profileId,
data: {
email: doc.email,
}
})
}
const Users: CollectionConfig = {
slug: "users",
auth: {
depth: 1,
tokenExpiration: 604800,
maxLoginAttempts: 5,
lockTime: 600,
verify: {
generateEmailHTML: ({ token, user }) => {
const userName = user?.name ?? user?.email ?? "friend";
const verificationURL = getRootUrl() + `/verify?token=${token}`;
return `Hi ${userName}, please verify your email by clicking here: <a href="${verificationURL}">${verificationURL}</a>`;
},
generateEmailSubject: ({ user }) => {
const userName = user?.name ?? "friend";
return `Hi ${userName}, please verify your email for mysite.com`;
},
},
forgotPassword: {
generateEmailHTML: (args) => {
const token = args?.token ?? "";
const userName = args?.user?.name ?? args?.user?.email ?? "friend";
const resetPasswordURL = getRootUrl() + `/reset-password?token=${token}`;
return `
<div>
<h1>Reset your password</h1>
<p>Hi, ${userName}</p>
<p>Click the link below to reset your password.</p>
<p>
<a href="${resetPasswordURL}">Reset Password</a>
<p>
</div>
`
},
generateEmailSubject: (args) => {
const userName = args?.user?.name ?? args?.user?.email ?? "friend";
return `Hi ${userName}, reset your password for mysite.com`
}
}
},
admin: {
useAsTitle: "name"
},
access: {
create: ({}) => true,
read: ({ req }) => isAdminAndSelf(req),
update: ({ req }) => isAdminAndSelf(req),
delete: ({ req }) => isAdminUser(req),
},
hooks: {
afterChange: [autoUpdateProfile],
beforeValidate: [attachProfileOnSignup],
},
fields: [
{
name: "name",
type: "text",
required: false,
hooks: {
afterRead: [populateName],
beforeChange: [populateName],
}
},
{
name: "role",
type: "select",
options: [
"admin",
"editor",
"web-user"
],
defaultValue: "web-user",
},
{
name: "profile",
type: "relationship",
relationTo: "profiles",
hasMany: false,
admin: {
readOnly: true,
},
hooks: {
beforeChange: [autoLinkProfile],
},
access: {
create: () => false,
update: () => false,
}
}
]
}
export default Users;Turn dev back on and visit Payload Admin to update
6. Update Profiles Collection
Make sure you've entered Payload Admin once to trigger Profiles collection.
First, let's add an additional access control rule:
...
export const isAdminAndSelfProfile = (req: PayloadRequest) => {
// admins: full access
if (req.user?.collection === "users" && req.user?.role === "admin") return true;
// logged-in user: restrict to their own profile doc
if (req.user?.collection === "users") {
const p = req.user.profile as unknown;
const profileId =
typeof p === "object" && p !== null && "id" in p
? // populated (depth >= 1)
(p as { id: string | number }).id
: // unpopulated (just an ID)
(p as string | number);
return { id: { equals: profileId } };
}
return false;
};Update Profiles Collection:
import { CollectionConfig } from "payload";
import { isAdminAndSelfProfile, isAdminUser } from "../functions/accessControl";
const Profiles: CollectionConfig = {
slug: "profiles",
admin: {
useAsTitle: "email"
},
access: {
create: ({}) => false,
read: ({ req }) => isAdminAndSelfProfile(req),
update: ({ req }) => isAdminAndSelfProfile(req),
delete: ({ req }) => isAdminUser(req),
},
fields: [
{
name: "email",
label: "Email",
type: "email",
unique: true,
required: true,
admin: {
readOnly: true,
}
},
{
name: "firstName",
label: "First Name",
type: "text",
required: false,
},
{
name: "lastName",
label: "Last Name",
type: "text",
required: false,
},
{
name: "profileImage",
label: "Profile Image",
type: "text", // will change to upload later
required: false,
},
{
name: "gender",
label: "Gender",
type: "select",
required: false,
options: [
{
label: "Male",
value: "Male",
},
{
label: "Female",
value: "Female",
},
{
label: "Unspecified",
value: "Unspecified",
},
]
},
{
name: "address1",
label: "Address Line 1",
type: "textarea",
required: false,
},
{
name: "address2",
label: "Address Line 2",
type: "textarea",
required: false,
},
{
name: "city",
label: "City",
type: "text",
required: false,
},
{
name: "state",
label: "State/Province/Region",
type: "text",
required: false,
},
{
name: "zipcode",
label: "Zip/Postal Code",
type: "number",
required: false,
},
{
name: "country",
label: "Country",
type: "text",
required: false,
}
]
}
export default Profiles;Try create a new user from the Admin Panel. Once done, a corresponding document should be automatically created in the Profiles collection.
Note that, at this point, if you use a fake email to create an account, the user won't be verified since we have implemented a verification system.
7. Create Signup Page
'use client'
import { getRootUrl } from "@/lib/utility";
import { useRouter } from "next/navigation";
import { useState } from "react"
export default function SignupPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setStatus("");
setLoading(true);
if (password !== confirmPassword) {
setStatus("Passwords do not match.");
setLoading(false);
return;
}
try {
const rootUrl = getRootUrl();
const res = await fetch(`${rootUrl}/api/users`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: 'include', // Important for cookie-based auth
body: JSON.stringify({
email,
password,
firstName,
lastName,
}),
});
if (!res.ok) {
throw new Error(`An unexpected error occurred. Please try again later.`);
};
if (res.ok) {
setStatus("Account created successfully! Please check your email to verify.");
setTimeout(() => {
router.push("/login");
}, 5000)
}
} catch (error) {
console.error("Network or unexpected error during signup: ", error);
setStatus("An unexpected error occurred. Please try again later.");
} finally {
setLoading(false);
}
};
return (
<main className="w-full space-y-4">
<h1>Signup Page</h1>
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 w-full max-w-[400px] text-sm"
>
<div className="flex flex-col gap-2 w-full">
<label htmlFor="firstName">First Name:</label>
<input
type="text"
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
disabled={loading}
className="w-full py-2 px-4 bg-gray-700 text-white"
/>
</div>
<div className="flex flex-col gap-2 w-full">
<label htmlFor="lastName">Last Name:</label>
<input
type="text"
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
disabled={loading}
className="w-full py-2 px-4 bg-gray-700 text-white"
/>
</div>
<div className="flex flex-col gap-2 w-full">
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
className="w-full py-2 px-4 bg-gray-700 text-white"
/>
</div>
<div className="flex flex-col gap-2 w-full">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
className="w-full py-2 px-4 bg-gray-700 text-white"
/>
</div>
<div className="flex flex-col gap-2 w-full">
<label htmlFor="confirmPassword">Confirm Password:</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={loading}
className="w-full py-2 px-4 bg-gray-700 text-white"
/>
</div>
<div>
<button
type="submit"
disabled={loading}
className="py-2 px-4 bg-emerald-600 disabled:opacity-50 cursor-pointer"
>Sign Up</button>
</div>
</form>
{status && <p className="text-sm text-sky-500">{status}</p>}
</main>
)
}8. Create Verify Page
'use client';
import { getRootUrl } from "@/lib/utility";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
export default function VerifyHandler() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
const verifyAccount = async () => {
setLoading(true);
const token = searchParams.get("token");
if (!token) {
setStatus("Error: No verification token found.");
setLoading(false);
return;
}
try {
const rootUrl = getRootUrl();
const res = await fetch(`${rootUrl}/api/users/verify/${token}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (res.ok) {
setStatus("Account verified successfully! Redirecting to login...");
setLoading(false);
setTimeout(() => {
router.push("/login");
}, 5000);
} else {
const error = await res.json();
setLoading(false);
console.log("Verification failed: ", error.message);
setStatus(`Verification failed: You may have already been verified or using the wrong url. Try login to your account first and see what's up.`);
}
} catch (error) {
console.error("Verification error: ", error);
setLoading(false);
setStatus("An unexpected error occurred during verification.");
}
};
verifyAccount();
}, [searchParams, router]);
return (
<div>
<h1>Email Verification</h1>
<p>{status}</p>
{loading && (
<div className="flex justify-center items-center">
<div className="w-12 h-12 rounded-full animate-spin
border-4 border-solid border-sky-500 border-t-transparent shadow-md">
</div>
</div>
)}
</div>
);
}import { Suspense } from "react";
import VerifyHandler from "../../components/auth/VerifyHandler";
export default function VerifyPage() {
return (
<Suspense fallback={<p className="text-center text-sm mt-md">Loading verify...</p>}>
<VerifyHandler />
</Suspense>
);
}9. Create Login Page
'use client'
import { getRootUrl } from "@/lib/utility";
import { useRouter } from "next/navigation";
import { useState } from "react"
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus("");
setLoading(true);
try {
const rootUrl = getRootUrl();
const res = await fetch(`${rootUrl}/api/users/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
// IMPORTANT: This tells the browser to include the HTTP-only cookie
credentials: 'include',
body: JSON.stringify({
email,
password,
}),
});
if (res.ok) {
// Login successful, redirect the user to homepage
router.push('/');
} else {
const errorData = await res.json();
setStatus(errorData.message || 'Login failed.');
}
} catch (error) {
console.error('Login error:', error);
setStatus('An unexpected error occurred. Please try again.');
} finally {
setLoading(false);
}
}
return (
<main className="flex flex-col">
<div className="bg-gray-600 p-8 rounded-lg shadow-md w-full max-w-md">
<h1 className="text-3xl font-bold mb-6">Login</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium">Email address</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<div className="flex justify-center items-center">
<div className="w-6 h-6 rounded-full animate-spin
border-4 border-solid border-white border-t-transparent shadow-md">
</div>
</div>
) : <span>Login</span>}
</button>
{status && (
<div className="text-sm text-amber-300 place-self-center">
<p className="">{status} Please make sure:</p>
<p>Your credentials are correct.</p>
<p>You have verified your email.</p>
</div>
)}
</form>
</div>
</main>
);
}At this point, you should test signing up. You should sign up successfully, get email verification, and login.
10. Show user's name on the homepage
This is for testing. We will switch to TanStack to manage user global state later.
'use client'
import { useEffect, useState } from 'react';
// Define a type for your user object to ensure type safety
interface User {
id: string;
email: string;
firstName: string;
}
export default function Home() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUser = async () => {
try {
const res = await fetch('/api/users/me', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
// This is crucial! It ensures the browser sends the session cookie.
credentials: 'include',
});
if (res.ok) {
const data = await res.json();
if (data.user) {
setUser(data.user);
} else {
// User is not authenticated, API returns no user
setUser(null);
}
} else {
// Handle non-ok responses (e.g., 401 Unauthorized)
setUser(null);
}
} catch (error) {
console.error('Failed to fetch user:', error);
setUser(null);
} finally {
setLoading(false);
}
};
fetchUser();
}, []);
// Show a loading state while fetching the user
if (loading) {
return (
<div>
<h1>Test Payload Auth</h1>
<p>Loading...</p>
</div>
);
}
return (
<div>
<h1>Test Payload Auth</h1>
{user ? (
// Display the user's first name if available, otherwise use their email
<p>Welcome, {user.firstName || user.email}</p>
) : (
<p>Welcome, guest</p>
)}
</div>
);
}11. Create Logout Page
This is temporary. We will won't need it anymore when we implement global state with TanStack.
'use client'
import { getRootUrl } from "@/lib/utility";
import { useRouter } from "next/navigation";
export default function LogoutPage() {
const router = useRouter();
const handleLogout = async () => {
try {
const rootUrl = getRootUrl();
const res = await fetch(`${rootUrl}/api/users/logout`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
credentials: "include",
});
if (!res.ok) {
throw new Error(`Server responded with status: ${res.status} ${res.statusText}`)
}
router.push("/");
} catch (error) {
console.error("An error occurred. ", (error as Error).message);
}
}
return (
<main className="flex flex-col">
<div>
<button
onClick={handleLogout}
className="bg-amber-600 py-2 px-4 text-sm cursor-pointer"
>Logout</button>
</div>
</main>
);
}12. Create Forgot Password Page
'use client'
import { getRootUrl } from '@/lib/utility';
import { useState } from 'react';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('');
setLoading(true);
try {
const rootUrl = getRootUrl();
const res = await fetch(`${rootUrl}/api/users/forgot-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (res.ok) {
setStatus('If an account with that email exists, a password reset link has been sent.');
setEmail('');
} else {
// For security, it's a best practice to give a generic message even on an error
setStatus('If an account with that email exists, a password reset link has been sent.');
}
} catch (err) {
console.error('Forgot password error:', err);
setStatus('An unexpected error occurred. Please try again.');
} finally {
setLoading(false);
}
};
return (
<main className="flex flex-col">
<div className="bg-gray-600 p-8 rounded-lg shadow-md w-full max-w-md">
<h1 className="text-3xl font-bold mb-6 text-center">Forgot Password</h1>
<p className="text-center mb-4">
Enter your email to receive a password reset link.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium">Email address</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
/>
</div>
{status && (
<p className={`text-sm text-center ${status.includes('error') ? 'text-red-500' : 'text-green-500'}`}>
{status}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50"
>
{loading ?
<div className="flex justify-center items-center">
<div className="w-6 h-6 rounded-full animate-spin
border-4 border-solid border-white border-t-transparent shadow-md">
</div>
</div>
: 'Send Reset Link'}
</button>
</form>
</div>
</main>
);
}13. Create Reset Password Page
'use client'
import { getRootUrl } from '@/lib/utility';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
const ResetPasswordHandler = () => {
const router = useRouter();
const searchParams = useSearchParams();
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [error, setError] = useState('');
const [status, setStatus] = useState('');
const [loading, setLoading] = useState(false);
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
const urlToken = searchParams.get('token');
if (urlToken) {
setToken(urlToken);
} else {
setError('Invalid or missing password reset token.');
}
}, [searchParams]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setStatus('');
setLoading(true);
if (password !== passwordConfirm) {
setError('Passwords do not match.');
setLoading(false);
return;
}
if (!token) {
setError('Invalid or missing password reset token.');
setLoading(false);
return;
}
try {
const rootUrl = getRootUrl();
const res = await fetch(`${rootUrl}/api/users/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
password,
}),
});
if (res.ok) {
setStatus('Password reset successfully! Redirecting to login...');
setTimeout(() => {
router.push('/login');
}, 3000);
} else {
const errorData = await res.json();
setError(errorData.message || 'Password reset failed. The token may be expired or invalid.');
}
} catch (err) {
console.error('Reset password error:', err);
setError('An unexpected error occurred. Please try again.');
} finally {
setLoading(false);
}
};
if (!token) {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24 bg-gray-100">
<p className="text-red-500">{error}</p>
</main>
);
}
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24 bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h1 className="text-3xl font-bold mb-6 text-center text-gray-800">Reset Password</h1>
<p className="text-center mb-4 text-gray-600">
Please enter your new password.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">New Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Confirm New Password</label>
<input
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
/>
</div>
{status && (
<p className="text-sm text-center text-green-500">{status}</p>
)}
{error && (
<p className="text-sm text-center text-red-500">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50"
>
{loading ?
<div className="flex justify-center items-center">
<div className="w-6 h-6 rounded-full animate-spin
border-4 border-solid border-white border-t-transparent shadow-md">
</div>
</div>
: 'Reset Password'}
</button>
</form>
</div>
</main>
);
}
export default ResetPasswordHandler;import { Suspense } from "react";
import ResetPasswordHandler from "../../components/auth/ResetPasswordHandler";
export default function VerifyPage() {
return (
<Suspense fallback={<p className="text-center text-sm mt-md">Loading verify...</p>}>
<ResetPasswordHandler />
</Suspense>
);
}14. Admin Panel Access Control
For references, visit: Payload Access Control: Admin and Restrict Access Control
Add the following rule to our existing accessControl.ts
export const adminAccessControl = ({ req }: { req: PayloadRequest }): boolean | Promise<boolean> => {
const user = req.user
if (user && (user.role?.includes('admin') || user.role?.includes('editor'))) {
return true // Allow access to admin and editor
}
return false // Deny access for all other roles
}We have to apply this rule the auth collection under the access -> admin property.
...
access: {
create: ({}) => true,
read: ({ req }) => isAdminAndSelf(req),
update: ({ req }) => isAdminAndSelf(req),
delete: ({ req }) => isAdminUser(req),
admin: adminAccessControl,
},
...We are done with Account Functionality. Next, it's time to setup global states with TanStack React Query Global User State