diff --git a/docs/authentication/overview.mdx b/docs/authentication/overview.mdx index c32ea9074d6..10ff346ebd7 100644 --- a/docs/authentication/overview.mdx +++ b/docs/authentication/overview.mdx @@ -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. diff --git a/packages/ui/src/elements/EmailAndUsername/index.tsx b/packages/ui/src/elements/EmailAndUsername/index.tsx index 061ad6102cd..72d6cce310f 100644 --- a/packages/ui/src/elements/EmailAndUsername/index.tsx +++ b/packages/ui/src/elements/EmailAndUsername/index.tsx @@ -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' @@ -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 ( -
- {showEmailField ? ( - - ) : null} - {showUsernameField && ( - - )} -
- ) + if (showEmailField || showUsernameField) { + return ( +
+ {showEmailField ? ( + + ) : null} + {showUsernameField && ( + + )} +
+ ) + } } diff --git a/packages/ui/src/views/Edit/Auth/index.tsx b/packages/ui/src/views/Edit/Auth/index.tsx index feb88980f8f..2333064b5e4 100644 --- a/packages/ui/src/views/Edit/Auth/index.tsx +++ b/packages/ui/src/views/Edit/Auth/index.tsx @@ -1,5 +1,6 @@ 'use client' +import { getFieldPermissions } from 'payload/shared' import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' @@ -51,9 +52,63 @@ export const Auth: React.FC = (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 @@ -179,21 +234,24 @@ export const Auth: React.FC = (props) => { {t('general:cancel')} )} - {!changingPassword && !requirePassword && !disableLocalStrategy && ( - - )} + {!changingPassword && + !requirePassword && + !disableLocalStrategy && + showPasswordFields && ( + + )} {operation === 'update' && hasPermissionToUnlock && (