diff --git a/packages/next/src/routes/rest/auth/access.ts b/packages/next/src/routes/rest/auth/access.ts index 1b97821b45..55c4545822 100644 --- a/packages/next/src/routes/rest/auth/access.ts +++ b/packages/next/src/routes/rest/auth/access.ts @@ -6,15 +6,29 @@ import type { BaseRouteHandler } from '../types.js' import { headersWithCors } from '../../../utilities/headersWithCors.js' export const access: BaseRouteHandler = async ({ req }) => { - const results = await accessOperation({ + const headers = headersWithCors({ + headers: new Headers(), req, }) - return Response.json(results, { - headers: headersWithCors({ - headers: new Headers(), + try { + const results = await accessOperation({ req, - }), - status: httpStatus.OK, - }) + }) + + return Response.json(results, { + headers, + status: httpStatus.OK, + }) + } catch (e: unknown) { + return Response.json( + { + error: e, + }, + { + headers, + status: httpStatus.INTERNAL_SERVER_ERROR, + }, + ) + } } diff --git a/packages/next/src/views/Account/index.tsx b/packages/next/src/views/Account/index.tsx index 54091a82f0..b1fd66ba43 100644 --- a/packages/next/src/views/Account/index.tsx +++ b/packages/next/src/views/Account/index.tsx @@ -9,15 +9,21 @@ import { FormQueryParamsProvider } from '@payloadcms/ui/providers/FormQueryParam import { notFound } from 'next/navigation.js' import React from 'react' +import { getDocumentPermissions } from '../Document/getDocumentPermissions.js' import { EditView } from '../Edit/index.js' import { Settings } from './Settings/index.js' export { generateAccountMetadata } from './meta.js' -export const Account: React.FC = ({ initPageResult, params, searchParams }) => { +export const Account: React.FC = async ({ + initPageResult, + params, + searchParams, +}) => { const { locale, permissions, + req, req: { i18n, payload, @@ -32,11 +38,17 @@ export const Account: React.FC = ({ initPageResult, params, sear serverURL, } = config - const collectionPermissions = permissions?.collections?.[userSlug] - const collectionConfig = config.collections.find((collection) => collection.slug === userSlug) if (collectionConfig) { + const { docPermissions, hasPublishPermission, hasSavePermission } = + await getDocumentPermissions({ + id: user.id, + collectionConfig, + data: user, + req, + }) + const viewComponentProps: ServerSideEditViewProps = { initPageResult, params, @@ -50,9 +62,10 @@ export const Account: React.FC = ({ initPageResult, params, sear action={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`} apiURL={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`} collectionSlug={userSlug} - docPermissions={collectionPermissions} - hasSavePermission={collectionPermissions?.update?.permission} - id={user?.id} + docPermissions={docPermissions} + hasPublishPermission={hasPublishPermission} + hasSavePermission={hasSavePermission} + id={user?.id.toString()} isEditing > => { + const { id, collectionConfig, globalConfig, locale, payload, req } = args + + let data: Data + + if (collectionConfig && id !== undefined && id !== null) { + data = await payload.findByID({ + id, + collection: collectionConfig.slug, + depth: 0, + locale: locale.code, + req, + }) + } + + if (globalConfig) { + data = await payload.findGlobal({ + slug: globalConfig.slug, + depth: 0, + locale: locale.code, + req, + }) + } + + return data +} diff --git a/packages/next/src/views/Document/getDocumentPermissions.tsx b/packages/next/src/views/Document/getDocumentPermissions.tsx new file mode 100644 index 0000000000..295dc0da64 --- /dev/null +++ b/packages/next/src/views/Document/getDocumentPermissions.tsx @@ -0,0 +1,105 @@ +import type { DocumentPermissions } from 'payload/auth' +import type { + Data, + PayloadRequest, + SanitizedCollectionConfig, + SanitizedGlobalConfig, +} from 'payload/types' + +import { hasSavePermission as getHasSavePermission } from '@payloadcms/ui/utilities/hasSavePermission' +import { isEditing as getIsEditing } from '@payloadcms/ui/utilities/isEditing' +import { docAccessOperation, docAccessOperationGlobal } from 'payload/operations' + +export const getDocumentPermissions = async (args: { + collectionConfig?: SanitizedCollectionConfig + data: Data + globalConfig?: SanitizedGlobalConfig + id?: number | string + req: PayloadRequest +}): Promise<{ + docPermissions: DocumentPermissions + hasPublishPermission: boolean + hasSavePermission: boolean +}> => { + const { id, collectionConfig, data = {}, globalConfig, req } = args + + let docPermissions: DocumentPermissions + let hasPublishPermission = false + + if (collectionConfig) { + try { + docPermissions = await docAccessOperation({ + id: id?.toString(), + collection: { + config: collectionConfig, + }, + req: { + ...req, + data, + }, + }) + + if (collectionConfig.versions?.drafts) { + hasPublishPermission = await docAccessOperation({ + id: id?.toString(), + collection: { + config: collectionConfig, + }, + req: { + ...req, + data: { + ...data, + _status: 'published', + }, + }, + }).then(({ update }) => update?.permission) + } + } catch (error) { + console.error(error) // eslint-disable-line no-console + } + } + + if (globalConfig) { + try { + docPermissions = await docAccessOperationGlobal({ + globalConfig, + req: { + ...req, + data, + }, + }) + + if (globalConfig.versions?.drafts) { + hasPublishPermission = await docAccessOperationGlobal({ + globalConfig, + req: { + ...req, + data: { + ...data, + _status: 'published', + }, + }, + }).then(({ update }) => update?.permission) + } + } catch (error) { + console.error(error) // eslint-disable-line no-console + } + } + + const hasSavePermission = getHasSavePermission({ + collectionSlug: collectionConfig?.slug, + docPermissions, + globalSlug: globalConfig?.slug, + isEditing: getIsEditing({ + id, + collectionSlug: collectionConfig?.slug, + globalSlug: globalConfig?.slug, + }), + }) + + return { + docPermissions, + hasPublishPermission, + hasSavePermission, + } +} diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index 54a6548e6f..4c57b3b052 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -1,6 +1,5 @@ import type { EditViewComponent } from 'payload/config' import type { AdminViewComponent, ServerSideEditViewProps } from 'payload/types' -import type { DocumentPermissions } from 'payload/types' import type { AdminViewProps } from 'payload/types' import { DocumentHeader } from '@payloadcms/ui/elements/DocumentHeader' @@ -9,15 +8,15 @@ import { RenderCustomComponent } from '@payloadcms/ui/elements/RenderCustomCompo import { DocumentInfoProvider } from '@payloadcms/ui/providers/DocumentInfo' import { EditDepthProvider } from '@payloadcms/ui/providers/EditDepth' import { FormQueryParamsProvider } from '@payloadcms/ui/providers/FormQueryParams' -import { hasSavePermission as getHasSavePermission } from '@payloadcms/ui/utilities/hasSavePermission' import { isEditing as getIsEditing } from '@payloadcms/ui/utilities/isEditing' import { notFound, redirect } from 'next/navigation.js' -import { docAccessOperation } from 'payload/operations' import React from 'react' import type { GenerateEditViewMetadata } from './getMetaBySegment.js' import { NotFoundView } from '../NotFound/index.js' +import { getDocumentData } from './getDocumentData.js' +import { getDocumentPermissions } from './getDocumentPermissions.js' import { getMetaBySegment } from './getMetaBySegment.js' import { getViewsFromConfig } from './getViewsFromConfig.js' @@ -61,32 +60,33 @@ export const Document: React.FC = async ({ let DefaultView: EditViewComponent let ErrorView: AdminViewComponent - let docPermissions: DocumentPermissions - let hasSavePermission: boolean let apiURL: string let action: string + const data = await getDocumentData({ + id, + collectionConfig, + globalConfig, + locale, + payload, + req, + }) + + const { docPermissions, hasPublishPermission, hasSavePermission } = await getDocumentPermissions({ + id, + collectionConfig, + data, + globalConfig, + req, + }) + if (collectionConfig) { if (!visibleEntities?.collections?.find((visibleSlug) => visibleSlug === collectionSlug)) { notFound() } - try { - docPermissions = await docAccessOperation({ - id, - collection: { - config: collectionConfig, - }, - req, - }) - } catch (error) { - notFound() - } - action = `${serverURL}${apiRoute}/${collectionSlug}${isEditing ? `/${id}` : ''}` - hasSavePermission = getHasSavePermission({ collectionSlug, docPermissions, isEditing }) - apiURL = `${serverURL}${apiRoute}/${collectionSlug}/${id}?locale=${locale.code}${ collectionConfig.versions?.drafts ? '&draft=true' : '' }` @@ -117,9 +117,6 @@ export const Document: React.FC = async ({ notFound() } - docPermissions = permissions?.globals?.[globalSlug] - hasSavePermission = getHasSavePermission({ docPermissions, globalSlug, isEditing }) - action = `${serverURL}${apiRoute}/globals/${globalSlug}` apiURL = `${serverURL}${apiRoute}/${globalSlug}?locale=${locale.code}${ @@ -191,6 +188,7 @@ export const Document: React.FC = async ({ disableActions={false} docPermissions={docPermissions} globalSlug={globalConfig?.slug} + hasPublishPermission={hasPublishPermission} hasSavePermission={hasSavePermission} id={id} isEditing={isEditing} diff --git a/packages/next/src/views/Edit/Default/index.tsx b/packages/next/src/views/Edit/Default/index.tsx index 9eedee3539..c1d7512db1 100644 --- a/packages/next/src/views/Edit/Default/index.tsx +++ b/packages/next/src/views/Edit/Default/index.tsx @@ -44,10 +44,10 @@ export const DefaultEditView: React.FC = () => { disableActions, disableLeaveWithoutSaving, docPermissions, - getDocPermissions, getDocPreferences, getVersions, globalSlug, + hasPublishPermission, hasSavePermission, initialData: data, initialState, @@ -115,7 +115,6 @@ export const DefaultEditView: React.FC = () => { } void getVersions() - void getDocPermissions() if (typeof onSaveFromContext === 'function') { void onSaveFromContext({ @@ -147,7 +146,6 @@ export const DefaultEditView: React.FC = () => { depth, collectionSlug, getVersions, - getDocPermissions, isEditing, refreshCookieAsync, adminRoute, @@ -221,6 +219,7 @@ export const DefaultEditView: React.FC = () => { apiURL={apiURL} data={data} disableActions={disableActions} + hasPublishPermission={hasPublishPermission} hasSavePermission={hasSavePermission} id={id} isEditing={isEditing} diff --git a/packages/next/src/views/LivePreview/index.client.tsx b/packages/next/src/views/LivePreview/index.client.tsx index 88dfb5e2a0..eb9dd68245 100644 --- a/packages/next/src/views/LivePreview/index.client.tsx +++ b/packages/next/src/views/LivePreview/index.client.tsx @@ -61,6 +61,7 @@ const PreviewView: React.FC = ({ docPermissions, getDocPreferences, globalSlug, + hasPublishPermission, hasSavePermission, initialData, initialState, @@ -160,6 +161,7 @@ const PreviewView: React.FC = ({ apiURL={apiURL} data={initialData} disableActions={disableActions} + hasPublishPermission={hasPublishPermission} hasSavePermission={hasSavePermission} id={id} isEditing={isEditing} diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index ed1d580ac6..855deef75d 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -27,6 +27,7 @@ export const DocumentControls: React.FC<{ apiURL: string data?: any disableActions?: boolean + hasPublishPermission?: boolean hasSavePermission?: boolean id?: number | string isAccountView?: boolean diff --git a/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx b/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx index 2366bd470f..98b7c73ed2 100644 --- a/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx +++ b/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx @@ -111,12 +111,6 @@ export const DocumentDrawerContent: React.FC = ({ collectionSlug={collectionConfig.slug} disableActions disableLeaveWithoutSaving - // Do NOT pass in the docPermissions we have here. This is because the permissions we have here do not have their where: { } returns resolved. - // If we set it to null though, the DocumentInfoProvider will fully-fetch the permissions from the server, and the where: { } returns will be resolved. - docPermissions={null} - // Same reason as above. We need to fully-fetch the docPreferences from the server. This is done in DocumentInfoProvider if we set it to null here. - hasSavePermission={null} - // isLoading, id={docID} isEditing={isEditing} onLoadError={onLoadError} diff --git a/packages/ui/src/elements/PublishButton/index.tsx b/packages/ui/src/elements/PublishButton/index.tsx index 96f30c8772..cc86400193 100644 --- a/packages/ui/src/elements/PublishButton/index.tsx +++ b/packages/ui/src/elements/PublishButton/index.tsx @@ -1,26 +1,18 @@ 'use client' -import qs from 'qs' import React, { useCallback } from 'react' import { useForm, useFormModified } from '../../forms/Form/context.js' import { FormSubmit } from '../../forms/Submit/index.js' -import { useConfig } from '../../providers/Config/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' -import { useLocale } from '../../providers/Locale/index.js' import { useTranslation } from '../../providers/Translation/index.js' export const DefaultPublishButton: React.FC<{ label?: string }> = ({ label: labelProp }) => { - const { code } = useLocale() - const { id, collectionSlug, globalSlug, publishedDoc, unpublishedVersions } = useDocumentInfo() - const [hasPublishPermission, setHasPublishPermission] = React.useState(false) - const { getData, submit } = useForm() + const { hasPublishPermission, publishedDoc, unpublishedVersions } = useDocumentInfo() + + const { submit } = useForm() const modified = useFormModified() - const { - routes: { api }, - serverURL, - } = useConfig() const { t } = useTranslation() const label = labelProp || t('version:publishChanges') @@ -35,46 +27,6 @@ export const DefaultPublishButton: React.FC<{ label?: string }> = ({ label: labe }) }, [submit]) - React.useEffect(() => { - const fetchPublishAccess = async () => { - let docAccessURL: string - let operation = 'update' - - const params = { - locale: code || undefined, - } - if (globalSlug) { - docAccessURL = `/globals/${globalSlug}/access` - } else if (collectionSlug) { - if (!id) operation = 'create' - docAccessURL = `/${collectionSlug}/access${id ? `/${id}` : ''}` - } - - if (docAccessURL) { - const data = getData() - - const res = await fetch(`${serverURL}${api}${docAccessURL}?${qs.stringify(params)}`, { - body: JSON.stringify({ - ...data, - _status: 'published', - }), - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - method: 'post', - }) - const json = await res.json() - const result = Boolean(json?.[operation]?.permission) - setHasPublishPermission(result) - } else { - setHasPublishPermission(true) - } - } - - void fetchPublishAccess() - }, [api, code, collectionSlug, getData, globalSlug, id, serverURL]) - if (!hasPublishPermission) return null return ( @@ -96,6 +48,5 @@ type Props = { export const PublishButton: React.FC = ({ CustomComponent }) => { if (CustomComponent) return CustomComponent - return } diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index c91b491c6f..4443453221 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -488,6 +488,10 @@ export const Form: React.FC = (props) => { [getFieldStateBySchemaPath, dispatchFields], ) + useEffect(() => { + if (typeof disabledFromProps === 'boolean') setDisabled(disabledFromProps) + }, [disabledFromProps]) + contextRef.current.submit = submit contextRef.current.getFields = getFields contextRef.current.getField = getField diff --git a/packages/ui/src/providers/DocumentInfo/index.tsx b/packages/ui/src/providers/DocumentInfo/index.tsx index 730c25f873..57073a78b1 100644 --- a/packages/ui/src/providers/DocumentInfo/index.tsx +++ b/packages/ui/src/providers/DocumentInfo/index.tsx @@ -5,7 +5,7 @@ import type { DocumentPermissions, DocumentPreferences, TypeWithID, Where } from import { notFound } from 'next/navigation.js' import qs from 'qs' -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react' +import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import type { DocumentInfoContext, DocumentInfoProps } from './types.js' @@ -32,16 +32,28 @@ export const DocumentInfoProvider: React.FC< children: React.ReactNode } > = ({ children, ...props }) => { - const { id, collectionSlug, globalSlug, onLoadError, onSave: onSaveFromProps } = props + const { + id, + collectionSlug, + docPermissions: docPermissionsFromProps, + globalSlug, + hasPublishPermission: hasPublishPermissionFromProps, + hasSavePermission: hasSavePermissionFromProps, + onLoadError, + onSave: onSaveFromProps, + } = props + const [isLoading, setIsLoading] = useState(false) const [isError, setIsError] = useState(false) const [documentTitle, setDocumentTitle] = useState('') - const [initialData, setInitialData] = useState() + const [data, setData] = useState() const [initialState, setInitialState] = useState() const [publishedDoc, setPublishedDoc] = useState(null) const [versions, setVersions] = useState>>(null) const [docPermissions, setDocPermissions] = useState(null) const [hasSavePermission, setHasSavePermission] = useState(null) + const [hasPublishPermission, setHasPublishPermission] = useState(null) + const hasInitializedDocPermissions = useRef(false) const [unpublishedVersions, setUnpublishedVersions] = useState>>(null) @@ -211,63 +223,81 @@ export const DocumentInfoProvider: React.FC< } }, [i18n, globalSlug, collectionSlug, id, baseURL, locale, versionsConfig, shouldFetchVersions]) - const getDocPermissions = React.useCallback(async () => { - const params = { - locale: locale || undefined, - } + const getDocPermissions = React.useCallback( + async (data: Data) => { + const params = { + locale: locale || undefined, + } - if (isEditing) { - const docAccessURL = collectionSlug - ? `/${collectionSlug}/access/${id}` - : globalSlug - ? `/globals/${globalSlug}/access` - : null + const newIsEditing = getIsEditing({ id: data?.id, collectionSlug, globalSlug }) - if (docAccessURL) { - const res = await fetch(`${serverURL}${api}${docAccessURL}?${qs.stringify(params)}`, { - credentials: 'include', - headers: { - 'Accept-Language': i18n.language, - }, - }) + if (newIsEditing) { + const docAccessURL = collectionSlug + ? `/${collectionSlug}/access/${data.id}` + : globalSlug + ? `/globals/${globalSlug}/access` + : null - const json: DocumentPermissions = await res.json() + if (docAccessURL) { + const res = await fetch(`${serverURL}${api}${docAccessURL}?${qs.stringify(params)}`, { + credentials: 'include', + headers: { + 'Accept-Language': i18n.language, + }, + }) - setDocPermissions(json) + const json: DocumentPermissions = await res.json() + const publishedAccessJSON = await fetch( + `${serverURL}${api}${docAccessURL}?${qs.stringify(params)}`, + { + body: JSON.stringify({ + data: { + ...(data || {}), + _status: 'published', + }, + }), + credentials: 'include', + headers: { + 'Accept-Language': i18n.language, + }, + method: 'POST', + }, + ).then((res) => res.json()) + + setDocPermissions(json) + + setHasSavePermission( + getHasSavePermission({ + collectionSlug, + docPermissions: json, + globalSlug, + isEditing: newIsEditing, + }), + ) + + setHasPublishPermission(publishedAccessJSON?.update?.permission) + } + } else { + // when creating new documents, there is no permissions saved for this document yet + // use the generic entity permissions instead + const newDocPermissions = collectionSlug + ? permissions?.collections?.[collectionSlug] + : permissions?.globals?.[globalSlug] + + setDocPermissions(newDocPermissions) setHasSavePermission( - getHasSavePermission({ collectionSlug, docPermissions: json, globalSlug, isEditing }), + getHasSavePermission({ + collectionSlug, + docPermissions: newDocPermissions, + globalSlug, + isEditing: newIsEditing, + }), ) } - } else { - // when creating new documents, there is no permissions saved for this document yet - // use the generic entity permissions instead - const newDocPermissions = collectionSlug - ? permissions?.collections?.[collectionSlug] - : permissions?.globals?.[globalSlug] - - setDocPermissions(newDocPermissions) - - setHasSavePermission( - getHasSavePermission({ - collectionSlug, - docPermissions: newDocPermissions, - globalSlug, - isEditing, - }), - ) - } - }, [ - serverURL, - api, - id, - permissions, - i18n.language, - locale, - collectionSlug, - globalSlug, - isEditing, - ]) + }, + [serverURL, api, permissions, i18n.language, locale, collectionSlug, globalSlug, isEditing], + ) const getDocPreferences = useCallback(() => { return getPreference(preferencesKey) @@ -320,8 +350,11 @@ export const DocumentInfoProvider: React.FC< serverURL, }) + const newData = json.doc + setInitialState(newState) - setInitialData(json.doc) + setData(newData) + await getDocPermissions(newData) }, [ api, @@ -333,6 +366,7 @@ export const DocumentInfoProvider: React.FC< locale, onSaveFromProps, serverURL, + getDocPermissions, ], ) @@ -359,7 +393,7 @@ export const DocumentInfoProvider: React.FC< signal: abortController.signal, }) - setInitialData(reduceFieldsToValues(result, true)) + setData(reduceFieldsToValues(result, true)) setInitialState(result) } catch (err) { if (!abortController.signal.aborted) { @@ -399,38 +433,49 @@ export const DocumentInfoProvider: React.FC< setDocumentTitle( formatDocTitle({ collectionConfig, - data: { ...initialData, id }, + data: { ...data, id }, dateFormat, fallback: id?.toString(), globalConfig, i18n, }), ) - }, [collectionConfig, initialData, dateFormat, i18n, id, globalConfig]) + }, [collectionConfig, data, dateFormat, i18n, id, globalConfig]) useEffect(() => { const loadDocPermissions = async () => { - const docPermissions: DocumentPermissions = props.docPermissions - const hasSavePermission: boolean = props.hasSavePermission + const docPermissions: DocumentPermissions = docPermissionsFromProps + const hasSavePermission: boolean = hasSavePermissionFromProps + const hasPublishPermission: boolean = hasPublishPermissionFromProps - if (!docPermissions || hasSavePermission === undefined || hasSavePermission === null) { - await getDocPermissions() + if ( + !docPermissions || + hasSavePermission === undefined || + hasSavePermission === null || + hasPublishPermission === undefined || + hasPublishPermission === null + ) { + await getDocPermissions(data) } else { setDocPermissions(docPermissions) setHasSavePermission(hasSavePermission) + setHasPublishPermission(hasPublishPermission) } } - if (collectionSlug || globalSlug) { + if (!hasInitializedDocPermissions.current && data && (collectionSlug || globalSlug)) { + hasInitializedDocPermissions.current = true void loadDocPermissions() } }, [ getDocPermissions, - props.docPermissions, - props.hasSavePermission, + docPermissionsFromProps, + hasSavePermissionFromProps, + hasPublishPermissionFromProps, setDocPermissions, collectionSlug, globalSlug, + data, ]) if (isError) notFound() @@ -446,8 +491,9 @@ export const DocumentInfoProvider: React.FC< getDocPermissions, getDocPreferences, getVersions, + hasPublishPermission, hasSavePermission, - initialData, + initialData: data, initialState, onSave, publishedDoc, diff --git a/packages/ui/src/providers/DocumentInfo/types.ts b/packages/ui/src/providers/DocumentInfo/types.ts index 6f3cf3ad1f..755c3198e8 100644 --- a/packages/ui/src/providers/DocumentInfo/types.ts +++ b/packages/ui/src/providers/DocumentInfo/types.ts @@ -26,6 +26,7 @@ export type DocumentInfoProps = { disableLeaveWithoutSaving?: boolean docPermissions?: DocumentPermissions globalSlug?: SanitizedGlobalConfig['slug'] + hasPublishPermission?: boolean hasSavePermission?: boolean id: null | number | string isEditing?: boolean @@ -35,7 +36,7 @@ export type DocumentInfoProps = { export type DocumentInfoContext = DocumentInfoProps & { docConfig?: ClientCollectionConfig | ClientGlobalConfig - getDocPermissions: () => Promise + getDocPermissions: (data?: Data) => Promise getDocPreferences: () => Promise getVersions: () => Promise initialData: Data diff --git a/test/access-control/config.ts b/test/access-control/config.ts index 6f2a949062..e16a90a86d 100644 --- a/test/access-control/config.ts +++ b/test/access-control/config.ts @@ -4,7 +4,7 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { devUser } from '../credentials.js' import { TestButton } from './TestButton.js' import { - createNotUpdateSlug, + createNotUpdateCollectionSlug, docLevelAccessSlug, firstArrayText, fullyRestrictedSlug, @@ -14,6 +14,7 @@ import { noAdminAccessEmail, nonAdminUserEmail, nonAdminUserSlug, + readNotUpdateGlobalSlug, readOnlyGlobalSlug, readOnlySlug, relyOnRequestHeadersSlug, @@ -22,7 +23,8 @@ import { siblingDataSlug, slug, unrestrictedSlug, - userRestrictedSlug, + userRestrictedCollectionSlug, + userRestrictedGlobalSlug, } from './shared.js' const openAccess = { @@ -90,6 +92,32 @@ export default buildConfigWithDefaults({ update: () => false, }, }, + { + slug: userRestrictedGlobalSlug, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + access: { + read: () => true, + update: ({ req, data }) => data?.name === req.user?.email, + }, + }, + { + slug: readNotUpdateGlobalSlug, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + access: { + read: () => true, + update: () => false, + }, + }, ], collections: [ { @@ -201,13 +229,13 @@ export default buildConfigWithDefaults({ { name: 'userRestrictedDocs', type: 'relationship', - relationTo: userRestrictedSlug, + relationTo: userRestrictedCollectionSlug, hasMany: true, }, { name: 'createNotUpdateDocs', type: 'relationship', - relationTo: 'create-not-update', + relationTo: createNotUpdateCollectionSlug, hasMany: true, }, ], @@ -243,7 +271,7 @@ export default buildConfigWithDefaults({ }, }, { - slug: userRestrictedSlug, + slug: userRestrictedCollectionSlug, admin: { useAsTitle: 'name', }, @@ -265,7 +293,7 @@ export default buildConfigWithDefaults({ }, }, { - slug: createNotUpdateSlug, + slug: createNotUpdateCollectionSlug, admin: { useAsTitle: 'name', }, @@ -563,5 +591,12 @@ export default buildConfigWithDefaults({ ], }, }) + + await payload.updateGlobal({ + slug: userRestrictedGlobalSlug, + data: { + name: 'dev@payloadcms.com', + }, + }) }, }) diff --git a/test/access-control/e2e.spec.ts b/test/access-control/e2e.spec.ts index ec969712c0..137d2e364e 100644 --- a/test/access-control/e2e.spec.ts +++ b/test/access-control/e2e.spec.ts @@ -4,7 +4,6 @@ import type { TypeWithID } from 'payload/types' import { expect, test } from '@playwright/test' import { devUser } from 'credentials.js' import path from 'path' -import { wait } from 'payload/utilities' import { fileURLToPath } from 'url' import type { PayloadTestSDK } from '../helpers/sdk/index.js' @@ -30,17 +29,20 @@ import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' import { - createNotUpdateSlug, + createNotUpdateCollectionSlug, docLevelAccessSlug, fullyRestrictedSlug, noAdminAccessEmail, nonAdminUserEmail, nonAdminUserSlug, + readNotUpdateGlobalSlug, readOnlyGlobalSlug, readOnlySlug, restrictedVersionsSlug, slug, unrestrictedSlug, + userRestrictedCollectionSlug, + userRestrictedGlobalSlug, } from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -63,6 +65,8 @@ describe('access control', () => { let readOnlyCollectionUrl: AdminUrlUtil let readOnlyGlobalUrl: AdminUrlUtil let restrictedVersionsUrl: AdminUrlUtil + let userRestrictedCollectionURL: AdminUrlUtil + let userRestrictedGlobalURL: AdminUrlUtil let serverURL: string let context: BrowserContext let logoutURL: string @@ -77,6 +81,8 @@ describe('access control', () => { readOnlyCollectionUrl = new AdminUrlUtil(serverURL, readOnlySlug) readOnlyGlobalUrl = new AdminUrlUtil(serverURL, readOnlySlug) restrictedVersionsUrl = new AdminUrlUtil(serverURL, restrictedVersionsSlug) + userRestrictedCollectionURL = new AdminUrlUtil(serverURL, userRestrictedCollectionSlug) + userRestrictedGlobalURL = new AdminUrlUtil(serverURL, userRestrictedGlobalSlug) context = await browser.newContext() page = await context.newPage() @@ -134,7 +140,7 @@ describe('access control', () => { }) }) - describe('collection - fully restricted', () => { + describe('collection — fully restricted', () => { let existingDoc: ReadOnlyCollection beforeAll(async () => { @@ -178,7 +184,7 @@ describe('access control', () => { }) }) - describe('collection - read-only', () => { + describe('collection — read-only', () => { let existingDoc: ReadOnlyCollection beforeAll(async () => { @@ -215,7 +221,7 @@ describe('access control', () => { await expect(page.locator(`#card-${readOnlySlug}`)).not.toHaveClass('card__actions') }) - test('edit view should not have actions buttons', async () => { + test('should not display actions on edit view', async () => { await page.goto(readOnlyCollectionUrl.edit(existingDoc.id)) await expect(page.locator('.collection-edit__collection-actions li')).toHaveCount(0) }) @@ -234,9 +240,9 @@ describe('access control', () => { }) }) - describe('collection - create but not edit', () => { + describe('collection — create but not edit', () => { test('should not show edit button', async () => { - const createNotUpdateURL = new AdminUrlUtil(serverURL, createNotUpdateSlug) + const createNotUpdateURL = new AdminUrlUtil(serverURL, createNotUpdateCollectionSlug) await page.goto(createNotUpdateURL.create) await page.waitForURL(createNotUpdateURL.create) await expect(page.locator('#field-name')).toBeVisible() @@ -265,7 +271,7 @@ describe('access control', () => { await expect(addDocButton).toBeVisible() await addDocButton.click() - const documentDrawer = page.locator('[id^=doc-drawer_create-not-update_1_]') + const documentDrawer = page.locator(`[id^=doc-drawer_${createNotUpdateCollectionSlug}_1_]`) await expect(documentDrawer).toBeVisible() await expect(documentDrawer.locator('#action-save')).toBeVisible() await documentDrawer.locator('#field-name').fill('name') @@ -277,41 +283,116 @@ describe('access control', () => { }) }) - describe('collection - dynamic update access', () => { - test('maintain access control in document drawer', async () => { - const unrestrictedDoc = await payload.create({ - collection: unrestrictedSlug, - data: { - name: 'unrestricted-123', - }, - }) - - await page.goto(unrestrictedURL.edit(unrestrictedDoc.id.toString())) - - const addDocButton = page.locator( - '#userRestrictedDocs-add-new button.relationship-add-new__add-button.doc-drawer__toggler', - ) - - await addDocButton.click() - const documentDrawer = 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 expect(page.locator('.Toastify')).toContainText('successfully') - await expect(documentDrawer.locator('#field-name')).toBeDisabled() - await documentDrawer.locator('button.doc-drawer__header-close').click() - await expect(documentDrawer).toBeHidden() - await addDocButton.click() - const documentDrawer2 = 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 expect(page.locator('.Toastify')).toContainText('successfully') - await expect(documentDrawer2.locator('#field-name')).toBeEnabled() + describe('global — read but not update', () => { + test('should not show edit button', async () => { + const createNotUpdateURL = new AdminUrlUtil(serverURL, readNotUpdateGlobalSlug) + await page.goto(createNotUpdateURL.global(readNotUpdateGlobalSlug)) + await page.waitForURL(createNotUpdateURL.global(readNotUpdateGlobalSlug)) + await expect(page.locator('#field-name')).toBeVisible() + await expect(page.locator('#field-name')).toBeDisabled() + await expect(page.locator('#action-save')).toBeHidden() }) }) - describe('collection - restricted versions', () => { + describe('dynamic update access', () => { + describe('collection', () => { + test('should restrict update access based on document field', async () => { + await page.goto(userRestrictedCollectionURL.create) + await expect(page.locator('#field-name')).toBeVisible() + await page.locator('#field-name').fill('anonymous@email.com') + await page.locator('#action-save').click() + await expect(page.locator('.Toastify')).toContainText('successfully') + await expect(page.locator('#field-name')).toBeDisabled() + await expect(page.locator('#action-save')).toBeHidden() + + await page.goto(userRestrictedCollectionURL.create) + await expect(page.locator('#field-name')).toBeVisible() + await page.locator('#field-name').fill(devUser.email) + await page.locator('#action-save').click() + await expect(page.locator('.Toastify')).toContainText('successfully') + await expect(page.locator('#field-name')).toBeEnabled() + await expect(page.locator('#action-save')).toBeVisible() + }) + + test('maintain access control in document drawer', async () => { + const unrestrictedDoc = await payload.create({ + collection: unrestrictedSlug, + data: { + name: 'unrestricted-123', + }, + }) + + await page.goto(unrestrictedURL.edit(unrestrictedDoc.id.toString())) + + const addDocButton = page.locator( + '#userRestrictedDocs-add-new button.relationship-add-new__add-button.doc-drawer__toggler', + ) + + await addDocButton.click() + const documentDrawer = page.locator('[id^=doc-drawer_user-restricted-collection_1_]') + await expect(documentDrawer).toBeVisible() + await documentDrawer.locator('#field-name').fill('anonymous@email.com') + await documentDrawer.locator('#action-save').click() + await expect(page.locator('.Toastify')).toContainText('successfully') + await expect(documentDrawer.locator('#field-name')).toBeDisabled() + await documentDrawer.locator('button.doc-drawer__header-close').click() + await expect(documentDrawer).toBeHidden() + await addDocButton.click() + const documentDrawer2 = page.locator('[id^=doc-drawer_user-restricted-collection_1_]') + await expect(documentDrawer2).toBeVisible() + await documentDrawer2.locator('#field-name').fill('dev@payloadcms.com') + await documentDrawer2.locator('#action-save').click() + await expect(page.locator('.Toastify')).toContainText('successfully') + await expect(documentDrawer2.locator('#field-name')).toBeEnabled() + }) + }) + + describe('global', () => { + test('should restrict update access based on document field', async () => { + await page.goto(userRestrictedGlobalURL.global(userRestrictedGlobalSlug)) + await page.waitForURL(userRestrictedGlobalURL.global(userRestrictedGlobalSlug)) + await expect(page.locator('#field-name')).toBeVisible() + await expect(page.locator('#field-name')).toHaveValue(devUser.email) + await expect(page.locator('#field-name')).toBeEnabled() + await page.locator('#field-name').fill('anonymous@email.com') + await page.locator('#action-save').click() + await expect(page.locator('.Toastify')).toContainText( + 'You are not allowed to perform this action', + ) + + await payload.updateGlobal({ + slug: userRestrictedGlobalSlug, + data: { + name: 'anonymous@payloadcms.com', + }, + }) + + await page.goto(userRestrictedGlobalURL.global(userRestrictedGlobalSlug)) + await page.waitForURL(userRestrictedGlobalURL.global(userRestrictedGlobalSlug)) + await expect(page.locator('#field-name')).toBeDisabled() + await expect(page.locator('#action-save')).toBeHidden() + }) + + test('should restrict access based on user settings', async () => { + const url = `${serverURL}/admin/globals/settings` + await page.goto(url) + await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain(url) + await openNav(page) + await expect(page.locator('#nav-global-settings')).toBeVisible() + await expect(page.locator('#nav-global-test')).toBeHidden() + await closeNav(page) + await page.locator('.checkbox-input:has(#field-test) input').check() + await saveDocAndAssert(page) + await openNav(page) + const globalTest = page.locator('#nav-global-test') + await expect(async () => await globalTest.isVisible()).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) + }) + }) + }) + + describe('collection — restricted versions', () => { let existingDoc: RestrictedVersion beforeAll(async () => { @@ -346,7 +427,7 @@ describe('access control', () => { }) }) - test('disable field based on document data', async () => { + test('should disable field based on document data', async () => { await page.goto(docLevelAccessURL.edit(existingDoc.id)) // validate that the text input is disabled because the field is "locked" @@ -354,7 +435,7 @@ describe('access control', () => { await expect(isDisabled).toBeDisabled() }) - test('disable operation based on document data', async () => { + test('should disable operation based on document data', async () => { await page.goto(docLevelAccessURL.edit(existingDoc.id)) // validate that the delete action is not displayed @@ -371,37 +452,6 @@ describe('access control', () => { }) }) - // TODO: Test flakes. In CI, test global does not appear in nav. Perhaps the checkbox setValue is not triggered BEFORE the document is saved, as the custom save button can be clicked even if the form has not been set to modified. - test('should show test global immediately after allowing access', async () => { - const url = `${serverURL}/admin/globals/settings` - await page.goto(url) - - await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain(url) - - await openNav(page) - - // Ensure that we have loaded accesses by checking that settings collection - // at least is visible in the menu. - await expect(page.locator('#nav-global-settings')).toBeVisible() - - // Test collection should be hidden at first. - await expect(page.locator('#nav-global-test')).toBeHidden() - - await closeNav(page) - - // Allow access to test global. - await page.locator('.checkbox-input:has(#field-test) input').check() - await saveDocAndAssert(page) - - await openNav(page) - - const globalTest = page.locator('#nav-global-test') - - await expect(async () => await globalTest.isVisible()).toPass({ - timeout: POLL_TOPASS_TIMEOUT, - }) - }) - describe('admin access', () => { test('should block admin access to admin user', async () => { const adminURL = `${serverURL}/admin` diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index 26f3b7e782..8a20b7462f 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -14,8 +14,8 @@ export interface Config { unrestricted: Unrestricted; 'fully-restricted': FullyRestricted; 'read-only-collection': ReadOnlyCollection; - 'user-restricted': UserRestricted; - 'create-not-update': CreateNotUpdate; + 'user-restricted-collection': UserRestrictedCollection; + 'create-not-update-collection': CreateNotUpdateCollection; 'restricted-versions': RestrictedVersion; 'sibling-data': SiblingDatum; 'rely-on-request-headers': RelyOnRequestHeader; @@ -30,6 +30,8 @@ export interface Config { settings: Setting; test: Test; 'read-only-global': ReadOnlyGlobal; + 'user-restricted-global': UserRestrictedGlobal; + 'read-not-update-global': ReadNotUpdateGlobal; }; locale: null; user: @@ -97,16 +99,16 @@ export interface Post { export interface Unrestricted { id: string; name?: string | null; - userRestrictedDocs?: (string | UserRestricted)[] | null; - createNotUpdateDocs?: (string | CreateNotUpdate)[] | null; + userRestrictedDocs?: (string | UserRestrictedCollection)[] | null; + createNotUpdateDocs?: (string | CreateNotUpdateCollection)[] | null; updatedAt: string; createdAt: string; } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "user-restricted". + * via the `definition` "user-restricted-collection". */ -export interface UserRestricted { +export interface UserRestrictedCollection { id: string; name?: string | null; updatedAt: string; @@ -114,9 +116,9 @@ export interface UserRestricted { } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "create-not-update". + * via the `definition` "create-not-update-collection". */ -export interface CreateNotUpdate { +export interface CreateNotUpdateCollection { id: string; name?: string | null; updatedAt: string; @@ -303,6 +305,26 @@ export interface ReadOnlyGlobal { updatedAt?: string | null; createdAt?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "user-restricted-global". + */ +export interface UserRestrictedGlobal { + id: string; + name?: string | null; + updatedAt?: string | null; + createdAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "read-not-update-global". + */ +export interface ReadNotUpdateGlobal { + id: string; + name?: string | null; + updatedAt?: string | null; + createdAt?: string | null; +} declare module 'payload' { diff --git a/test/access-control/shared.ts b/test/access-control/shared.ts index b9a5cf5372..e982ed0d3e 100644 --- a/test/access-control/shared.ts +++ b/test/access-control/shared.ts @@ -6,9 +6,11 @@ export const unrestrictedSlug = 'unrestricted' export const readOnlySlug = 'read-only-collection' export const readOnlyGlobalSlug = 'read-only-global' -export const userRestrictedSlug = 'user-restricted' +export const userRestrictedCollectionSlug = 'user-restricted-collection' export const fullyRestrictedSlug = 'fully-restricted' -export const createNotUpdateSlug = 'create-not-update' +export const createNotUpdateCollectionSlug = 'create-not-update-collection' +export const userRestrictedGlobalSlug = 'user-restricted-global' +export const readNotUpdateGlobalSlug = 'read-not-update-global' export const restrictedVersionsSlug = 'restricted-versions' export const siblingDataSlug = 'sibling-data' export const relyOnRequestHeadersSlug = 'rely-on-request-headers' diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 790de3dc56..763fc8a8f6 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -206,7 +206,7 @@ describe('versions', () => { await expect(findTableCell(page, '_status', 'Draft Title')).toContainText('Draft') }) - test('bulk update - should publish changes', async () => { + test('bulk update — should publish changes', async () => { const description = 'published document' await page.goto(url.list) @@ -233,7 +233,7 @@ describe('versions', () => { await expect(findTableCell(page, '_status', 'Draft Title')).toContainText('Published') }) - test('bulk update - should draft changes', async () => { + test('bulk update — should draft changes', async () => { const description = 'draft document' await page.goto(url.list) @@ -259,7 +259,7 @@ describe('versions', () => { await expect(findTableCell(page, '_status', 'Draft Title')).toContainText('Draft') }) - test('collection - has versions tab', async () => { + test('collection — has versions tab', async () => { await page.goto(url.list) await page.locator('tbody tr .cell-title a').first().click() @@ -276,7 +276,7 @@ describe('versions', () => { expect(href).toBe(`${pathname}/versions`) }) - test('collection - tab displays proper number of versions', async () => { + test('collection — tab displays proper number of versions', async () => { await page.goto(url.list) const linkToDoc = page @@ -297,12 +297,10 @@ describe('versions', () => { expect(versionCount).toBe('11') }) - test('collection - has versions route', async () => { + test('collection — has versions route', async () => { await page.goto(url.list) await page.locator('tbody tr .cell-title a').first().click() - await page.waitForSelector('.doc-header__title', { state: 'visible' }) - await page.goto(`${page.url()}/versions`) expect(page.url()).toMatch(/\/versions/) }) @@ -361,7 +359,7 @@ describe('versions', () => { // TODO: Check versions/:version-id view for collections / globals - test('global - has versions tab', async () => { + test('global — has versions tab', async () => { const global = new AdminUrlUtil(serverURL, draftGlobalSlug) await page.goto(global.global(draftGlobalSlug)) @@ -378,7 +376,7 @@ describe('versions', () => { expect(href).toBe(`${pathname}/versions`) }) - test('global - has versions route', async () => { + test('global — has versions route', async () => { const global = new AdminUrlUtil(serverURL, globalSlug) const versionsURL = `${global.global(globalSlug)}/versions` await page.goto(versionsURL) @@ -488,7 +486,7 @@ describe('versions', () => { await expect(page.locator('#field-title')).toHaveValue(spanishTitle) }) - test('collection - autosave should only update the current document', async () => { + test('collection — autosave should only update the current document', async () => { // create and save first doc await page.goto(autosaveURL.create) // Should redirect from /create to /[collectionslug]/[new id] due to auto-save @@ -547,20 +545,18 @@ describe('versions', () => { await expect(page.locator('#field-title')).toHaveValue('title') }) - test('should hide publish when access control prevents updating on globals', async () => { + test('globals — should hide publish button when access control prevents update', async () => { const url = new AdminUrlUtil(serverURL, disablePublishGlobalSlug) await page.goto(url.global(disablePublishGlobalSlug)) - await expect(page.locator('#action-save')).not.toBeAttached() }) - test('should hide publish when access control prevents create operation', async () => { + test('collections — should hide publish button when access control prevents create', async () => { await page.goto(disablePublishURL.create) - await expect(page.locator('#action-save')).not.toBeAttached() }) - test('should hide publish when access control prevents update operation', async () => { + test('collections — should hide publish button when access control prevents update', async () => { const publishedDoc = await payload.create({ collection: disablePublishSlug, data: { @@ -571,7 +567,6 @@ describe('versions', () => { }) await page.goto(disablePublishURL.edit(String(publishedDoc.id))) - await expect(page.locator('#action-save')).not.toBeAttached() }) diff --git a/tsconfig.json b/tsconfig.json index d55001935b..609ea599b2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,7 +37,7 @@ ], "paths": { "@payload-config": [ - "./test/_community/config.ts" + "./test/access-control/config.ts" ], "@payloadcms/live-preview": [ "./packages/live-preview/src"