diff --git a/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx b/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx index 82cf3e4e6c..7c552fa336 100644 --- a/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx +++ b/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx @@ -12,20 +12,20 @@ import Button from '../Button'; import { useConfig } from '../../utilities/Config'; import { useLocale } from '../../utilities/Locale'; import { useAuth } from '../../utilities/Auth'; -import { DocumentInfoProvider } from '../../utilities/DocumentInfo'; +import { DocumentInfoProvider, useDocumentInfo } from '../../utilities/DocumentInfo'; import RenderCustomComponent from '../../utilities/RenderCustomComponent'; import usePayloadAPI from '../../../hooks/usePayloadAPI'; import formatFields from '../../views/collections/Edit/formatFields'; import { useRelatedCollections } from '../../forms/field-types/Relationship/AddNew/useRelatedCollections'; import IDLabel from '../IDLabel'; import { baseClass } from '.'; +import { CollectionPermission } from '../../../../auth'; -export const DocumentDrawerContent: React.FC = ({ +const Content: React.FC = ({ collectionSlug, - id, drawerSlug, - onSave: onSaveFromProps, customHeader, + onSave, }) => { const { serverURL, routes: { api } } = useConfig(); const { toggleModal, modalState, closeModal } = useModal(); @@ -36,20 +36,23 @@ export const DocumentDrawerContent: React.FC = ({ const hasInitializedState = useRef(false); const [isOpen, setIsOpen] = useState(false); const [collectionConfig] = useRelatedCollections(collectionSlug); + const { docPermissions, id } = useDocumentInfo(); const [fields, setFields] = useState(() => formatFields(collectionConfig, true)); + // no need to an additional requests when creating new documents + const initialID = useRef(id); + const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI( + (initialID.current ? `${serverURL}${api}/${collectionSlug}/${initialID.current}` : null), + { initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } }, + ); + useEffect(() => { setFields(formatFields(collectionConfig, true)); }, [collectionSlug, collectionConfig]); - const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI( - (id ? `${serverURL}${api}/${collectionSlug}/${id}` : null), - { initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } }, - ); - useEffect(() => { - if (isLoadingDocument) { + if (isLoadingDocument || hasInitializedState.current) { return; } @@ -81,62 +84,87 @@ export const DocumentDrawerContent: React.FC = ({ } }, [isError, t, isOpen, data, drawerSlug, closeModal, isLoadingDocument]); + if (isError) return null; + + const isEditing = Boolean(id); + const apiURL = id ? `${serverURL}${api}/${collectionSlug}/${id}` : null; + const action = `${serverURL}${api}/${collectionSlug}${id ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`; + const hasSavePermission = (isEditing && docPermissions?.update?.permission) || (!isEditing && (docPermissions as CollectionPermission)?.create?.permission); + const isLoading = !internalState || !docPermissions || isLoadingDocument; + + return ( + +
+

+ {!customHeader ? t(!id ? 'fields:addNewLabel' : 'general:editLabel', { label: getTranslation(collectionConfig.labels.singular, i18n) }) : customHeader} +

+ +
+ {id && ( + + )} + + ), + }} + /> + ); +}; + +// First provide the document context using `DocumentInfoProvider` +// this is so we can utilize the `useDocumentInfo` hook in the `Content` component +// this drawer is used for both creating and editing documents +// this means that the `id` may be unknown until the document is created +export const DocumentDrawerContent: React.FC = (props) => { + const { collectionSlug, id: idFromProps, onSave: onSaveFromProps } = props; + const [collectionConfig] = useRelatedCollections(collectionSlug); + const [id, setId] = useState(idFromProps); + const onSave = useCallback((args) => { + setId(args.doc.id); + if (typeof onSaveFromProps === 'function') { onSaveFromProps({ ...args, collectionConfig, }); } - }, [collectionConfig, onSaveFromProps]); - - if (isError) return null; + }, [onSaveFromProps, collectionConfig]); return ( - -
-

- {!customHeader ? t(!id ? 'fields:addNewLabel' : 'general:editLabel', { label: getTranslation(collectionConfig.labels.singular, i18n) }) : customHeader} -

- -
- {id && ( - - )} - - ), - }} +
); diff --git a/src/admin/components/utilities/Auth/index.tsx b/src/admin/components/utilities/Auth/index.tsx index 85257fb12f..657c164aa9 100644 --- a/src/admin/components/utilities/Auth/index.tsx +++ b/src/admin/components/utilities/Auth/index.tsx @@ -92,7 +92,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const json: Permissions = await request.json(); setPermissions(json); } else { - throw new Error("Fetching permissions failed with status code " + request.status); + throw new Error(`Fetching permissions failed with status code ${request.status}`); } }, [serverURL, api, i18n]); @@ -135,7 +135,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children if (id) { refreshPermissions(); } - }, [i18n, id, api, serverURL]); + }, [i18n, id, api, serverURL, refreshPermissions]); useEffect(() => { let reminder: ReturnType; diff --git a/test/access-control/config.ts b/test/access-control/config.ts index 31e9ca53e9..0fe54403d5 100644 --- a/test/access-control/config.ts +++ b/test/access-control/config.ts @@ -1,11 +1,13 @@ import { devUser } from '../credentials'; import { buildConfig } from '../buildConfig'; import { FieldAccess } from '../../src/fields/config/types'; -import { SiblingDatum } from './payload-types'; import { firstArrayText, secondArrayText } from './shared'; export const slug = 'posts'; +export const unrestrictedSlug = 'unrestricted'; export const readOnlySlug = 'read-only-collection'; + +export const userRestrictedSlug = 'user-restricted'; export const restrictedSlug = 'restricted'; export const restrictedVersionsSlug = 'restricted-versions'; export const siblingDataSlug = 'sibling-data'; @@ -110,6 +112,21 @@ export default buildConfig({ }, ], }, + { + slug: unrestrictedSlug, + fields: [ + { + name: 'name', + type: 'text', + }, + { + name: 'userRestrictedDocs', + type: 'relationship', + relationTo: userRestrictedSlug, + hasMany: true, + }, + ], + }, { slug: restrictedSlug, fields: [ @@ -140,6 +157,28 @@ export default buildConfig({ delete: () => false, }, }, + { + slug: userRestrictedSlug, + admin: { + useAsTitle: 'name', + }, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + access: { + create: () => true, + read: () => true, + update: ({ req }) => ({ + name: { + equals: req.user?.email, + }, + }), + delete: () => false, + }, + }, { slug: restrictedVersionsSlug, versions: true, @@ -314,7 +353,7 @@ export default buildConfig({ }, }); - await payload.create({ + await payload.create({ collection: siblingDataSlug, data: { array: [ diff --git a/test/access-control/e2e.spec.ts b/test/access-control/e2e.spec.ts index bc9a204522..69543bdecc 100644 --- a/test/access-control/e2e.spec.ts +++ b/test/access-control/e2e.spec.ts @@ -4,8 +4,9 @@ import payload from '../../src'; import { AdminUrlUtil } from '../helpers/adminUrlUtil'; import { initPayloadE2E } from '../helpers/configHelpers'; import { login } from '../helpers'; -import { restrictedVersionsSlug, readOnlySlug, restrictedSlug, slug, docLevelAccessSlug } from './config'; +import { restrictedVersionsSlug, readOnlySlug, restrictedSlug, slug, docLevelAccessSlug, unrestrictedSlug } from './config'; import type { ReadOnlyCollection, RestrictedVersion } from './payload-types'; +import wait from '../../src/utilities/wait'; /** * TODO: Access Control @@ -183,7 +184,7 @@ describe('access control', () => { beforeAll(async () => { docLevelAccessURL = new AdminUrlUtil(serverURL, docLevelAccessSlug); - existingDoc = await payload.create({ + existingDoc = await payload.create({ collection: docLevelAccessSlug, data: { approvedTitle: 'Title', @@ -215,6 +216,44 @@ describe('access control', () => { await expect(deleteAction).toContainText('Delete'); }); }); + + test('maintain access control in document drawer', async () => { + const unrestrictedDoc = await payload.create({ + collection: unrestrictedSlug, + data: { + name: 'unrestricted-123', + }, + }); + + // navigate to the `unrestricted` document and open the drawers to test access + const unrestrictedURL = new AdminUrlUtil(serverURL, unrestrictedSlug); + await page.goto(unrestrictedURL.edit(unrestrictedDoc.id)); + + const button = await page.locator('#userRestrictedDocs-add-new button.relationship-add-new__add-button.doc-drawer__toggler'); + await button.click(); + const documentDrawer = await page.locator('[id^=doc-drawer_user-restricted_1_]'); + await expect(documentDrawer).toBeVisible(); + await documentDrawer.locator('#field-name').fill('anonymous@email.com'); + await documentDrawer.locator('#action-save').click(); + await wait(200); + await expect(page.locator('.Toastify')).toContainText('successfully'); + + // ensure user is not allowed to edit this document + await expect(await documentDrawer.locator('#field-name')).toBeDisabled(); + await documentDrawer.locator('button.doc-drawer__header-close').click(); + await wait(200); + + await button.click(); + const documentDrawer2 = await page.locator('[id^=doc-drawer_user-restricted_1_]'); + await expect(documentDrawer2).toBeVisible(); + await documentDrawer2.locator('#field-name').fill('dev@payloadcms.com'); + await documentDrawer2.locator('#action-save').click(); + await wait(200); + await expect(page.locator('.Toastify')).toContainText('successfully'); + + // ensure user is allowed to edit this document + await expect(await documentDrawer2.locator('#field-name')).toBeEnabled(); + }); }); async function createDoc(data: any): Promise<{ id: string }> {