fix(ui): updates auth fields UI to reflect access control (#12745)
### What? Reflects any access control restrictions applied to Auth fields in the UI. I.e. if `email` has `update: () => false` the field should be displayed as read-only. ### Why? Currently any access control that is applied to auth fields is functional but is not matched within the UI. For example: - `password` that does not have read access will not return data, but the field will still be shown when it should be hidden - `email` that does not have update access, updating the field and saving the doc will **not** update the data, but it should be displayed as read-only so nothing can be filled out and the updating restriction is made clear ### How? Passes field permissions through to the Auth fields UI and adds docs with instructions on how to override auth field access. #### Testing Use `access-control` test suite and `auth` collection. Tests added to `access-control` e2e. Fixes #11569
This commit is contained in:
@@ -201,3 +201,43 @@ API Keys can be enabled on auth collections. These are particularly useful when
|
|||||||
### Custom Strategies
|
### Custom Strategies
|
||||||
|
|
||||||
There are cases where these may not be enough for your application. Payload is extendable by design so you can wire up your own strategy when you need to. [More details](./custom-strategies).
|
There are cases where these may not be enough for your application. Payload is extendable by design so you can wire up your own strategy when you need to. [More details](./custom-strategies).
|
||||||
|
|
||||||
|
### Access Control
|
||||||
|
|
||||||
|
Default auth fields including `email`, `username`, and `password` can be overridden by defining a custom field with the same name in your collection config. This allows you to customize the field — including access control — while preserving the underlying auth functionality. For example, you might want to restrict the `email` field from being updated once it is created, or only allow it to be read by certain user roles. You can achieve this by redefining the field and setting access rules accordingly.
|
||||||
|
|
||||||
|
Here's an example of how to restrict access to default auth fields:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
export const Auth: CollectionConfig = {
|
||||||
|
slug: 'users',
|
||||||
|
auth: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'email', // or 'username'
|
||||||
|
type: 'text',
|
||||||
|
access: {
|
||||||
|
create: () => true,
|
||||||
|
read: () => false,
|
||||||
|
update: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'password', // this will be applied to all password-related fields including new password, confirm password.
|
||||||
|
type: 'text',
|
||||||
|
hidden: true, // needed only for the password field to prevent duplication in the Admin panel
|
||||||
|
access: {
|
||||||
|
update: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:**
|
||||||
|
|
||||||
|
- Access functions will apply across the application — I.e. if `read` access is disabled on `email`, it will not appear in the Admin panel UI or API.
|
||||||
|
- Restricting `read` on the `email` or `username` disables the **Unlock** action in the Admin panel as this function requires access to a user-identifying field.
|
||||||
|
- When overriding the `password` field, you may need to include `hidden: true` to prevent duplicate fields being displayed in the Admin panel.
|
||||||
|
|||||||
9
packages/ui/src/elements/EmailAndUsername/index.scss
Normal file
9
packages/ui/src/elements/EmailAndUsername/index.scss
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@import '../../scss/styles.scss';
|
||||||
|
|
||||||
|
@layer payload-default {
|
||||||
|
.login-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--base);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,14 @@
|
|||||||
import type { TFunction } from '@payloadcms/translations'
|
import type { TFunction } from '@payloadcms/translations'
|
||||||
import type { LoginWithUsernameOptions, SanitizedFieldPermissions } from 'payload'
|
import type { LoginWithUsernameOptions, SanitizedFieldPermissions } from 'payload'
|
||||||
|
|
||||||
import { email, username } from 'payload/shared'
|
import { email, getFieldPermissions, username } from 'payload/shared'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { EmailField } from '../../fields/Email/index.js'
|
import { EmailField } from '../../fields/Email/index.js'
|
||||||
import { TextField } from '../../fields/Text/index.js'
|
import { TextField } from '../../fields/Text/index.js'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
const baseClass = 'login-fields'
|
||||||
type RenderEmailAndUsernameFieldsProps = {
|
type RenderEmailAndUsernameFieldsProps = {
|
||||||
className?: string
|
className?: string
|
||||||
loginWithUsername?: false | LoginWithUsernameOptions
|
loginWithUsername?: false | LoginWithUsernameOptions
|
||||||
@@ -23,47 +25,92 @@ type RenderEmailAndUsernameFieldsProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EmailAndUsernameFields(props: RenderEmailAndUsernameFieldsProps) {
|
export function EmailAndUsernameFields(props: RenderEmailAndUsernameFieldsProps) {
|
||||||
const { className, loginWithUsername, readOnly, t } = props
|
const {
|
||||||
|
className,
|
||||||
|
loginWithUsername,
|
||||||
|
operation: operationFromProps,
|
||||||
|
permissions,
|
||||||
|
readOnly,
|
||||||
|
t,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
function getAuthFieldPermission(fieldName: string, operation: 'read' | 'update') {
|
||||||
|
const permissionsResult = getFieldPermissions({
|
||||||
|
field: { name: fieldName, type: 'text' },
|
||||||
|
operation: operationFromProps === 'create' ? 'create' : operation,
|
||||||
|
parentName: '',
|
||||||
|
permissions,
|
||||||
|
})
|
||||||
|
return permissionsResult.operation
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEmailFieldOverride =
|
||||||
|
typeof permissions === 'object' && 'email' in permissions && permissions.email
|
||||||
|
const hasUsernameFieldOverride =
|
||||||
|
typeof permissions === 'object' && 'username' in permissions && permissions.username
|
||||||
|
|
||||||
|
const emailPermissions = hasEmailFieldOverride
|
||||||
|
? {
|
||||||
|
read: getAuthFieldPermission('email', 'read'),
|
||||||
|
update: getAuthFieldPermission('email', 'update'),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
read: true,
|
||||||
|
update: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernamePermissions = hasUsernameFieldOverride
|
||||||
|
? {
|
||||||
|
read: getAuthFieldPermission('username', 'read'),
|
||||||
|
update: getAuthFieldPermission('username', 'update'),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
read: true,
|
||||||
|
update: true,
|
||||||
|
}
|
||||||
|
|
||||||
const showEmailField =
|
const showEmailField =
|
||||||
!loginWithUsername || loginWithUsername?.requireEmail || loginWithUsername?.allowEmailLogin
|
(!loginWithUsername || loginWithUsername?.requireEmail || loginWithUsername?.allowEmailLogin) &&
|
||||||
|
emailPermissions.read
|
||||||
|
|
||||||
const showUsernameField = Boolean(loginWithUsername)
|
const showUsernameField = Boolean(loginWithUsername) && usernamePermissions.read
|
||||||
|
|
||||||
return (
|
if (showEmailField || showUsernameField) {
|
||||||
<div className={className}>
|
return (
|
||||||
{showEmailField ? (
|
<div className={[baseClass, className && className].filter(Boolean).join(' ')}>
|
||||||
<EmailField
|
{showEmailField ? (
|
||||||
field={{
|
<EmailField
|
||||||
name: 'email',
|
field={{
|
||||||
admin: {
|
name: 'email',
|
||||||
autoComplete: 'off',
|
admin: {
|
||||||
},
|
autoComplete: 'off',
|
||||||
label: t('general:email'),
|
},
|
||||||
required: !loginWithUsername || (loginWithUsername && loginWithUsername.requireEmail),
|
label: t('general:email'),
|
||||||
}}
|
required: !loginWithUsername || (loginWithUsername && loginWithUsername.requireEmail),
|
||||||
path="email"
|
}}
|
||||||
readOnly={readOnly}
|
path="email"
|
||||||
schemaPath="email"
|
readOnly={readOnly || !emailPermissions.update}
|
||||||
validate={email}
|
schemaPath="email"
|
||||||
/>
|
validate={email}
|
||||||
) : null}
|
/>
|
||||||
{showUsernameField && (
|
) : null}
|
||||||
<TextField
|
{showUsernameField && (
|
||||||
field={{
|
<TextField
|
||||||
name: 'username',
|
field={{
|
||||||
admin: {
|
name: 'username',
|
||||||
autoComplete: 'off',
|
admin: {
|
||||||
},
|
autoComplete: 'off',
|
||||||
label: t('authentication:username'),
|
},
|
||||||
required: loginWithUsername && loginWithUsername.requireUsername,
|
label: t('authentication:username'),
|
||||||
}}
|
required: loginWithUsername && loginWithUsername.requireUsername,
|
||||||
path="username"
|
}}
|
||||||
readOnly={readOnly}
|
path="username"
|
||||||
schemaPath="username"
|
readOnly={readOnly || !usernamePermissions.update}
|
||||||
validate={username}
|
schemaPath="username"
|
||||||
/>
|
validate={username}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
)
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { getFieldPermissions } from 'payload/shared'
|
||||||
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -51,9 +52,63 @@ export const Auth: React.FC<Props> = (props) => {
|
|||||||
},
|
},
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
|
|
||||||
|
let showPasswordFields = true
|
||||||
|
let showUnlock = true
|
||||||
|
const hasPasswordFieldOverride =
|
||||||
|
typeof docPermissions.fields === 'object' && 'password' in docPermissions.fields
|
||||||
|
const hasLoginFieldOverride =
|
||||||
|
typeof docPermissions.fields === 'object' &&
|
||||||
|
('username' in docPermissions.fields || 'email' in docPermissions.fields)
|
||||||
|
|
||||||
|
if (hasPasswordFieldOverride) {
|
||||||
|
const { permissions: passwordPermissions } = getFieldPermissions({
|
||||||
|
field: { name: 'password', type: 'text' },
|
||||||
|
operation,
|
||||||
|
parentName: '',
|
||||||
|
permissions: docPermissions?.fields,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (operation === 'create') {
|
||||||
|
showPasswordFields = typeof passwordPermissions === 'object' && passwordPermissions.create
|
||||||
|
} else {
|
||||||
|
showPasswordFields =
|
||||||
|
typeof passwordPermissions === 'object' &&
|
||||||
|
passwordPermissions.read &&
|
||||||
|
passwordPermissions.update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLoginFieldOverride) {
|
||||||
|
const hasEmailAndUsernameFields =
|
||||||
|
loginWithUsername && (loginWithUsername.requireEmail || loginWithUsername.allowEmailLogin)
|
||||||
|
|
||||||
|
const { operation: emailPermission } = getFieldPermissions({
|
||||||
|
field: { name: 'email', type: 'text' },
|
||||||
|
operation: 'read',
|
||||||
|
parentName: '',
|
||||||
|
permissions: docPermissions?.fields,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { operation: usernamePermission } = getFieldPermissions({
|
||||||
|
field: { name: 'username', type: 'text' },
|
||||||
|
operation: 'read',
|
||||||
|
parentName: '',
|
||||||
|
permissions: docPermissions?.fields,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasEmailAndUsernameFields) {
|
||||||
|
showUnlock = usernamePermission || emailPermission
|
||||||
|
} else if (loginWithUsername && !hasEmailAndUsernameFields) {
|
||||||
|
showUnlock = usernamePermission
|
||||||
|
} else {
|
||||||
|
showUnlock = emailPermission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const enableFields =
|
const enableFields =
|
||||||
!disableLocalStrategy ||
|
(!disableLocalStrategy ||
|
||||||
(typeof disableLocalStrategy === 'object' && disableLocalStrategy.enableFields === true)
|
(typeof disableLocalStrategy === 'object' && disableLocalStrategy.enableFields === true)) &&
|
||||||
|
(showUnlock || showPasswordFields)
|
||||||
|
|
||||||
const disabled = readOnly || isInitializing
|
const disabled = readOnly || isInitializing
|
||||||
|
|
||||||
@@ -81,8 +136,8 @@ export const Auth: React.FC<Props> = (props) => {
|
|||||||
}, [permissions, collectionSlug])
|
}, [permissions, collectionSlug])
|
||||||
|
|
||||||
const handleChangePassword = useCallback(
|
const handleChangePassword = useCallback(
|
||||||
(showPasswordFields: boolean) => {
|
(changingPassword: boolean) => {
|
||||||
if (showPasswordFields) {
|
if (changingPassword) {
|
||||||
setValidateBeforeSubmit(true)
|
setValidateBeforeSubmit(true)
|
||||||
|
|
||||||
dispatchFields({
|
dispatchFields({
|
||||||
@@ -104,7 +159,7 @@ export const Auth: React.FC<Props> = (props) => {
|
|||||||
dispatchFields({ type: 'REMOVE', path: 'confirm-password' })
|
dispatchFields({ type: 'REMOVE', path: 'confirm-password' })
|
||||||
}
|
}
|
||||||
|
|
||||||
setChangingPassword(showPasswordFields)
|
setChangingPassword(changingPassword)
|
||||||
},
|
},
|
||||||
[dispatchFields, t, setValidateBeforeSubmit],
|
[dispatchFields, t, setValidateBeforeSubmit],
|
||||||
)
|
)
|
||||||
@@ -179,21 +234,24 @@ export const Auth: React.FC<Props> = (props) => {
|
|||||||
{t('general:cancel')}
|
{t('general:cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!changingPassword && !requirePassword && !disableLocalStrategy && (
|
{!changingPassword &&
|
||||||
<Button
|
!requirePassword &&
|
||||||
buttonStyle="secondary"
|
!disableLocalStrategy &&
|
||||||
disabled={disabled}
|
showPasswordFields && (
|
||||||
id="change-password"
|
<Button
|
||||||
onClick={() => handleChangePassword(true)}
|
buttonStyle="secondary"
|
||||||
size="medium"
|
disabled={disabled}
|
||||||
>
|
id="change-password"
|
||||||
{t('authentication:changePassword')}
|
onClick={() => handleChangePassword(true)}
|
||||||
</Button>
|
size="medium"
|
||||||
)}
|
>
|
||||||
|
{t('authentication:changePassword')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{operation === 'update' && hasPermissionToUnlock && (
|
{operation === 'update' && hasPermissionToUnlock && (
|
||||||
<Button
|
<Button
|
||||||
buttonStyle="secondary"
|
buttonStyle="secondary"
|
||||||
disabled={disabled}
|
disabled={disabled || !showUnlock}
|
||||||
onClick={() => void unlock()}
|
onClick={() => void unlock()}
|
||||||
size="medium"
|
size="medium"
|
||||||
>
|
>
|
||||||
|
|||||||
53
test/access-control/collections/Auth/index.ts
Normal file
53
test/access-control/collections/Auth/index.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
import { authSlug } from '../../shared.js'
|
||||||
|
|
||||||
|
export const Auth: CollectionConfig = {
|
||||||
|
slug: authSlug,
|
||||||
|
auth: {
|
||||||
|
verify: true,
|
||||||
|
// loginWithUsername: {
|
||||||
|
// requireEmail: true,
|
||||||
|
// allowEmailLogin: true,
|
||||||
|
// },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'email',
|
||||||
|
type: 'text',
|
||||||
|
access: {
|
||||||
|
update: ({ req: { user }, data }) => {
|
||||||
|
const isUserOrSelf =
|
||||||
|
(user && 'roles' in user && user?.roles?.includes('admin')) || user?.id === data?.id
|
||||||
|
return isUserOrSelf
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// name: 'username',
|
||||||
|
// type: 'text',
|
||||||
|
// access: {
|
||||||
|
// update: () => false,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
type: 'text',
|
||||||
|
hidden: true,
|
||||||
|
access: {
|
||||||
|
update: ({ req: { user }, data }) => {
|
||||||
|
const isUserOrSelf =
|
||||||
|
(user && 'roles' in user && user?.roles?.includes('admin')) || user?.id === data?.id
|
||||||
|
return isUserOrSelf
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'roles',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: ['user'],
|
||||||
|
hasMany: true,
|
||||||
|
options: ['admin', 'user'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable no-restricted-exports */
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
@@ -9,6 +10,7 @@ import type { Config, User } from './payload-types.js'
|
|||||||
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
|
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
|
||||||
import { devUser } from '../credentials.js'
|
import { devUser } from '../credentials.js'
|
||||||
import { textToLexicalJSON } from '../lexical/collections/LexicalLocalized/textToLexicalJSON.js'
|
import { textToLexicalJSON } from '../lexical/collections/LexicalLocalized/textToLexicalJSON.js'
|
||||||
|
import { Auth } from './collections/Auth/index.js'
|
||||||
import { Disabled } from './collections/Disabled/index.js'
|
import { Disabled } from './collections/Disabled/index.js'
|
||||||
import { Hooks } from './collections/hooks/index.js'
|
import { Hooks } from './collections/hooks/index.js'
|
||||||
import { Regression1 } from './collections/Regression-1/index.js'
|
import { Regression1 } from './collections/Regression-1/index.js'
|
||||||
@@ -569,6 +571,7 @@ export default buildConfigWithDefaults(
|
|||||||
Regression1,
|
Regression1,
|
||||||
Regression2,
|
Regression2,
|
||||||
Hooks,
|
Hooks,
|
||||||
|
Auth,
|
||||||
],
|
],
|
||||||
globals: [
|
globals: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { devUser } from 'credentials.js'
|
|||||||
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
||||||
import { openNav } from 'helpers/e2e/toggleNav.js'
|
import { openNav } from 'helpers/e2e/toggleNav.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { wait } from 'payload/shared'
|
import { email, wait } from 'payload/shared'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
|
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
|
||||||
@@ -25,6 +25,7 @@ import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
|||||||
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||||
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
||||||
import {
|
import {
|
||||||
|
authSlug,
|
||||||
createNotUpdateCollectionSlug,
|
createNotUpdateCollectionSlug,
|
||||||
disabledSlug,
|
disabledSlug,
|
||||||
docLevelAccessSlug,
|
docLevelAccessSlug,
|
||||||
@@ -53,7 +54,7 @@ const dirname = path.dirname(filename)
|
|||||||
* Repeat all above for globals
|
* Repeat all above for globals
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { beforeAll, describe } = test
|
const { beforeAll, beforeEach, describe } = test
|
||||||
let payload: PayloadTestSDK<Config>
|
let payload: PayloadTestSDK<Config>
|
||||||
describe('Access Control', () => {
|
describe('Access Control', () => {
|
||||||
let page: Page
|
let page: Page
|
||||||
@@ -71,6 +72,7 @@ describe('Access Control', () => {
|
|||||||
let serverURL: string
|
let serverURL: string
|
||||||
let context: BrowserContext
|
let context: BrowserContext
|
||||||
let logoutURL: string
|
let logoutURL: string
|
||||||
|
let authFields: AdminUrlUtil
|
||||||
|
|
||||||
beforeAll(async ({ browser }, testInfo) => {
|
beforeAll(async ({ browser }, testInfo) => {
|
||||||
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
||||||
@@ -87,6 +89,7 @@ describe('Access Control', () => {
|
|||||||
userRestrictedCollectionURL = new AdminUrlUtil(serverURL, userRestrictedCollectionSlug)
|
userRestrictedCollectionURL = new AdminUrlUtil(serverURL, userRestrictedCollectionSlug)
|
||||||
userRestrictedGlobalURL = new AdminUrlUtil(serverURL, userRestrictedGlobalSlug)
|
userRestrictedGlobalURL = new AdminUrlUtil(serverURL, userRestrictedGlobalSlug)
|
||||||
disabledFields = new AdminUrlUtil(serverURL, disabledSlug)
|
disabledFields = new AdminUrlUtil(serverURL, disabledSlug)
|
||||||
|
authFields = new AdminUrlUtil(serverURL, authSlug)
|
||||||
|
|
||||||
context = await browser.newContext()
|
context = await browser.newContext()
|
||||||
page = await context.newPage()
|
page = await context.newPage()
|
||||||
@@ -228,7 +231,7 @@ describe('Access Control', () => {
|
|||||||
/**
|
/**
|
||||||
* This reproduces a bug where certain fields were incorrectly marked as read-only
|
* This reproduces a bug where certain fields were incorrectly marked as read-only
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line playwright/expect-expect
|
|
||||||
test('ensure complex collection config fields show up in correct read-only state', async () => {
|
test('ensure complex collection config fields show up in correct read-only state', async () => {
|
||||||
const regression1URL = new AdminUrlUtil(serverURL, 'regression1')
|
const regression1URL = new AdminUrlUtil(serverURL, 'regression1')
|
||||||
await page.goto(regression1URL.list)
|
await page.goto(regression1URL.list)
|
||||||
@@ -272,7 +275,7 @@ describe('Access Control', () => {
|
|||||||
/**
|
/**
|
||||||
* This reproduces a bug where certain fields were incorrectly marked as read-only
|
* This reproduces a bug where certain fields were incorrectly marked as read-only
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line playwright/expect-expect
|
|
||||||
test('ensure complex collection config fields show up in correct read-only state 2', async () => {
|
test('ensure complex collection config fields show up in correct read-only state 2', async () => {
|
||||||
const regression2URL = new AdminUrlUtil(serverURL, 'regression2')
|
const regression2URL = new AdminUrlUtil(serverURL, 'regression2')
|
||||||
await page.goto(regression2URL.list)
|
await page.goto(regression2URL.list)
|
||||||
@@ -733,6 +736,33 @@ describe('Access Control', () => {
|
|||||||
await expect(page.locator('#field-array__0__text')).toBeDisabled()
|
await expect(page.locator('#field-array__0__text')).toBeDisabled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('restricting update access to auth fields', () => {
|
||||||
|
let existingDoc: ReadOnlyCollection
|
||||||
|
beforeAll(async () => {
|
||||||
|
existingDoc = await payload.create({
|
||||||
|
collection: authSlug,
|
||||||
|
data: {
|
||||||
|
email: 'test@payloadcms.com',
|
||||||
|
password: 'test',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
test('should show email as readonly when user does not have update permission', async () => {
|
||||||
|
await page.goto(authFields.edit(existingDoc.id))
|
||||||
|
const emailField = page.locator('#field-email')
|
||||||
|
await expect(emailField).toBeVisible()
|
||||||
|
await expect(emailField).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should hide Change Password button when user does not have update permission', async () => {
|
||||||
|
await page.goto(authFields.edit(existingDoc.id))
|
||||||
|
const passwordField = page.locator('#field-password')
|
||||||
|
await expect(passwordField).toBeHidden()
|
||||||
|
const changePasswordButton = page.locator('#change-password')
|
||||||
|
await expect(changePasswordButton).toBeHidden()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function createDoc(data: any): Promise<Record<string, unknown> & TypeWithID> {
|
async function createDoc(data: any): Promise<Record<string, unknown> & TypeWithID> {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface Config {
|
|||||||
auth: {
|
auth: {
|
||||||
users: UserAuthOperations;
|
users: UserAuthOperations;
|
||||||
'public-users': PublicUserAuthOperations;
|
'public-users': PublicUserAuthOperations;
|
||||||
|
'auth-collection': AuthCollectionAuthOperations;
|
||||||
};
|
};
|
||||||
blocks: {};
|
blocks: {};
|
||||||
collections: {
|
collections: {
|
||||||
@@ -91,6 +92,7 @@ export interface Config {
|
|||||||
regression1: Regression1;
|
regression1: Regression1;
|
||||||
regression2: Regression2;
|
regression2: Regression2;
|
||||||
hooks: Hook;
|
hooks: Hook;
|
||||||
|
'auth-collection': AuthCollection;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
'payload-preferences': PayloadPreference;
|
'payload-preferences': PayloadPreference;
|
||||||
'payload-migrations': PayloadMigration;
|
'payload-migrations': PayloadMigration;
|
||||||
@@ -120,6 +122,7 @@ export interface Config {
|
|||||||
regression1: Regression1Select<false> | Regression1Select<true>;
|
regression1: Regression1Select<false> | Regression1Select<true>;
|
||||||
regression2: Regression2Select<false> | Regression2Select<true>;
|
regression2: Regression2Select<false> | Regression2Select<true>;
|
||||||
hooks: HooksSelect<false> | HooksSelect<true>;
|
hooks: HooksSelect<false> | HooksSelect<true>;
|
||||||
|
'auth-collection': AuthCollectionSelect<false> | AuthCollectionSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
@@ -148,6 +151,9 @@ export interface Config {
|
|||||||
})
|
})
|
||||||
| (PublicUser & {
|
| (PublicUser & {
|
||||||
collection: 'public-users';
|
collection: 'public-users';
|
||||||
|
})
|
||||||
|
| (AuthCollection & {
|
||||||
|
collection: 'auth-collection';
|
||||||
});
|
});
|
||||||
jobs: {
|
jobs: {
|
||||||
tasks: unknown;
|
tasks: unknown;
|
||||||
@@ -190,6 +196,24 @@ export interface PublicUserAuthOperations {
|
|||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export interface AuthCollectionAuthOperations {
|
||||||
|
forgotPassword: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
login: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
registerFirstUser: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
unlock: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "users".
|
* via the `definition` "users".
|
||||||
@@ -695,6 +719,26 @@ export interface Hook {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "auth-collection".
|
||||||
|
*/
|
||||||
|
export interface AuthCollection {
|
||||||
|
id: string;
|
||||||
|
password?: string | null;
|
||||||
|
roles?: ('admin' | 'user')[] | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
email: string;
|
||||||
|
resetPasswordToken?: string | null;
|
||||||
|
resetPasswordExpiration?: string | null;
|
||||||
|
salt?: string | null;
|
||||||
|
hash?: string | null;
|
||||||
|
_verified?: boolean | null;
|
||||||
|
_verificationToken?: string | null;
|
||||||
|
loginAttempts?: number | null;
|
||||||
|
lockUntil?: string | null;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "payload-locked-documents".
|
* via the `definition` "payload-locked-documents".
|
||||||
@@ -793,6 +837,10 @@ export interface PayloadLockedDocument {
|
|||||||
| ({
|
| ({
|
||||||
relationTo: 'hooks';
|
relationTo: 'hooks';
|
||||||
value: string | Hook;
|
value: string | Hook;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'auth-collection';
|
||||||
|
value: string | AuthCollection;
|
||||||
} | null);
|
} | null);
|
||||||
globalSlug?: string | null;
|
globalSlug?: string | null;
|
||||||
user:
|
user:
|
||||||
@@ -803,6 +851,10 @@ export interface PayloadLockedDocument {
|
|||||||
| {
|
| {
|
||||||
relationTo: 'public-users';
|
relationTo: 'public-users';
|
||||||
value: string | PublicUser;
|
value: string | PublicUser;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
relationTo: 'auth-collection';
|
||||||
|
value: string | AuthCollection;
|
||||||
};
|
};
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -821,6 +873,10 @@ export interface PayloadPreference {
|
|||||||
| {
|
| {
|
||||||
relationTo: 'public-users';
|
relationTo: 'public-users';
|
||||||
value: string | PublicUser;
|
value: string | PublicUser;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
relationTo: 'auth-collection';
|
||||||
|
value: string | AuthCollection;
|
||||||
};
|
};
|
||||||
key?: string | null;
|
key?: string | null;
|
||||||
value?:
|
value?:
|
||||||
@@ -1198,6 +1254,25 @@ export interface HooksSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "auth-collection_select".
|
||||||
|
*/
|
||||||
|
export interface AuthCollectionSelect<T extends boolean = true> {
|
||||||
|
password?: T;
|
||||||
|
roles?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
email?: T;
|
||||||
|
resetPasswordToken?: T;
|
||||||
|
resetPasswordExpiration?: T;
|
||||||
|
salt?: T;
|
||||||
|
hash?: T;
|
||||||
|
_verified?: T;
|
||||||
|
_verificationToken?: T;
|
||||||
|
loginAttempts?: T;
|
||||||
|
lockUntil?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "payload-locked-documents_select".
|
* via the `definition` "payload-locked-documents_select".
|
||||||
|
|||||||
@@ -25,3 +25,5 @@ export const disabledSlug = 'disabled'
|
|||||||
export const nonAdminEmail = 'no-admin-access@payloadcms.com'
|
export const nonAdminEmail = 'no-admin-access@payloadcms.com'
|
||||||
export const publicUserEmail = 'public-user@payloadcms.com'
|
export const publicUserEmail = 'public-user@payloadcms.com'
|
||||||
export const publicUsersSlug = 'public-users'
|
export const publicUsersSlug = 'public-users'
|
||||||
|
|
||||||
|
export const authSlug = 'auth-collection'
|
||||||
|
|||||||
Reference in New Issue
Block a user