Skip to content

fix(ui): updates auth fields UI to reflect access control #12745

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/authentication/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,43 @@ API Keys can be enabled on auth collections. These are particularly useful when
### 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.
126 changes: 86 additions & 40 deletions packages/ui/src/elements/EmailAndUsername/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type { TFunction } from '@payloadcms/translations'
import type { LoginWithUsernameOptions, SanitizedFieldPermissions } from 'payload'

import { email, username } from 'payload/shared'
import { email, getFieldPermissions, username } from 'payload/shared'
import React from 'react'

import { EmailField } from '../../fields/Email/index.js'
Expand All @@ -23,47 +23,93 @@ type 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 =
!loginWithUsername || loginWithUsername?.requireEmail || loginWithUsername?.allowEmailLogin
(!loginWithUsername || loginWithUsername?.requireEmail || loginWithUsername?.allowEmailLogin) &&
emailPermissions.read

const showUsernameField = Boolean(loginWithUsername)
const showUsernameField = Boolean(loginWithUsername) && usernamePermissions.read

return (
<div className={className}>
{showEmailField ? (
<EmailField
field={{
name: 'email',
admin: {
autoComplete: 'off',
},
label: t('general:email'),
required: !loginWithUsername || (loginWithUsername && loginWithUsername.requireEmail),
}}
path="email"
readOnly={readOnly}
schemaPath="email"
validate={email}
/>
) : null}
{showUsernameField && (
<TextField
field={{
name: 'username',
admin: {
autoComplete: 'off',
},
label: t('authentication:username'),
required: loginWithUsername && loginWithUsername.requireUsername,
}}
path="username"
readOnly={readOnly}
schemaPath="username"
validate={username}
/>
)}
</div>
)
if (showEmailField || showUsernameField) {
return (
<div className={className}>
{showEmailField ? (
<EmailField
field={{
name: 'email',
admin: {
autoComplete: 'off',
},
label: t('general:email'),
required: !loginWithUsername || (loginWithUsername && loginWithUsername.requireEmail),
}}
path="email"
readOnly={readOnly || !emailPermissions.update}
schemaPath="email"
validate={email}
/>
) : null}
{showUsernameField && (
<TextField
field={{
name: 'username',
admin: {
autoComplete: 'off',
style: { marginTop: showEmailField ? 'var(--base)' : '' },
},
label: t('authentication:username'),
required: loginWithUsername && loginWithUsername.requireUsername,
}}
path="username"
readOnly={readOnly || !usernamePermissions.update}
schemaPath="username"
validate={username}
/>
)}
</div>
)
}
}
86 changes: 72 additions & 14 deletions packages/ui/src/views/Edit/Auth/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { getFieldPermissions } from 'payload/shared'
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'

Expand Down Expand Up @@ -51,9 +52,63 @@ export const Auth: React.FC<Props> = (props) => {
},
} = 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 =
!disableLocalStrategy ||
(typeof disableLocalStrategy === 'object' && disableLocalStrategy.enableFields === true)
(!disableLocalStrategy ||
(typeof disableLocalStrategy === 'object' && disableLocalStrategy.enableFields === true)) &&
(showUnlock || showPasswordFields)

const disabled = readOnly || isInitializing

Expand Down Expand Up @@ -179,21 +234,24 @@ export const Auth: React.FC<Props> = (props) => {
{t('general:cancel')}
</Button>
)}
{!changingPassword && !requirePassword && !disableLocalStrategy && (
<Button
buttonStyle="secondary"
disabled={disabled}
id="change-password"
onClick={() => handleChangePassword(true)}
size="medium"
>
{t('authentication:changePassword')}
</Button>
)}
{!changingPassword &&
!requirePassword &&
!disableLocalStrategy &&
showPasswordFields && (
<Button
buttonStyle="secondary"
disabled={disabled}
id="change-password"
onClick={() => handleChangePassword(true)}
size="medium"
>
{t('authentication:changePassword')}
</Button>
)}
{operation === 'update' && hasPermissionToUnlock && (
<Button
buttonStyle="secondary"
disabled={disabled}
disabled={disabled || !showUnlock}
onClick={() => void unlock()}
size="medium"
>
Expand Down
53 changes: 53 additions & 0 deletions test/access-control/collections/Auth/index.ts
Original file line number Diff line number Diff line change
@@ -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'],
},
],
}
3 changes: 3 additions & 0 deletions test/access-control/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable no-restricted-exports */
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
Expand All @@ -9,6 +10,7 @@ import type { Config, User } from './payload-types.js'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { textToLexicalJSON } from '../lexical/collections/LexicalLocalized/textToLexicalJSON.js'
import { Auth } from './collections/Auth/index.js'
import { Disabled } from './collections/Disabled/index.js'
import { Hooks } from './collections/hooks/index.js'
import { Regression1 } from './collections/Regression-1/index.js'
Expand Down Expand Up @@ -569,6 +571,7 @@ export default buildConfigWithDefaults(
Regression1,
Regression2,
Hooks,
Auth,
],
globals: [
{
Expand Down
Loading
Loading