fix(ui): properly sets hasSavePermission on nested documents (#6394)
This commit is contained in:
@@ -9,6 +9,8 @@ import { RenderCustomComponent } from '@payloadcms/ui/elements/RenderCustomCompo
|
|||||||
import { DocumentInfoProvider } from '@payloadcms/ui/providers/DocumentInfo'
|
import { DocumentInfoProvider } from '@payloadcms/ui/providers/DocumentInfo'
|
||||||
import { EditDepthProvider } from '@payloadcms/ui/providers/EditDepth'
|
import { EditDepthProvider } from '@payloadcms/ui/providers/EditDepth'
|
||||||
import { FormQueryParamsProvider } from '@payloadcms/ui/providers/FormQueryParams'
|
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 { notFound, redirect } from 'next/navigation.js'
|
||||||
import { docAccessOperation } from 'payload/operations'
|
import { docAccessOperation } from 'payload/operations'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
@@ -52,7 +54,7 @@ export const Document: React.FC<AdminViewProps> = async ({
|
|||||||
const collectionSlug = collectionConfig?.slug || undefined
|
const collectionSlug = collectionConfig?.slug || undefined
|
||||||
const globalSlug = globalConfig?.slug || undefined
|
const globalSlug = globalConfig?.slug || undefined
|
||||||
|
|
||||||
const isEditing = Boolean(globalSlug || (collectionSlug && !!id))
|
const isEditing = getIsEditing({ id, collectionSlug, globalSlug })
|
||||||
|
|
||||||
let ViewOverride: EditViewComponent
|
let ViewOverride: EditViewComponent
|
||||||
let CustomView: EditViewComponent
|
let CustomView: EditViewComponent
|
||||||
@@ -83,9 +85,7 @@ export const Document: React.FC<AdminViewProps> = async ({
|
|||||||
|
|
||||||
action = `${serverURL}${apiRoute}/${collectionSlug}${isEditing ? `/${id}` : ''}`
|
action = `${serverURL}${apiRoute}/${collectionSlug}${isEditing ? `/${id}` : ''}`
|
||||||
|
|
||||||
hasSavePermission =
|
hasSavePermission = getHasSavePermission({ collectionSlug, docPermissions, isEditing })
|
||||||
(isEditing && permissions?.collections?.[collectionSlug]?.update?.permission) ||
|
|
||||||
(!isEditing && permissions?.collections?.[collectionSlug]?.create?.permission)
|
|
||||||
|
|
||||||
apiURL = `${serverURL}${apiRoute}/${collectionSlug}/${id}?locale=${locale.code}${
|
apiURL = `${serverURL}${apiRoute}/${collectionSlug}/${id}?locale=${locale.code}${
|
||||||
collectionConfig.versions?.drafts ? '&draft=true' : ''
|
collectionConfig.versions?.drafts ? '&draft=true' : ''
|
||||||
@@ -118,7 +118,8 @@ export const Document: React.FC<AdminViewProps> = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
docPermissions = permissions?.globals?.[globalSlug]
|
docPermissions = permissions?.globals?.[globalSlug]
|
||||||
hasSavePermission = isEditing && docPermissions?.update?.permission
|
hasSavePermission = getHasSavePermission({ docPermissions, globalSlug, isEditing })
|
||||||
|
|
||||||
action = `${serverURL}${apiRoute}/globals/${globalSlug}`
|
action = `${serverURL}${apiRoute}/globals/${globalSlug}`
|
||||||
|
|
||||||
apiURL = `${serverURL}${apiRoute}/${globalSlug}?locale=${locale.code}${
|
apiURL = `${serverURL}${apiRoute}/${globalSlug}?locale=${locale.code}${
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export const DefaultEditView: React.FC = () => {
|
|||||||
disableActions={disableActions}
|
disableActions={disableActions}
|
||||||
hasSavePermission={hasSavePermission}
|
hasSavePermission={hasSavePermission}
|
||||||
id={id}
|
id={id}
|
||||||
isEditing={Boolean(id)}
|
isEditing={isEditing}
|
||||||
permissions={docPermissions}
|
permissions={docPermissions}
|
||||||
slug={collectionConfig?.slug || globalConfig?.slug}
|
slug={collectionConfig?.slug || globalConfig?.slug}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const PreviewView: React.FC<Props> = ({
|
|||||||
hasSavePermission,
|
hasSavePermission,
|
||||||
initialData,
|
initialData,
|
||||||
initialState,
|
initialState,
|
||||||
|
isEditing,
|
||||||
onSave: onSaveFromProps,
|
onSave: onSaveFromProps,
|
||||||
} = useDocumentInfo()
|
} = useDocumentInfo()
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ const PreviewView: React.FC<Props> = ({
|
|||||||
disableActions={disableActions}
|
disableActions={disableActions}
|
||||||
hasSavePermission={hasSavePermission}
|
hasSavePermission={hasSavePermission}
|
||||||
id={id}
|
id={id}
|
||||||
isEditing={Boolean(id)}
|
isEditing={isEditing}
|
||||||
permissions={docPermissions}
|
permissions={docPermissions}
|
||||||
slug={collectionConfig?.slug || globalConfig?.slug}
|
slug={collectionConfig?.slug || globalConfig?.slug}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const FormSubmit = forwardRef<HTMLButtonElement, Props>((props, ref) => {
|
|||||||
const { type = 'submit', buttonId: id, children, disabled: disabledFromProps } = props
|
const { type = 'submit', buttonId: id, children, disabled: disabledFromProps } = props
|
||||||
const processing = useFormProcessing()
|
const processing = useFormProcessing()
|
||||||
const { disabled } = useForm()
|
const { disabled } = useForm()
|
||||||
|
|
||||||
const canSave = !(disabledFromProps || processing || disabled)
|
const canSave = !(disabledFromProps || processing || disabled)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type { DocumentInfoContext, DocumentInfoProps } from './types.js'
|
|||||||
import { LoadingOverlay } from '../../elements/Loading/index.js'
|
import { LoadingOverlay } from '../../elements/Loading/index.js'
|
||||||
import { formatDocTitle } from '../../utilities/formatDocTitle.js'
|
import { formatDocTitle } from '../../utilities/formatDocTitle.js'
|
||||||
import { getFormState } from '../../utilities/getFormState.js'
|
import { getFormState } from '../../utilities/getFormState.js'
|
||||||
|
import { hasSavePermission as getHasSavePermission } from '../../utilities/hasSavePermission.js'
|
||||||
|
import { isEditing as getIsEditing } from '../../utilities/isEditing.js'
|
||||||
import { reduceFieldsToValues } from '../../utilities/reduceFieldsToValues.js'
|
import { reduceFieldsToValues } from '../../utilities/reduceFieldsToValues.js'
|
||||||
import { useAuth } from '../Auth/index.js'
|
import { useAuth } from '../Auth/index.js'
|
||||||
import { useConfig } from '../Config/index.js'
|
import { useConfig } from '../Config/index.js'
|
||||||
@@ -63,25 +65,23 @@ export const DocumentInfoProvider: React.FC<
|
|||||||
|
|
||||||
const baseURL = `${serverURL}${api}`
|
const baseURL = `${serverURL}${api}`
|
||||||
let slug: string
|
let slug: string
|
||||||
let pluralType: 'collections' | 'globals'
|
|
||||||
let preferencesKey: string
|
let preferencesKey: string
|
||||||
|
|
||||||
if (globalSlug) {
|
if (globalSlug) {
|
||||||
slug = globalSlug
|
slug = globalSlug
|
||||||
pluralType = 'globals'
|
|
||||||
preferencesKey = `global-${slug}`
|
preferencesKey = `global-${slug}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (collectionSlug) {
|
if (collectionSlug) {
|
||||||
slug = collectionSlug
|
slug = collectionSlug
|
||||||
pluralType = 'collections'
|
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
preferencesKey = `collection-${slug}-${id}`
|
preferencesKey = `collection-${slug}-${id}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const operation = collectionSlug && !id ? 'create' : 'update'
|
const isEditing = getIsEditing({ id, collectionSlug, globalSlug })
|
||||||
|
const operation = isEditing ? 'update' : 'create'
|
||||||
const shouldFetchVersions = Boolean(versionsConfig && docPermissions?.readVersions?.permission)
|
const shouldFetchVersions = Boolean(versionsConfig && docPermissions?.readVersions?.permission)
|
||||||
|
|
||||||
const getVersions = useCallback(async () => {
|
const getVersions = useCallback(async () => {
|
||||||
@@ -212,15 +212,16 @@ export const DocumentInfoProvider: React.FC<
|
|||||||
}, [i18n, globalSlug, collectionSlug, id, baseURL, locale, versionsConfig, shouldFetchVersions])
|
}, [i18n, globalSlug, collectionSlug, id, baseURL, locale, versionsConfig, shouldFetchVersions])
|
||||||
|
|
||||||
const getDocPermissions = React.useCallback(async () => {
|
const getDocPermissions = React.useCallback(async () => {
|
||||||
let docAccessURL: string
|
|
||||||
const params = {
|
const params = {
|
||||||
locale: locale || undefined,
|
locale: locale || undefined,
|
||||||
}
|
}
|
||||||
if (pluralType === 'globals') {
|
|
||||||
docAccessURL = `/globals/${slug}/access`
|
if (isEditing) {
|
||||||
} else if (pluralType === 'collections' && id) {
|
const docAccessURL = collectionSlug
|
||||||
docAccessURL = `/${slug}/access/${id}`
|
? `/${collectionSlug}/access/${id}`
|
||||||
}
|
: globalSlug
|
||||||
|
? `/globals/${globalSlug}/access`
|
||||||
|
: null
|
||||||
|
|
||||||
if (docAccessURL) {
|
if (docAccessURL) {
|
||||||
const res = await fetch(`${serverURL}${api}${docAccessURL}?${qs.stringify(params)}`, {
|
const res = await fetch(`${serverURL}${api}${docAccessURL}?${qs.stringify(params)}`, {
|
||||||
@@ -229,17 +230,44 @@ export const DocumentInfoProvider: React.FC<
|
|||||||
'Accept-Language': i18n.language,
|
'Accept-Language': i18n.language,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
|
||||||
|
const json: DocumentPermissions = await res.json()
|
||||||
|
|
||||||
setDocPermissions(json)
|
setDocPermissions(json)
|
||||||
setHasSavePermission(json?.update?.permission)
|
|
||||||
} else {
|
setHasSavePermission(
|
||||||
// fallback to permissions from the entity type
|
getHasSavePermission({ collectionSlug, docPermissions: json, globalSlug, isEditing }),
|
||||||
// (i.e. create has no id)
|
)
|
||||||
setDocPermissions(permissions?.[pluralType]?.[slug])
|
|
||||||
setHasSavePermission(permissions?.[pluralType]?.[slug]?.update?.permission)
|
|
||||||
}
|
}
|
||||||
}, [serverURL, api, pluralType, slug, id, permissions, i18n.language, locale])
|
} 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,
|
||||||
|
])
|
||||||
|
|
||||||
const getDocPreferences = useCallback(() => {
|
const getDocPreferences = useCallback(() => {
|
||||||
return getPreference<DocumentPreferences>(preferencesKey)
|
return getPreference<DocumentPreferences>(preferencesKey)
|
||||||
|
|||||||
29
packages/ui/src/utilities/hasSavePermission.ts
Normal file
29
packages/ui/src/utilities/hasSavePermission.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { CollectionPermission, DocumentPermissions, GlobalPermission } from 'payload/auth'
|
||||||
|
|
||||||
|
export const hasSavePermission = (args: {
|
||||||
|
/*
|
||||||
|
* Pass either `collectionSlug` or `globalSlug`
|
||||||
|
*/
|
||||||
|
collectionSlug?: string
|
||||||
|
docPermissions: DocumentPermissions
|
||||||
|
/*
|
||||||
|
* Pass either `collectionSlug` or `globalSlug`
|
||||||
|
*/
|
||||||
|
globalSlug?: string
|
||||||
|
isEditing: boolean
|
||||||
|
}) => {
|
||||||
|
const { collectionSlug, docPermissions, globalSlug, isEditing } = args
|
||||||
|
|
||||||
|
if (collectionSlug) {
|
||||||
|
return Boolean(
|
||||||
|
(isEditing && docPermissions?.update?.permission) ||
|
||||||
|
(!isEditing && (docPermissions as CollectionPermission)?.create?.permission),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalSlug) {
|
||||||
|
return Boolean((docPermissions as GlobalPermission)?.update?.permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
9
packages/ui/src/utilities/isEditing.ts
Normal file
9
packages/ui/src/utilities/isEditing.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export const isEditing = ({
|
||||||
|
id,
|
||||||
|
collectionSlug,
|
||||||
|
globalSlug,
|
||||||
|
}: {
|
||||||
|
collectionSlug?: string
|
||||||
|
globalSlug?: string
|
||||||
|
id?: number | string
|
||||||
|
}): boolean => Boolean(globalSlug || (collectionSlug && !!id))
|
||||||
@@ -4,8 +4,10 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
|
|||||||
import { devUser } from '../credentials.js'
|
import { devUser } from '../credentials.js'
|
||||||
import { TestButton } from './TestButton.js'
|
import { TestButton } from './TestButton.js'
|
||||||
import {
|
import {
|
||||||
|
createNotUpdateSlug,
|
||||||
docLevelAccessSlug,
|
docLevelAccessSlug,
|
||||||
firstArrayText,
|
firstArrayText,
|
||||||
|
fullyRestrictedSlug,
|
||||||
hiddenAccessCountSlug,
|
hiddenAccessCountSlug,
|
||||||
hiddenAccessSlug,
|
hiddenAccessSlug,
|
||||||
hiddenFieldsSlug,
|
hiddenFieldsSlug,
|
||||||
@@ -15,7 +17,6 @@ import {
|
|||||||
readOnlyGlobalSlug,
|
readOnlyGlobalSlug,
|
||||||
readOnlySlug,
|
readOnlySlug,
|
||||||
relyOnRequestHeadersSlug,
|
relyOnRequestHeadersSlug,
|
||||||
restrictedSlug,
|
|
||||||
restrictedVersionsSlug,
|
restrictedVersionsSlug,
|
||||||
secondArrayText,
|
secondArrayText,
|
||||||
siblingDataSlug,
|
siblingDataSlug,
|
||||||
@@ -203,10 +204,16 @@ export default buildConfigWithDefaults({
|
|||||||
relationTo: userRestrictedSlug,
|
relationTo: userRestrictedSlug,
|
||||||
hasMany: true,
|
hasMany: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'createNotUpdateDocs',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'create-not-update',
|
||||||
|
hasMany: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: restrictedSlug,
|
slug: fullyRestrictedSlug,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'name',
|
name: 'name',
|
||||||
@@ -257,6 +264,24 @@ export default buildConfigWithDefaults({
|
|||||||
delete: () => false,
|
delete: () => false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: createNotUpdateSlug,
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'name',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
access: {
|
||||||
|
create: () => true,
|
||||||
|
read: () => true,
|
||||||
|
update: () => false,
|
||||||
|
delete: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: restrictedVersionsSlug,
|
slug: restrictedVersionsSlug,
|
||||||
versions: true,
|
versions: true,
|
||||||
|
|||||||
@@ -28,15 +28,16 @@ import {
|
|||||||
} from '../helpers.js'
|
} from '../helpers.js'
|
||||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||||
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||||
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
||||||
import {
|
import {
|
||||||
|
createNotUpdateSlug,
|
||||||
docLevelAccessSlug,
|
docLevelAccessSlug,
|
||||||
|
fullyRestrictedSlug,
|
||||||
noAdminAccessEmail,
|
noAdminAccessEmail,
|
||||||
nonAdminUserEmail,
|
nonAdminUserEmail,
|
||||||
nonAdminUserSlug,
|
nonAdminUserSlug,
|
||||||
readOnlyGlobalSlug,
|
readOnlyGlobalSlug,
|
||||||
readOnlySlug,
|
readOnlySlug,
|
||||||
restrictedSlug,
|
|
||||||
restrictedVersionsSlug,
|
restrictedVersionsSlug,
|
||||||
slug,
|
slug,
|
||||||
unrestrictedSlug,
|
unrestrictedSlug,
|
||||||
@@ -46,7 +47,6 @@ const dirname = path.dirname(filename)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Access Control
|
* TODO: Access Control
|
||||||
* prevent user from logging in (canAccessAdmin)
|
|
||||||
*
|
*
|
||||||
* FSK: 'should properly prevent / allow public users from reading a restricted field'
|
* FSK: 'should properly prevent / allow public users from reading a restricted field'
|
||||||
*
|
*
|
||||||
@@ -59,6 +59,7 @@ describe('access control', () => {
|
|||||||
let page: Page
|
let page: Page
|
||||||
let url: AdminUrlUtil
|
let url: AdminUrlUtil
|
||||||
let restrictedUrl: AdminUrlUtil
|
let restrictedUrl: AdminUrlUtil
|
||||||
|
let unrestrictedURL: AdminUrlUtil
|
||||||
let readOnlyCollectionUrl: AdminUrlUtil
|
let readOnlyCollectionUrl: AdminUrlUtil
|
||||||
let readOnlyGlobalUrl: AdminUrlUtil
|
let readOnlyGlobalUrl: AdminUrlUtil
|
||||||
let restrictedVersionsUrl: AdminUrlUtil
|
let restrictedVersionsUrl: AdminUrlUtil
|
||||||
@@ -71,7 +72,8 @@ describe('access control', () => {
|
|||||||
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
|
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
|
||||||
|
|
||||||
url = new AdminUrlUtil(serverURL, slug)
|
url = new AdminUrlUtil(serverURL, slug)
|
||||||
restrictedUrl = new AdminUrlUtil(serverURL, restrictedSlug)
|
restrictedUrl = new AdminUrlUtil(serverURL, fullyRestrictedSlug)
|
||||||
|
unrestrictedURL = new AdminUrlUtil(serverURL, unrestrictedSlug)
|
||||||
readOnlyCollectionUrl = new AdminUrlUtil(serverURL, readOnlySlug)
|
readOnlyCollectionUrl = new AdminUrlUtil(serverURL, readOnlySlug)
|
||||||
readOnlyGlobalUrl = new AdminUrlUtil(serverURL, readOnlySlug)
|
readOnlyGlobalUrl = new AdminUrlUtil(serverURL, readOnlySlug)
|
||||||
restrictedVersionsUrl = new AdminUrlUtil(serverURL, restrictedVersionsSlug)
|
restrictedVersionsUrl = new AdminUrlUtil(serverURL, restrictedVersionsSlug)
|
||||||
@@ -93,6 +95,7 @@ describe('access control', () => {
|
|||||||
logoutURL = `${serverURL}${adminRoute}${logoutRoute}`
|
logoutURL = `${serverURL}${adminRoute}${logoutRoute}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('fields', () => {
|
||||||
test('field without read access should not show', async () => {
|
test('field without read access should not show', async () => {
|
||||||
const { id } = await createDoc({ restrictedField: 'restricted' })
|
const { id } = await createDoc({ restrictedField: 'restricted' })
|
||||||
|
|
||||||
@@ -125,12 +128,18 @@ describe('access control', () => {
|
|||||||
await expect(page.locator('#field-restrictedCollapsibleText')).toHaveCount(0)
|
await expect(page.locator('#field-restrictedCollapsibleText')).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('restricted collection', () => {
|
test('should not show field without permission', async () => {
|
||||||
|
await page.goto(url.account)
|
||||||
|
await expect(page.locator('#field-roles')).toBeHidden()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('collection - fully restricted', () => {
|
||||||
let existingDoc: ReadOnlyCollection
|
let existingDoc: ReadOnlyCollection
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
existingDoc = await payload.create({
|
existingDoc = await payload.create({
|
||||||
collection: restrictedSlug,
|
collection: fullyRestrictedSlug,
|
||||||
data: {
|
data: {
|
||||||
name: 'name',
|
name: 'name',
|
||||||
},
|
},
|
||||||
@@ -139,7 +148,7 @@ describe('access control', () => {
|
|||||||
|
|
||||||
test('should not show in card list', async () => {
|
test('should not show in card list', async () => {
|
||||||
await page.goto(url.admin)
|
await page.goto(url.admin)
|
||||||
await expect(page.locator(`#card-${restrictedSlug}`)).toHaveCount(0)
|
await expect(page.locator(`#card-${fullyRestrictedSlug}`)).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should not show in nav', async () => {
|
test('should not show in nav', async () => {
|
||||||
@@ -169,14 +178,7 @@ describe('access control', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('restricted fields', () => {
|
describe('collection - read-only', () => {
|
||||||
test('should not show field without permission', async () => {
|
|
||||||
await page.goto(url.account)
|
|
||||||
await expect(page.locator('#field-roles')).toBeHidden()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('read-only collection', () => {
|
|
||||||
let existingDoc: ReadOnlyCollection
|
let existingDoc: ReadOnlyCollection
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -232,7 +234,84 @@ describe('access control', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('readVersions', () => {
|
describe('collection - create but not edit', () => {
|
||||||
|
test('should not show edit button', async () => {
|
||||||
|
const createNotUpdateURL = new AdminUrlUtil(serverURL, createNotUpdateSlug)
|
||||||
|
await page.goto(createNotUpdateURL.create)
|
||||||
|
await page.waitForURL(createNotUpdateURL.create)
|
||||||
|
await expect(page.locator('#field-name')).toBeVisible()
|
||||||
|
await page.locator('#field-name').fill('name')
|
||||||
|
await expect(page.locator('#field-name')).toHaveValue('name')
|
||||||
|
await expect(page.locator('#action-save')).toBeVisible()
|
||||||
|
await page.locator('#action-save').click()
|
||||||
|
await expect(page.locator('.Toastify')).toContainText('successfully')
|
||||||
|
await expect(page.locator('#action-save')).toBeHidden()
|
||||||
|
await expect(page.locator('#field-name')).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should 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(
|
||||||
|
'#createNotUpdateDocs-add-new button.relationship-add-new__add-button.doc-drawer__toggler',
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(addDocButton).toBeVisible()
|
||||||
|
await addDocButton.click()
|
||||||
|
const documentDrawer = page.locator('[id^=doc-drawer_create-not-update_1_]')
|
||||||
|
await expect(documentDrawer).toBeVisible()
|
||||||
|
await expect(documentDrawer.locator('#action-save')).toBeVisible()
|
||||||
|
await documentDrawer.locator('#field-name').fill('name')
|
||||||
|
await expect(documentDrawer.locator('#field-name')).toHaveValue('name')
|
||||||
|
await documentDrawer.locator('#action-save').click()
|
||||||
|
await expect(page.locator('.Toastify')).toContainText('successfully')
|
||||||
|
await expect(documentDrawer.locator('#action-save')).toBeHidden()
|
||||||
|
await expect(documentDrawer.locator('#field-name')).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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('collection - restricted versions', () => {
|
||||||
let existingDoc: RestrictedVersion
|
let existingDoc: RestrictedVersion
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -323,44 +402,7 @@ describe('access control', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('maintain access control in document drawer', async () => {
|
describe('admin access', () => {
|
||||||
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.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')
|
|
||||||
|
|
||||||
// ensure user is not allowed to edit this document
|
|
||||||
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')
|
|
||||||
|
|
||||||
// ensure user is allowed to edit this document
|
|
||||||
await expect(documentDrawer2.locator('#field-name')).toBeEnabled()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should block admin access to admin user', async () => {
|
test('should block admin access to admin user', async () => {
|
||||||
const adminURL = `${serverURL}/admin`
|
const adminURL = `${serverURL}/admin`
|
||||||
await page.goto(adminURL)
|
await page.goto(adminURL)
|
||||||
@@ -430,6 +472,7 @@ describe('access control', () => {
|
|||||||
await expect(page.locator('.next-error-h1')).toBeVisible()
|
await expect(page.locator('.next-error-h1')).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
async function createDoc(data: any): Promise<TypeWithID & Record<string, unknown>> {
|
async function createDoc(data: any): Promise<TypeWithID & Record<string, unknown>> {
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { initPayloadInt } from '../helpers/initPayloadInt.js'
|
|||||||
import configPromise, { requestHeaders } from './config.js'
|
import configPromise, { requestHeaders } from './config.js'
|
||||||
import {
|
import {
|
||||||
firstArrayText,
|
firstArrayText,
|
||||||
|
fullyRestrictedSlug,
|
||||||
hiddenAccessCountSlug,
|
hiddenAccessCountSlug,
|
||||||
hiddenAccessSlug,
|
hiddenAccessSlug,
|
||||||
hiddenFieldsSlug,
|
hiddenFieldsSlug,
|
||||||
relyOnRequestHeadersSlug,
|
relyOnRequestHeadersSlug,
|
||||||
restrictedSlug,
|
|
||||||
restrictedVersionsSlug,
|
restrictedVersionsSlug,
|
||||||
secondArrayText,
|
secondArrayText,
|
||||||
siblingDataSlug,
|
siblingDataSlug,
|
||||||
@@ -36,7 +36,7 @@ describe('Access Control', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
restricted = await payload.create({
|
restricted = await payload.create({
|
||||||
collection: restrictedSlug,
|
collection: fullyRestrictedSlug,
|
||||||
data: { name: 'restricted' },
|
data: { name: 'restricted' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -324,7 +324,7 @@ describe('Access Control', () => {
|
|||||||
it('should allow overrideAccess: false', async () => {
|
it('should allow overrideAccess: false', async () => {
|
||||||
const req = async () =>
|
const req = async () =>
|
||||||
await payload.update({
|
await payload.update({
|
||||||
collection: restrictedSlug,
|
collection: fullyRestrictedSlug,
|
||||||
id: restricted.id,
|
id: restricted.id,
|
||||||
data: { name: updatedName },
|
data: { name: updatedName },
|
||||||
overrideAccess: false, // this should respect access control
|
overrideAccess: false, // this should respect access control
|
||||||
@@ -335,7 +335,7 @@ describe('Access Control', () => {
|
|||||||
|
|
||||||
it('should allow overrideAccess: true', async () => {
|
it('should allow overrideAccess: true', async () => {
|
||||||
const doc = await payload.update({
|
const doc = await payload.update({
|
||||||
collection: restrictedSlug,
|
collection: fullyRestrictedSlug,
|
||||||
id: restricted.id,
|
id: restricted.id,
|
||||||
data: { name: updatedName },
|
data: { name: updatedName },
|
||||||
overrideAccess: true, // this should override access control
|
overrideAccess: true, // this should override access control
|
||||||
@@ -346,7 +346,7 @@ describe('Access Control', () => {
|
|||||||
|
|
||||||
it('should allow overrideAccess by default', async () => {
|
it('should allow overrideAccess by default', async () => {
|
||||||
const doc = await payload.update({
|
const doc = await payload.update({
|
||||||
collection: restrictedSlug,
|
collection: fullyRestrictedSlug,
|
||||||
id: restricted.id,
|
id: restricted.id,
|
||||||
data: { name: updatedName },
|
data: { name: updatedName },
|
||||||
})
|
})
|
||||||
@@ -357,7 +357,7 @@ describe('Access Control', () => {
|
|||||||
it('should allow overrideAccess: false - update many', async () => {
|
it('should allow overrideAccess: false - update many', async () => {
|
||||||
const req = async () =>
|
const req = async () =>
|
||||||
await payload.update({
|
await payload.update({
|
||||||
collection: restrictedSlug,
|
collection: fullyRestrictedSlug,
|
||||||
where: {
|
where: {
|
||||||
id: { equals: restricted.id },
|
id: { equals: restricted.id },
|
||||||
},
|
},
|
||||||
@@ -370,7 +370,7 @@ describe('Access Control', () => {
|
|||||||
|
|
||||||
it('should allow overrideAccess: true - update many', async () => {
|
it('should allow overrideAccess: true - update many', async () => {
|
||||||
const doc = await payload.update({
|
const doc = await payload.update({
|
||||||
collection: restrictedSlug,
|
collection: fullyRestrictedSlug,
|
||||||
where: {
|
where: {
|
||||||
id: { equals: restricted.id },
|
id: { equals: restricted.id },
|
||||||
},
|
},
|
||||||
@@ -383,7 +383,7 @@ describe('Access Control', () => {
|
|||||||
|
|
||||||
it('should allow overrideAccess by default - update many', async () => {
|
it('should allow overrideAccess by default - update many', async () => {
|
||||||
const doc = await payload.update({
|
const doc = await payload.update({
|
||||||
collection: restrictedSlug,
|
collection: fullyRestrictedSlug,
|
||||||
where: {
|
where: {
|
||||||
id: { equals: restricted.id },
|
id: { equals: restricted.id },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ export interface Config {
|
|||||||
'non-admin-user': NonAdminUser;
|
'non-admin-user': NonAdminUser;
|
||||||
posts: Post;
|
posts: Post;
|
||||||
unrestricted: Unrestricted;
|
unrestricted: Unrestricted;
|
||||||
restricted: Restricted;
|
'fully-restricted': FullyRestricted;
|
||||||
'read-only-collection': ReadOnlyCollection;
|
'read-only-collection': ReadOnlyCollection;
|
||||||
'user-restricted': UserRestricted;
|
'user-restricted': UserRestricted;
|
||||||
|
'create-not-update': CreateNotUpdate;
|
||||||
'restricted-versions': RestrictedVersion;
|
'restricted-versions': RestrictedVersion;
|
||||||
'sibling-data': SiblingDatum;
|
'sibling-data': SiblingDatum;
|
||||||
'rely-on-request-headers': RelyOnRequestHeader;
|
'rely-on-request-headers': RelyOnRequestHeader;
|
||||||
@@ -97,6 +98,7 @@ export interface Unrestricted {
|
|||||||
id: string;
|
id: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
userRestrictedDocs?: (string | UserRestricted)[] | null;
|
userRestrictedDocs?: (string | UserRestricted)[] | null;
|
||||||
|
createNotUpdateDocs?: (string | CreateNotUpdate)[] | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -112,9 +114,19 @@ export interface UserRestricted {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "restricted".
|
* via the `definition` "create-not-update".
|
||||||
*/
|
*/
|
||||||
export interface Restricted {
|
export interface CreateNotUpdate {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "fully-restricted".
|
||||||
|
*/
|
||||||
|
export interface FullyRestricted {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export const readOnlySlug = 'read-only-collection'
|
|||||||
export const readOnlyGlobalSlug = 'read-only-global'
|
export const readOnlyGlobalSlug = 'read-only-global'
|
||||||
|
|
||||||
export const userRestrictedSlug = 'user-restricted'
|
export const userRestrictedSlug = 'user-restricted'
|
||||||
export const restrictedSlug = 'restricted'
|
export const fullyRestrictedSlug = 'fully-restricted'
|
||||||
|
export const createNotUpdateSlug = 'create-not-update'
|
||||||
export const restrictedVersionsSlug = 'restricted-versions'
|
export const restrictedVersionsSlug = 'restricted-versions'
|
||||||
export const siblingDataSlug = 'sibling-data'
|
export const siblingDataSlug = 'sibling-data'
|
||||||
export const relyOnRequestHeadersSlug = 'rely-on-request-headers'
|
export const relyOnRequestHeadersSlug = 'rely-on-request-headers'
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@payload-config": [
|
"@payload-config": [
|
||||||
"./test/_community/config.ts"
|
"./test/access-control/config.ts"
|
||||||
],
|
],
|
||||||
"@payloadcms/live-preview": [
|
"@payloadcms/live-preview": [
|
||||||
"./packages/live-preview/src"
|
"./packages/live-preview/src"
|
||||||
|
|||||||
Reference in New Issue
Block a user