diff --git a/public/assets/images/add-white.svg b/public/assets/images/add-white.svg
new file mode 100644
index 0000000..6a5089b
--- /dev/null
+++ b/public/assets/images/add-white.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/assets/images/remove-white.svg b/public/assets/images/remove-white.svg
new file mode 100644
index 0000000..f7b9e88
--- /dev/null
+++ b/public/assets/images/remove-white.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js
index 874d545..37c53e3 100644
--- a/src/app/(payload)/admin/importMap.js
+++ b/src/app/(payload)/admin/importMap.js
@@ -37,10 +37,12 @@ import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from
import { default as default_0c3317dc490d2187d9715694656ec3d3 } from '@/components/SiteCell'
import { default as default_2e6cf1b1b850543a1bd5771a5b86ee4d } from '@/components/SiteRowLabel'
import { default as default_32d7f490396abda7b466903585904485 } from '@/components/RemoveUser'
+import { default as default_7ce813284ffa92867fd9fdc508e13505 } from 'src/components/UserCollectionDescription'
import { default as default_fedc587b86d65a6c9503093fbd9e9e2f } from '@/components/CustomPublishButton'
import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin'
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
import { default as default_6d0fe59af291eda0374bdc5d5f9a5829 } from '@/components/CustomDashboard'
+import { default as default_7bac276e3812125fd6b97470a9f2783d } from '@/components/UserRolesAndPermissions'
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
@@ -82,8 +84,10 @@ export const importMap = {
"@/components/SiteCell#default": default_0c3317dc490d2187d9715694656ec3d3,
"@/components/SiteRowLabel#default": default_2e6cf1b1b850543a1bd5771a5b86ee4d,
"@/components/RemoveUser#default": default_32d7f490396abda7b466903585904485,
+ "src/components/UserCollectionDescription#default": default_7ce813284ffa92867fd9fdc508e13505,
"@/components/CustomPublishButton#default": default_fedc587b86d65a6c9503093fbd9e9e2f,
"@/components/BeforeLogin#default": default_8a7ab0eb7ab5c511aba12e68480bfe5e,
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
- "@/components/CustomDashboard#default": default_6d0fe59af291eda0374bdc5d5f9a5829
+ "@/components/CustomDashboard#default": default_6d0fe59af291eda0374bdc5d5f9a5829,
+ "@/components/UserRolesAndPermissions#default": default_7bac276e3812125fd6b97470a9f2783d
}
diff --git a/src/app/(payload)/custom.scss b/src/app/(payload)/custom.scss
index 8104888..e3cf2aa 100644
--- a/src/app/(payload)/custom.scss
+++ b/src/app/(payload)/custom.scss
@@ -17,6 +17,9 @@
@forward "usa-skipnav";
@forward "usa-summary-box";
@forward "uswds-utilities";
+@forward "usa-table";
+@forward "usa-accordion";
+@forward "usa-in-page-navigation";
// Import USWDS variables early so they're available for custom styles
@import "../styles/uswds-variables.scss";
@@ -452,3 +455,48 @@ button {
.list-header__actions {
margin-left: var(--spacing-4);
}
+
+// In page nav container
+// Roles and Permissions page
+
+.usa-in-page-nav-container {
+ position: relative;
+ max-width: 75rem;
+ margin-left: auto;
+ margin-right: auto;
+ main {
+ max-width: 55rem;
+ h2, h4 {
+ font-family: inherit;
+ }
+ .usa-prose > ul.margin-top-1 {
+ margin-top: .5rem;
+ li {
+ line-height: 1;
+ }
+ }
+ .usa-prose > .usa-table-container--scrollable td {
+ vertical-align: top;
+ }
+ }
+}
+
+.usa-table--borderless thead.bg-base-lighter th {
+ background-color: #dfe1e2;
+}
+
+.usa-accordion__button.bg-primary {
+ background-color: #005ea2;
+ color: #ffffff;
+ background-image: url(/assets/images/remove-white.svg), linear-gradient(transparent, transparent);
+ &:hover {
+ background-color: #1a4480;
+ }
+}
+
+.usa-accordion__button.bg-primary[aria-expanded=false] {
+ background-image: url(/assets/images/add-white.svg), linear-gradient(transparent, transparent);
+ &:hover {
+ background-color: #1a4480;
+ }
+}
diff --git a/src/collections/Users/index.ts b/src/collections/Users/index.ts
index a07aa3e..054c95d 100644
--- a/src/collections/Users/index.ts
+++ b/src/collections/Users/index.ts
@@ -21,6 +21,9 @@ export const Users: CollectionConfig = {
slug: 'users',
admin: {
group: 'User Management',
+ components: {
+ Description: 'src/components/UserCollectionDescription',
+ },
description: 'Manage who can access and edit the site, including roles and permissions.',
defaultColumns: ['email', 'updatedAt', 'sites'],
useAsTitle: 'email',
diff --git a/src/components/CustomDashboard/index.tsx b/src/components/CustomDashboard/index.tsx
index 4cd34b6..f74f216 100644
--- a/src/components/CustomDashboard/index.tsx
+++ b/src/components/CustomDashboard/index.tsx
@@ -100,6 +100,27 @@ const CustomDashboard: React.FC = async (props: { payload: BasePayload }) => {
groupedItems[groupKey].push(item)
})
+ const usersGroup = collections.find(c => c.slug === 'users')?.group || 'User Management'
+
+ const rolesAndPermissionsPage = {
+ slug: 'sites-roles-and-permissions',
+ label: 'Site Roles and Permissions',
+ description: 'Documentation on roles and permissions.',
+ group: typeof usersGroup === 'string' ? usersGroup : 'User Management',
+ href: '/admin/sites-roles-and-permissions',
+ }
+
+ if (!groupedItems[rolesAndPermissionsPage.group]) {
+ groupedItems[rolesAndPermissionsPage.group] = []
+ }
+
+ const usersIdx = groupedItems[rolesAndPermissionsPage.group].findIndex(i => i.slug === 'users')
+ if(usersIdx >= 0) {
+ groupedItems[rolesAndPermissionsPage.group].splice(usersIdx + 1, 0, rolesAndPermissionsPage as any)
+ } else {
+ groupedItems[rolesAndPermissionsPage.group].push(rolesAndPermissionsPage as any)
+ }
+
// Define the order of groups
const groupOrder = [
'Content Collection',
diff --git a/src/components/UserCollectionDescription/index.tsx b/src/components/UserCollectionDescription/index.tsx
new file mode 100644
index 0000000..c9a8f8d
--- /dev/null
+++ b/src/components/UserCollectionDescription/index.tsx
@@ -0,0 +1,12 @@
+import Link from "next/link";
+
+const UserCollectionDescription = () => {
+ return (
+ <>
+
Manage who can access and edit the site, including roles and permissions.
+
Learn about roles and permissions here.
+ >
+ )
+}
+
+export default UserCollectionDescription;
diff --git a/src/components/UserRolesAndPermissions/SetStepNav.client.tsx b/src/components/UserRolesAndPermissions/SetStepNav.client.tsx
new file mode 100644
index 0000000..611d203
--- /dev/null
+++ b/src/components/UserRolesAndPermissions/SetStepNav.client.tsx
@@ -0,0 +1,34 @@
+"use client";
+// ref https://github.com/payloadcms/payload/issues/12344#issuecomment-2867110662
+// workaround for Breadcrumbs not working when using DefaultTemplate
+import { useEffect } from "react";
+import { useStepNav } from "@payloadcms/ui";
+
+export function SetStepNav() {
+ const { setStepNav } = useStepNav();
+
+ useEffect(() => {
+ // Define your breadcrumb path. The first entry should be the "home"/dashboard.
+ // Subsequent entries represent the current custom view trail.
+ setStepNav([
+ {
+ label: "Sites Roles and Permissions",
+ url: "/admin/sites-roles-and-permissions", // your custom view route
+ },
+ ]);
+
+ // Optional: on unmount, clear or reset to dashboard-only
+ return () => {
+ try {
+ setStepNav([
+ { label: "Dashboard", url: "/admin" },
+ ]);
+ } catch {
+ /* no-op */
+ }
+ };
+ }, [setStepNav]);
+
+ // This component only sets step nav; it renders nothing.
+ return null;
+}
diff --git a/src/components/UserRolesAndPermissions/USWDSInPageNavInit.client.tsx b/src/components/UserRolesAndPermissions/USWDSInPageNavInit.client.tsx
new file mode 100644
index 0000000..cb89b06
--- /dev/null
+++ b/src/components/UserRolesAndPermissions/USWDSInPageNavInit.client.tsx
@@ -0,0 +1,90 @@
+
+"use client";
+
+import { useEffect } from "react";
+
+/**
+ * USWDSInit — Client-only initializer for:
+ * - In-page navigation
+ * - Accordion
+ *
+ * It dynamically imports USWDS JS and initializes components on the document.
+ * Safe for Next.js App Router: nothing runs during SSR.
+ */
+export function USWDSInit() {
+ useEffect(() => {
+ let inPageNav: any | undefined;
+ let accordion: any | undefined;
+
+ let mounted = true;
+
+ (async () => {
+ try {
+ // In-page navigation
+ const inPageNavMod = await import("@uswds/uswds/js/usa-in-page-navigation");
+ inPageNav = inPageNavMod?.default ?? inPageNavMod;
+
+ // Accordion
+ const accordionMod = await import("@uswds/uswds/js/usa-accordion");
+ accordion = accordionMod?.default ?? accordionMod;
+
+ if (mounted) {
+ // Initialize both against the document
+ inPageNav?.on(document.body);
+ accordion?.on(document.body);
+
+ // Optional: if headings are dynamically injected/updated,
+ // re-init the in-page nav when headings change.
+ const main = document.querySelector("main");
+ if (main) {
+ const observer = new MutationObserver(() => {
+ try {
+ // Re-run to rebuild TOC (guarding against double init)
+ inPageNav?.off(document.body);
+ inPageNav?.on(document.body);
+ } catch {
+ /* no-op */
+ }
+ });
+
+ observer.observe(main, {
+ subtree: true,
+ childList: true,
+ characterData: true,
+ });
+
+ // Clean up the observer on unmount
+ (USWDSInit as any)._observer = observer;
+ }
+ }
+ } catch {
+ // Silence errors to avoid noisy logs during dev hot reload
+ }
+ })();
+
+ return () => {
+ mounted = false;
+ try {
+ // Disconnect the mutation observer if we created one
+ const observer = (USWDSInit as any)._observer as MutationObserver | undefined;
+ observer?.disconnect();
+ } catch {
+ /* no-op */
+ }
+
+ try {
+ inPageNav?.off(document.body);
+ } catch {
+ /* no-op */
+ }
+
+ try {
+ accordion?.off(document.body);
+ } catch {
+ /* no-op */
+ }
+ };
+ }, []);
+
+ return null;
+}
diff --git a/src/components/UserRolesAndPermissions/index.tsx b/src/components/UserRolesAndPermissions/index.tsx
new file mode 100644
index 0000000..aca2914
--- /dev/null
+++ b/src/components/UserRolesAndPermissions/index.tsx
@@ -0,0 +1,681 @@
+import React from "react";
+import type { AdminViewServerProps } from "payload";
+import { DefaultTemplate } from "@payloadcms/next/templates";
+import { Gutter } from "@payloadcms/ui";
+import Link from "next/link";
+import { Check, X } from "lucide-react";
+import { USWDSInit } from "./USWDSInPageNavInit.client";
+import { SetStepNav } from "./SetStepNav.client";
+import { redirect } from "next/navigation";
+
+/**
+* User Roles & Permissions — Custom Root View (Server Component)
+* Wrapped in DefaultTemplate so the admin chrome (header, sidebar, breadcrumbs) renders.
+* The in-page nav aside + headings in are picked up by USWDS JS initialized by the client component.
+*/
+export default function UserRolesAndPermissions(props: AdminViewServerProps) {
+ const { initPageResult, params, searchParams } = props;
+ const { req, permissions, locale, visibleEntities } = initPageResult;
+ const user = req.user
+
+ if(!user) {
+ // redirect to login if not logged in
+ redirect(`/admin/login`)
+ }
+
+ return (
+
+ {/* Initialize USWDS In-page nav, accordion on the client and fix breadcrumbs */}
+
+
+
+ {/* Main content */}
+
+ {/* In-page nav + content layout */}
+
+ {/* USWDS JS auto-populates this aside based on headings inside */}
+
+
+
+
+
+
+
+
+
+
Cloud.gov Publisher Sites Roles and Permissions
+
Documentation on roles and permissions. Manage who can access and edit the site here.
+
+
+
Cloud.gov Publisher (Multi-Tenant) Roles
+
When you add a user to a site, you assign them a role. The role determines which
+ actions they can perform on content, users, and automation workflows within that site.
+
If a user has roles across multiple sites, their permissions apply per site. The same
+ person may be a Manager on one site and a User on another.
+
System Administrators (Cloud.gov Page's Engineers/Operators) have global access to
+ all sites and are not governed by site-level role permissions.
+
+
Available Roles
+
+
+
+
+
Role
+
Description
+
+
+
+
+
Manager
+
Full control of site content and user management.
+
+
+
User
+
Contributor with limited publishing rights.
+
+
+
Bot
+
System-generated automation account for CI/CD and maintenance.
+
+
+
+
+
Notes
+
+
+ A User has the least permissions, while a Manager has the most.
+
+
+ Bots are restricted to programmatic actions through the API.
+
+
+
+
Role Definitions
+
+
+
+
+
+
+ Description
+
+
+ Manages all content and users within a single site. Has full CRUD and publishing rights
+ but cannot modify system-level or infrastructure settings of the Cloud.gov Publisher.
+
+
+ Capabilities
+
+
+
Create, edit, and publish content.
+
Manage site users and assign roles.
+
Approve content from other users.
+
Edit site configuration.
+
+
+
+
+
+
+
+ Description
+
+
+ Contributor role with limited permissions, can create and edit drafts but cannot
+ publish or manage users.
+
+
+ Capabilities
+
+
+
Create and update draft content.
+
Collaborate with Managers for publishing.
+
Maintain their own profile and access dashboard.
+
+
+
+
+
+
+
+ Description
+
+
+ The bot role has read only access. It cannot update or delete content.
+
+
+ Capabilities
+
+
+
Run automated jobs or deploy published sites.
+
+
+
+
+
Feature Permission Matrix
+
+
+
+
+
Feature Area
+
Action
+
User
+
Manager
+
Bot
+
Notes
+
+
+
+
+
Content Management
+
Create Drafts
+
+
+
+
Users can create content;
+ Managers can edit any content
+
+
+
Read drafts
+
+
+
+
All roles can read site content
+
+
+
Update drafts
+
+
+
+
Managers can update published content; Users only drafts
+
+
+
Delete content
+
+
+
+
Managers can delete content
+
+
+
Publish content
+
+
+
+
Only Managers can publish
+
+
+
User Management
+
View site users
+
+
+
+
Only Managers can view and manage users
+
+
+
Add new users
+
+
+
+
Restricted to Managers
+
+
+
Edit or remove users
+
+
+
+
Managers cannot assign Bot or Admin roles
+
+
+
Role Management
+
Assign roles
+
+
+
+
Managers can assign User or Manager only
+
+
+
Site Access
+
Access site dashboard
+
+
+
+
Managers and Users can access the site dashboard
+
+
+
Modify site configuration
+
+
+
+
Restricted to Managers
+
+
+
Automation / API
+
Access via API key
+
+
+
+
API authentication only
+
+
+
Trigger publish or deploy
+
+
+
+
Used for scheduled releases
+
+
+
UI Access
+
Login to web app
+
+
+
+
Bots cannot log in
+
+
+
Manage personal profile
+
+
+
+
Not applicable for Bots
+
+
+
+
+
+
Permissions by Resource
+
The following sections provide permission summaries for each key resource area.
+
Site Content
+
+
+
+
+
Action
+
User
+
Manager
+
Bot
+
+
+
+
+
Create content
+
+
+
+
+
+
Edit draft content
+
+
+
+
+
+
Edit published content
+
+
+
+
+
+
Delete content
+
+
+
+
+
+
Publish or unpublish content
+
+
+
+
+
+
View all content
+
+
+
+
+
+
+
+
Notes
+
+
Managers can override or delete any content.
+
Users can only edit drafts they created.
+
+
+
Site User and Role Management
+
+
+
+
+
Action
+
User
+
Manager
+
Bot
+
+
+
+
+
View site users
+
+
+
+
+
+
Invite or add users
+
+
+
+
+
+
Edit or remove users
+
+
+
+
+
+
Assign user roles
+
+
+
+
+
+
Assign Bot role
+
+
+
+
+
+
Assign System Admin role
+
+
+
+
+
+
+
+
Notes
+
+
Managers can assign only User and Manager roles.
+
Bots and Users cannot modify user lists or roles.
+
+
+
Site Access and Configuration
+
+
+
+
+
Action
+
User
+
Manager
+
Bot
+
+
+
+
+
Access dashboard
+
+
+
+
+
+
Modify site configuration (Change template)
+
+
+
+
+
+
Access other sites
+
+
+
+
+
+
Manage site integrations
+
+
+
+
+
+
+
+
Notes
+
+
Configuration edits are site-scoped; Managers cannot modify other sites.
+
Bots are isolated to API actions.
+
+
+
Automation and API Permissions
+
+
+
+
+
Action
+
User
+
Manager
+
Bot
+
+
+
+
+
Access API
+
+
+
+
+
+
Create or modify content via API
+
+
+
+
+
+
Trigger CI/CD workflows
+
+
+
+
+
+
Manage API keys
+
+
+
+
+
+
Run scheduled publish tasks
+
+
+
+
+
+
+
+
Notes
+
+
Bots authenticate using API keys.
+
API keys are managed at the system level by administrators.
+
+
+
UI and Authentication
+
+
+
+
+
Action
+
User
+
Manager
+
Bot
+
+
+
+
+
Log in to web interface
+
+
+
+
+
+
Manage personal profile
+
+
+
+
+
+
Access site admin panel
+
+
+
+
+
+
Use system credentials
+
+
+
+
+
+
+
+
Notes
+
+
Bots do not have UI access.
+
Managers access site admin panels for content and user management.
+
+
+
Example: Authorization Table
+
+
+
+
+
Site_id
+
Role
+
Resource
+
Action
+
Allowed
+
Source
+
+
+
+
+
101
+
manager
+
content
+
create
+
+
site role
+
+
+
101
+
manager
+
content
+
publish
+
+
site role
+
+
+
101
+
manager
+
users
+
manage
+
+
site role
+
+
+
101
+
user
+
content
+
create
+
+
site role
+
+
+
101
+
user
+
content
+
publish
+
+
site role
+
+
+
101
+
bot
+
api
+
access
+
+
API key
+
+
+
101
+
bot
+
content
+
publish
+
+
automation
+
+
+
102
+
manager
+
content
+
publish
+
+
site role
+
+
+
102
+
user
+
content
+
edit
+
+
site role
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/payload.config.ts b/src/payload.config.ts
index 7192d66..87329c8 100644
--- a/src/payload.config.ts
+++ b/src/payload.config.ts
@@ -3,7 +3,7 @@ import { postgresAdapter } from '@payloadcms/db-postgres'
import sharp from 'sharp' // sharp-import
import path from 'path'
-import { buildConfig } from 'payload'
+import { AdminViewConfig, buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import { Categories } from './collections/Categories'
@@ -47,6 +47,15 @@ const [NotFoundPage, NotFoundPageCollection] = createSiteGlobal(NotFoundPageConf
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
+// Predeclare the roles and permissions page component to conform views values with AdminViewConfig
+const UserRolesAndPermissionsView: AdminViewConfig = {
+ Component: '@/components/UserRolesAndPermissions',
+ path: '/sites-roles-and-permissions',
+ meta: {
+ title: 'Payload - Sites Roles and Permissions'
+ }
+}
+
const config = {
admin: {
theme: 'light' as const,
@@ -64,6 +73,7 @@ const config = {
dashboard: {
Component: '@/components/CustomDashboard',
},
+ userRolesAndPermissions: UserRolesAndPermissionsView,
},
},
groups: [