Collection List

Used in this guide:
Next.js 15.3.4
Payload CMS

Helper functions:

payload/utility.ts
import { FieldHook } from "payload";
 
export const populateName: FieldHook = async ({ data, value }) => {
    if (!value && data?.email) {
        return data.email.split("@")[0];
    }
    return value;
}
lib/utility.ts
export function getRootUrl() {
    const rootUrl = process.env.NODE_ENV === "development"
        ? process.env.NEXT_PUBLIC_ROOT_URL_DEV
        : process.env.NEXT_PUBLIC_ROOT_URL_PROD;
    return rootUrl;
}

Access Control Helpers:

root/lib/payload/accessControl.ts
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;
    }
}
 
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;
};

Users Collection

root/payload/collections/users.ts
import { isAdminAndSelf, isAdminUser } from "@/lib/payload/accessControl";
import { populateName } from "@/lib/payload/utility";
import { getRootUrl } from "@/lib/utility";
import { CollectionAfterChangeHook, CollectionBeforeValidateHook, CollectionConfig, FieldHook } from "payload";
 
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: ({ req }) => isAdminUser(req),
        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],
                // afterRead: [autoBackFillProfile],
            },
            access: {
                create: () => false,
                update: () => false,
            }
        }
    ]
}
 
export default Users;


JKT

Stay focused, and the rest will follow

©Jakkrit Turner. All rights reserved