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
*/} +
+
+
+ ); +} 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: [