fix(next,ui): fixes global doc permissions and optimizes publish access data loading (#6451)

This commit is contained in:
Jacob Fletcher
2024-05-22 10:03:12 -04:00
committed by GitHub
parent db772a058c
commit 2b941b7e2c
19 changed files with 540 additions and 267 deletions

View File

@@ -6,15 +6,29 @@ import type { BaseRouteHandler } from '../types.js'
import { headersWithCors } from '../../../utilities/headersWithCors.js'
export const access: BaseRouteHandler = async ({ req }) => {
const headers = headersWithCors({
headers: new Headers(),
req,
})
try {
const results = await accessOperation({
req,
})
return Response.json(results, {
headers: headersWithCors({
headers: new Headers(),
req,
}),
headers,
status: httpStatus.OK,
})
} catch (e: unknown) {
return Response.json(
{
error: e,
},
{
headers,
status: httpStatus.INTERNAL_SERVER_ERROR,
},
)
}
}

View File

@@ -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<AdminViewProps> = ({ initPageResult, params, searchParams }) => {
export const Account: React.FC<AdminViewProps> = async ({
initPageResult,
params,
searchParams,
}) => {
const {
locale,
permissions,
req,
req: {
i18n,
payload,
@@ -32,11 +38,17 @@ export const Account: React.FC<AdminViewProps> = ({ 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<AdminViewProps> = ({ 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
>
<DocumentHeader

View File

@@ -0,0 +1,41 @@
import type {
Data,
Payload,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
} from 'payload/types'
export const getDocumentData = async (args: {
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
id?: number | string
locale: Locale
payload: Payload
req: PayloadRequest
}): Promise<Data> => {
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
}

View File

@@ -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,
}
}

View File

@@ -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<AdminViewProps> = 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<AdminViewProps> = 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<AdminViewProps> = async ({
disableActions={false}
docPermissions={docPermissions}
globalSlug={globalConfig?.slug}
hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission}
id={id}
isEditing={isEditing}

View File

@@ -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}

View File

@@ -61,6 +61,7 @@ const PreviewView: React.FC<Props> = ({
docPermissions,
getDocPreferences,
globalSlug,
hasPublishPermission,
hasSavePermission,
initialData,
initialState,
@@ -160,6 +161,7 @@ const PreviewView: React.FC<Props> = ({
apiURL={apiURL}
data={initialData}
disableActions={disableActions}
hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission}
id={id}
isEditing={isEditing}

View File

@@ -27,6 +27,7 @@ export const DocumentControls: React.FC<{
apiURL: string
data?: any
disableActions?: boolean
hasPublishPermission?: boolean
hasSavePermission?: boolean
id?: number | string
isAccountView?: boolean

View File

@@ -111,12 +111,6 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
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}

View File

@@ -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<Props> = ({ CustomComponent }) => {
if (CustomComponent) return CustomComponent
return <DefaultPublishButton />
}

View File

@@ -488,6 +488,10 @@ export const Form: React.FC<FormProps> = (props) => {
[getFieldStateBySchemaPath, dispatchFields],
)
useEffect(() => {
if (typeof disabledFromProps === 'boolean') setDisabled(disabledFromProps)
}, [disabledFromProps])
contextRef.current.submit = submit
contextRef.current.getFields = getFields
contextRef.current.getField = getField

View File

@@ -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<Data>()
const [data, setData] = useState<Data>()
const [initialState, setInitialState] = useState<FormState>()
const [publishedDoc, setPublishedDoc] = useState<TypeWithID & TypeWithTimestamps>(null)
const [versions, setVersions] = useState<PaginatedDocs<TypeWithVersion<any>>>(null)
const [docPermissions, setDocPermissions] = useState<DocumentPermissions>(null)
const [hasSavePermission, setHasSavePermission] = useState<boolean>(null)
const [hasPublishPermission, setHasPublishPermission] = useState<boolean>(null)
const hasInitializedDocPermissions = useRef(false)
const [unpublishedVersions, setUnpublishedVersions] =
useState<PaginatedDocs<TypeWithVersion<any>>>(null)
@@ -211,14 +223,17 @@ export const DocumentInfoProvider: React.FC<
}
}, [i18n, globalSlug, collectionSlug, id, baseURL, locale, versionsConfig, shouldFetchVersions])
const getDocPermissions = React.useCallback(async () => {
const getDocPermissions = React.useCallback(
async (data: Data) => {
const params = {
locale: locale || undefined,
}
if (isEditing) {
const newIsEditing = getIsEditing({ id: data?.id, collectionSlug, globalSlug })
if (newIsEditing) {
const docAccessURL = collectionSlug
? `/${collectionSlug}/access/${id}`
? `/${collectionSlug}/access/${data.id}`
: globalSlug
? `/globals/${globalSlug}/access`
: null
@@ -232,12 +247,35 @@ export const DocumentInfoProvider: React.FC<
})
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 }),
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
@@ -253,21 +291,13 @@ export const DocumentInfoProvider: React.FC<
collectionSlug,
docPermissions: newDocPermissions,
globalSlug,
isEditing,
isEditing: newIsEditing,
}),
)
}
}, [
serverURL,
api,
id,
permissions,
i18n.language,
locale,
collectionSlug,
globalSlug,
isEditing,
])
},
[serverURL, api, permissions, i18n.language, locale, collectionSlug, globalSlug, isEditing],
)
const getDocPreferences = useCallback(() => {
return getPreference<DocumentPreferences>(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,

View File

@@ -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<void>
getDocPermissions: (data?: Data) => Promise<void>
getDocPreferences: () => Promise<DocumentPreferences>
getVersions: () => Promise<void>
initialData: Data

View File

@@ -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',
},
})
},
})

View File

@@ -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,7 +283,37 @@ describe('access control', () => {
})
})
describe('collection - dynamic update access', () => {
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('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,
@@ -293,7 +329,7 @@ describe('access control', () => {
)
await addDocButton.click()
const documentDrawer = page.locator('[id^=doc-drawer_user-restricted_1_]')
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()
@@ -302,7 +338,7 @@ describe('access control', () => {
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_]')
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()
@@ -311,7 +347,52 @@ describe('access control', () => {
})
})
describe('collection - restricted versions', () => {
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`

View File

@@ -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' {

View File

@@ -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'

View File

@@ -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()
})

View File

@@ -37,7 +37,7 @@
],
"paths": {
"@payload-config": [
"./test/_community/config.ts"
"./test/access-control/config.ts"
],
"@payloadcms/live-preview": [
"./packages/live-preview/src"