feat: allows loginWithUsername to not require username (#7480)

Allows username to be optional when using the new loginWithUsername
feature. This can be done by the following:

```ts
auth: {
  loginWithUsername: {
    requireUsername: false, // <-- new property, default true
    requireEmail: false, // default: false
    allowEmailLogin: true, // default false
  },
},
```
This commit is contained in:
Jarrod Flesch
2024-08-05 11:35:01 -04:00
committed by GitHub
parent cdb2072a6d
commit 1ebd54b315
67 changed files with 884 additions and 325 deletions

View File

@@ -0,0 +1,45 @@
'use client'
import type { LoginWithUsernameOptions } from 'payload'
import { EmailField, TextField, useTranslation } from '@payloadcms/ui'
import { email, username } from 'payload/shared'
import React from 'react'
type Props = {
loginWithUsername?: LoginWithUsernameOptions | false
}
export const EmailAndUsernameFields: React.FC<Props> = ({ loginWithUsername }) => {
const { t } = useTranslation()
const requireEmail = !loginWithUsername || (loginWithUsername && loginWithUsername.requireEmail)
const requireUsername = loginWithUsername && loginWithUsername.requireUsername
const showEmailField =
!loginWithUsername || loginWithUsername?.requireEmail || loginWithUsername?.allowEmailLogin
const showUsernameField = Boolean(loginWithUsername)
return (
<React.Fragment>
{showEmailField && (
<EmailField
autoComplete="email"
label={t('general:email')}
name="email"
path="email"
required={requireEmail}
validate={email}
/>
)}
{showUsernameField && (
<TextField
label={t('authentication:username')}
name="username"
path="username"
required={requireUsername}
validate={username}
/>
)}
</React.Fragment>
)
}

View File

@@ -1,5 +1,5 @@
'use client'
import type { FormState } from 'payload'
import type { FormState, LoginWithUsernameOptions } from 'payload'
import {
ConfirmPasswordField,
@@ -15,14 +15,13 @@ import {
import { getFormState } from '@payloadcms/ui/shared'
import React from 'react'
import { LoginField } from '../Login/LoginField/index.js'
import { EmailAndUsernameFields } from '../../elements/EmailAndUsername/index.js'
export const CreateFirstUserClient: React.FC<{
initialState: FormState
loginType: 'email' | 'emailOrUsername' | 'username'
requireEmail?: boolean
loginWithUsername?: LoginWithUsernameOptions | false
userSlug: string
}> = ({ initialState, loginType, requireEmail = true, userSlug }) => {
}> = ({ initialState, loginWithUsername, userSlug }) => {
const { getFieldMap } = useComponentMap()
const {
@@ -58,10 +57,7 @@ export const CreateFirstUserClient: React.FC<{
redirect={admin}
validationOperation="create"
>
{['emailOrUsername', 'username'].includes(loginType) && <LoginField type="username" />}
{['email', 'emailOrUsername'].includes(loginType) && (
<LoginField required={requireEmail} type="email" />
)}
<EmailAndUsernameFields loginWithUsername={loginWithUsername} />
<PasswordField
label={t('authentication:newPassword')}
name="password"

View File

@@ -27,12 +27,6 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
const collectionConfig = config.collections?.find((collection) => collection?.slug === userSlug)
const { auth: authOptions } = collectionConfig
const loginWithUsername = authOptions.loginWithUsername
const emailRequired = loginWithUsername && loginWithUsername.requireEmail
let loginType: LoginFieldProps['type'] = loginWithUsername ? 'username' : 'email'
if (loginWithUsername && (loginWithUsername.allowEmailLogin || loginWithUsername.requireEmail)) {
loginType = 'emailOrUsername'
}
const { formState } = await getDocumentData({
collectionConfig,
@@ -47,8 +41,7 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
<p>{req.t('authentication:beginCreateFirstUser')}</p>
<CreateFirstUserClient
initialState={formState}
loginType={loginType}
requireEmail={emailRequired}
loginWithUsername={loginWithUsername}
userSlug={userSlug}
/>
</div>

View File

@@ -4,9 +4,7 @@ import {
Button,
CheckboxField,
ConfirmPasswordField,
EmailField,
PasswordField,
TextField,
useAuth,
useConfig,
useDocumentInfo,
@@ -14,12 +12,12 @@ import {
useFormModified,
useTranslation,
} from '@payloadcms/ui'
import { email as emailValidation } from 'payload/shared'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import type { Props } from './types.js'
import { EmailAndUsernameFields } from '../../../../elements/EmailAndUsername/index.js'
import { APIKey } from './APIKey.js'
import './index.scss'
@@ -140,38 +138,7 @@ export const Auth: React.FC<Props> = (props) => {
<div className={[baseClass, className].filter(Boolean).join(' ')}>
{!disableLocalStrategy && (
<React.Fragment>
{Boolean(loginWithUsername) && (
<TextField
disabled={disabled}
label={t('authentication:username')}
name="username"
readOnly={readOnly}
required
/>
)}
{(!loginWithUsername ||
loginWithUsername?.allowEmailLogin ||
loginWithUsername?.requireEmail) && (
<EmailField
autoComplete="email"
disabled={disabled}
label={t('general:email')}
name="email"
readOnly={readOnly}
required={!loginWithUsername || loginWithUsername?.requireEmail}
validate={(value) =>
emailValidation(value, {
name: 'email',
type: 'email',
data: {},
preferences: { fields: {} },
req: { t } as any,
required: true,
siblingData: {},
})
}
/>
)}
<EmailAndUsernameFields loginWithUsername={loginWithUsername} />
{(showPasswordFields || requirePassword) && (
<div className={`${baseClass}__changing-password`}>
<PasswordField

View File

@@ -1,16 +1,16 @@
'use client'
import type { PayloadRequest } from 'payload'
import type { Validate, ValidateOptions } from 'payload'
import { EmailField, TextField, useConfig, useTranslation } from '@payloadcms/ui'
import { EmailField, TextField, useTranslation } from '@payloadcms/ui'
import { email, username } from 'payload/shared'
import React from 'react'
export type LoginFieldProps = {
required?: boolean
type: 'email' | 'emailOrUsername' | 'username'
validate?: Validate
}
export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true }) => {
const { t } = useTranslation()
const config = useConfig()
if (type === 'email') {
return (
@@ -20,17 +20,7 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
name="email"
path="email"
required={required}
validate={(value) =>
email(value, {
name: 'email',
type: 'email',
data: {},
preferences: { fields: {} },
req: { t } as PayloadRequest,
required: true,
siblingData: {},
})
}
validate={email}
/>
)
}
@@ -41,23 +31,8 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
label={t('authentication:username')}
name="username"
path="username"
required
validate={(value) =>
username(value, {
name: 'username',
type: 'text',
data: {},
preferences: { fields: {} },
req: {
payload: {
config,
},
t,
} as PayloadRequest,
required: true,
siblingData: {},
})
}
required={required}
validate={username}
/>
)
}
@@ -68,36 +43,16 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
label={t('authentication:emailOrUsername')}
name="username"
path="username"
required
validate={(value) => {
const passesUsername = username(value, {
name: 'username',
type: 'text',
data: {},
preferences: { fields: {} },
req: {
payload: {
config,
},
t,
} as PayloadRequest,
required: true,
siblingData: {},
})
const passesEmail = email(value, {
name: 'username',
type: 'email',
data: {},
preferences: { fields: {} },
req: {
payload: {
config,
},
t,
} as PayloadRequest,
required: true,
siblingData: {},
})
required={required}
validate={(value, options) => {
const passesUsername = username(
value,
options as ValidateOptions<any, { email?: string }, any, string>,
)
const passesEmail = email(
value,
options as ValidateOptions<any, { username?: string }, any, string>,
)
if (!passesEmail && !passesUsername) {
return `${t('general:email')}: ${passesEmail} ${t('general:username')}: ${passesUsername}`

View File

@@ -1,4 +1,5 @@
import type { ArrayField } from '../../fields/config/types.js'
import type { ArrayFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { FieldMap } from '../forms/FieldMap.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -12,6 +13,7 @@ export type ArrayFieldProps = {
maxRows?: ArrayField['maxRows']
minRows?: ArrayField['minRows']
name?: string
validate?: ArrayFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,4 +1,5 @@
import type { Block, BlockField } from '../../fields/config/types.js'
import type { BlockFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { FieldMap } from '../forms/FieldMap.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -12,6 +13,7 @@ export type BlocksFieldProps = {
minRows?: number
name?: string
slug?: string
validate?: BlockFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,3 +1,4 @@
import type { CheckboxFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -9,6 +10,7 @@ export type CheckboxFieldProps = {
onChange?: (val: boolean) => void
partialChecked?: boolean
path?: string
validate?: CheckboxFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,4 +1,5 @@
import type { CodeField } from '../../fields/config/types.js'
import type { CodeFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -7,6 +8,7 @@ export type CodeFieldProps = {
language?: CodeField['admin']['language']
name?: string
path?: string
validate?: CodeFieldValidation
width: string
} & FormFieldBase

View File

@@ -1,4 +1,5 @@
import type { DateField } from '../../fields/config/types.js'
import type { DateFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -7,6 +8,7 @@ export type DateFieldProps = {
name?: string
path?: string
placeholder?: DateField['admin']['placeholder'] | string
validate?: DateFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,4 +1,5 @@
import type { EmailField } from '../../fields/config/types.js'
import type { EmailFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -7,6 +8,7 @@ export type EmailFieldProps = {
name?: string
path?: string
placeholder?: EmailField['admin']['placeholder']
validate?: EmailFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,4 +1,5 @@
import type { JSONField } from '../../fields/config/types.js'
import type { JSONFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -7,6 +8,7 @@ export type JSONFieldProps = {
jsonSchema?: Record<string, unknown>
name?: string
path?: string
validate?: JSONFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,4 +1,5 @@
import type { NumberField } from '../../fields/config/types.js'
import type { NumberFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -12,6 +13,7 @@ export type NumberFieldProps = {
path?: string
placeholder?: NumberField['admin']['placeholder']
step?: number
validate?: NumberFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,3 +1,4 @@
import type { PointFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -6,6 +7,7 @@ export type PointFieldProps = {
path?: string
placeholder?: string
step?: number
validate?: PointFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,4 +1,5 @@
import type { Option } from '../../fields/config/types.js'
import type { RadioFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -8,6 +9,7 @@ export type RadioFieldProps = {
onChange?: OnChange
options?: Option[]
path?: string
validate?: RadioFieldValidation
value?: string
width?: string
} & FormFieldBase

View File

@@ -1,4 +1,5 @@
import type { RelationshipField } from '../../fields/config/types.js'
import type { RelationshipFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -9,6 +10,7 @@ export type RelationshipFieldProps = {
name: string
relationTo?: RelationshipField['relationTo']
sortOptions?: RelationshipField['admin']['sortOptions']
validate?: RelationshipFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,3 +1,4 @@
import type { RichTextFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { MappedField } from '../forms/FieldMap.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -5,6 +6,7 @@ import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../typ
export type RichTextComponentProps = {
name: string
richTextComponentMap?: Map<string, MappedField[] | React.ReactNode>
validate?: RichTextFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,4 +1,5 @@
import type { Option } from '../../fields/config/types.js'
import type { SelectFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -10,6 +11,7 @@ export type SelectFieldProps = {
onChange?: (e: string | string[]) => void
options?: Option[]
path?: string
validate?: SelectFieldValidation
value?: string
width?: string
} & FormFieldBase

View File

@@ -1,4 +1,5 @@
import type { TextField } from '../../fields/config/types.js'
import type { TextFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -13,6 +14,7 @@ export type TextFieldProps = {
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
path?: string
placeholder?: TextField['admin']['placeholder']
validate?: TextFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,4 +1,5 @@
import type { TextareaField } from '../../fields/config/types.js'
import type { TextareaFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -9,6 +10,7 @@ export type TextareaFieldProps = {
path?: string
placeholder?: TextareaField['admin']['placeholder']
rows?: number
validate?: TextareaFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,4 +1,10 @@
import type { DescriptionComponent, FormFieldBase, LabelComponent, UploadField } from 'payload'
import type {
DescriptionComponent,
FormFieldBase,
LabelComponent,
UploadField,
UploadFieldValidation,
} from 'payload'
import type { ErrorComponent } from '../forms/Error.js'
@@ -7,6 +13,7 @@ export type UploadFieldProps = {
name?: string
path?: string
relationTo?: UploadField['relationTo']
validate?: UploadFieldValidation
width?: string
} & FormFieldBase

View File

@@ -1,4 +1,4 @@
import type { ClientValidate, Field } from '../../fields/config/types.js'
import type { Field, Validate } from '../../fields/config/types.js'
import type { Where } from '../../types/index.js'
export type Data = {
@@ -25,7 +25,7 @@ export type FormField = {
passesCondition?: boolean
rows?: Row[]
valid: boolean
validate?: ClientValidate
validate?: Validate
value: unknown
}

View File

@@ -1,8 +1,8 @@
import type { Field } from '../../fields/config/types.js'
import type { EmailField } from '../../fields/config/types.js'
import { email } from '../../fields/validations.js'
export const emailField = ({ required = true }: { required?: boolean }): Field => ({
export const emailFieldConfig: EmailField = {
name: 'email',
type: 'email',
admin: {
@@ -20,7 +20,7 @@ export const emailField = ({ required = true }: { required?: boolean }): Field =
],
},
label: ({ t }) => t('general:email'),
required,
required: true,
unique: true,
validate: email,
})
}

View File

@@ -1,8 +1,8 @@
import type { Field } from '../../fields/config/types.js'
import type { TextField } from '../../fields/config/types.js'
import { username } from '../../fields/validations.js'
export const usernameField: Field = {
export const usernameFieldConfig: TextField = {
name: 'username',
type: 'text',
admin: {

View File

@@ -0,0 +1,77 @@
import type { RequiredDataFromCollectionSlug } from '../collections/config/types.js'
import type { AuthCollection, CollectionSlug, PayloadRequest } from '../index.js'
import { ValidationError } from '../errors/index.js'
type ValidateUsernameOrEmailArgs<TSlug extends CollectionSlug> = {
authOptions: AuthCollection['config']['auth']
collectionSlug: string
data: RequiredDataFromCollectionSlug<TSlug>
req: PayloadRequest
} & (
| {
operation: 'create'
originalDoc?: never
}
| {
operation: 'update'
originalDoc: RequiredDataFromCollectionSlug<TSlug>
}
)
export const ensureUsernameOrEmail = <TSlug extends CollectionSlug>({
authOptions: { disableLocalStrategy, loginWithUsername },
collectionSlug,
data,
operation,
originalDoc,
req,
}: ValidateUsernameOrEmailArgs<TSlug>) => {
// neither username or email are required
// and neither are provided
// so we need to manually validate
if (
!disableLocalStrategy &&
loginWithUsername &&
!loginWithUsername.requireEmail &&
!loginWithUsername.requireUsername
) {
let missingFields = false
if (operation === 'create' && !data.email && !data.username) {
missingFields = true
} else if (operation === 'update') {
// prevent clearing both email and username
if ('email' in data && !data.email && 'username' in data && !data.username) {
missingFields = true
}
// prevent clearing email if no username
if ('email' in data && !data.email && !originalDoc.username) {
missingFields = true
}
// prevent clearing username if no email
if ('username' in data && !data.username && !originalDoc.email) {
missingFields = true
}
}
if (missingFields) {
throw new ValidationError(
{
collection: collectionSlug,
errors: [
{
field: 'username',
message: 'Username or email is required',
},
{
field: 'email',
message: 'Username or email is required',
},
],
},
req.t,
)
}
}
return
}

View File

@@ -1,11 +1,11 @@
import type { Field } from '../fields/config/types.js'
import type { Field, TextField } from '../fields/config/types.js'
import type { IncomingAuthType } from './types.js'
import { accountLockFields } from './baseFields/accountLock.js'
import { apiKeyFields } from './baseFields/apiKey.js'
import { baseAuthFields } from './baseFields/auth.js'
import { emailField } from './baseFields/email.js'
import { usernameField } from './baseFields/username.js'
import { emailFieldConfig } from './baseFields/email.js'
import { usernameFieldConfig } from './baseFields/username.js'
import { verificationFields } from './baseFields/verification.js'
export const getBaseAuthFields = (authConfig: IncomingAuthType): Field[] => {
@@ -16,19 +16,24 @@ export const getBaseAuthFields = (authConfig: IncomingAuthType): Field[] => {
}
if (!authConfig.disableLocalStrategy) {
const emailFieldIndex = authFields.push(emailField({ required: true })) - 1
const emailField = { ...emailFieldConfig }
let usernameField: TextField | undefined
if (authConfig.loginWithUsername) {
if (
typeof authConfig.loginWithUsername === 'object' &&
authConfig.loginWithUsername.requireEmail === false
) {
authFields[emailFieldIndex] = emailField({ required: false })
usernameField = { ...usernameFieldConfig }
if (typeof authConfig.loginWithUsername === 'object') {
if (authConfig.loginWithUsername.requireEmail === false) {
emailField.required = false
}
if (authConfig.loginWithUsername.requireUsername === false) {
usernameField.required = false
}
}
authFields.push(usernameField)
}
authFields.push(emailField)
if (usernameField) authFields.push(usernameField)
authFields.push(...baseAuthFields)
if (authConfig.verify) {

View File

@@ -11,6 +11,7 @@ import { Forbidden } from '../../errors/index.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { ensureUsernameOrEmail } from '../ensureUsernameOrEmail.js'
export type Arguments<TSlug extends CollectionSlug> = {
collection: Collection
@@ -44,6 +45,14 @@ export const registerFirstUserOperation = async <TSlug extends CollectionSlug>(
try {
const shouldCommit = await initTransaction(req)
ensureUsernameOrEmail<TSlug>({
authOptions: config.auth,
collectionSlug: slug,
data,
operation: 'create',
req,
})
const doc = await payload.db.findOne({
collection: config.slug,
req,

View File

@@ -118,10 +118,18 @@ export type AuthStrategy = {
name: string
}
export type LoginWithUsernameOptions = {
allowEmailLogin?: boolean
requireEmail?: boolean
}
export type LoginWithUsernameOptions =
| {
allowEmailLogin?: false
requireEmail?: boolean
// If `allowEmailLogin` is false, `requireUsername` must be true (default: true)
requireUsername?: true
}
| {
allowEmailLogin?: true
requireEmail?: boolean
requireUsername?: boolean
}
export interface IncomingAuthType {
cookies?: {

View File

@@ -66,4 +66,5 @@ export const authDefaults: IncomingAuthType = {
export const loginWithUsernameDefaults: LoginWithUsernameOptions = {
allowEmailLogin: false,
requireEmail: false,
requireUsername: true,
}

View File

@@ -1,3 +1,4 @@
import type { LoginWithUsernameOptions } from '../../auth/types.js'
import type { Config, SanitizedConfig } from '../../config/types.js'
import type { CollectionConfig, SanitizedCollectionConfig } from './types.js'
@@ -153,14 +154,24 @@ export const sanitizeCollection = async (
sanitized.auth.strategies = []
}
sanitized.auth.loginWithUsername = sanitized.auth.loginWithUsername
? {
if (sanitized.auth.loginWithUsername) {
if (sanitized.auth.loginWithUsername === true) {
sanitized.auth.loginWithUsername = loginWithUsernameDefaults
} else {
const loginWithUsernameWithDefaults = {
...loginWithUsernameDefaults,
...(typeof sanitized.auth.loginWithUsername === 'boolean'
? {}
: sanitized.auth.loginWithUsername),
...sanitized.auth.loginWithUsername,
} as LoginWithUsernameOptions
// if allowEmailLogin is false, requireUsername must be true
if (loginWithUsernameWithDefaults.allowEmailLogin === false) {
loginWithUsernameWithDefaults.requireUsername = true
}
: false
sanitized.auth.loginWithUsername = loginWithUsernameWithDefaults
}
} else {
sanitized.auth.loginWithUsername = false
}
sanitized.fields = mergeBaseFields(sanitized.fields, getBaseAuthFields(sanitized.auth))
}

View File

@@ -11,6 +11,7 @@ import type {
RequiredDataFromCollectionSlug,
} from '../config/types.js'
import { ensureUsernameOrEmail } from '../../auth/ensureUsernameOrEmail.js'
import executeAccess from '../../auth/executeAccess.js'
import { sendVerificationEmail } from '../../auth/sendVerificationEmail.js'
import { registerLocalStrategy } from '../../auth/strategies/local/register.js'
@@ -49,6 +50,14 @@ export const createOperation = async <TSlug extends CollectionSlug>(
try {
const shouldCommit = await initTransaction(args.req)
ensureUsernameOrEmail<TSlug>({
authOptions: args.collection.config.auth,
collectionSlug: args.collection.config.slug,
data: args.data,
operation: 'create',
req: args.req,
})
// /////////////////////////////////////
// beforeOperation - Collection
// /////////////////////////////////////

View File

@@ -12,6 +12,7 @@ import type {
RequiredDataFromCollectionSlug,
} from '../config/types.js'
import { ensureUsernameOrEmail } from '../../auth/ensureUsernameOrEmail.js'
import executeAccess from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
@@ -199,6 +200,17 @@ export const updateOperation = async <TSlug extends CollectionSlug>(
req,
})
if (args.collection.config.auth) {
ensureUsernameOrEmail<TSlug>({
authOptions: args.collection.config.auth,
collectionSlug: args.collection.config.slug,
data: args.data,
operation: 'update',
originalDoc,
req: args.req,
})
}
// /////////////////////////////////////
// beforeValidate - Fields
// /////////////////////////////////////

View File

@@ -11,6 +11,7 @@ import type {
RequiredDataFromCollectionSlug,
} from '../config/types.js'
import { ensureUsernameOrEmail } from '../../auth/ensureUsernameOrEmail.js'
import executeAccess from '../../auth/executeAccess.js'
import { generatePasswordSaltHash } from '../../auth/strategies/local/generatePasswordSaltHash.js'
import { hasWhereAccessResult } from '../../auth/types.js'
@@ -143,6 +144,17 @@ export const updateByIDOperation = async <TSlug extends CollectionSlug>(
showHiddenFields: true,
})
if (args.collection.config.auth) {
ensureUsernameOrEmail<TSlug>({
authOptions: args.collection.config.auth,
collectionSlug: args.collection.config.slug,
data: args.data,
operation: 'update',
originalDoc,
req: args.req,
})
}
// /////////////////////////////////////
// Generate data for all files and sizes
// /////////////////////////////////////

View File

@@ -8,13 +8,20 @@ import { APIError } from './APIError.js'
// This gets dynamically reassigned during compilation
export let ValidationErrorName = 'ValidationError'
export type ValidationFieldError = {
// The field path, i.e. "textField", "groupField.subTextField", etc.
field: string
// The error message to display for this field
message: string
}
export class ValidationError extends APIError<{
collection?: string
errors: { field: string; message: string }[]
errors: ValidationFieldError[]
global?: string
}> {
constructor(
results: { collection?: string; errors: { field: string; message: string }[]; global?: string },
results: { collection?: string; errors: ValidationFieldError[]; global?: string },
t?: TFunction,
) {
const message = t

View File

@@ -20,3 +20,4 @@ export { NotFound } from './NotFound.js'
export { QueryError } from './QueryError.js'
export { ReservedFieldName } from './ReservedFieldName.js'
export { ValidationError, ValidationErrorName } from './ValidationError.js'
export type { ValidationFieldError } from './ValidationError.js'

View File

@@ -7,6 +7,7 @@ import type { CSSProperties } from 'react'
import monacoeditor from 'monaco-editor' // IMPORTANT - DO NOT REMOVE: This is required for pnpm's default isolated mode to work - even though the import is not used. This is due to a typescript bug: https://github.com/microsoft/TypeScript/issues/47663#issuecomment-1519138189. (tsbugisolatedmode)
import type { JSONSchema4 } from 'json-schema'
import type React from 'react'
import type { DeepPartial } from 'ts-essentials'
import type { RichTextAdapter, RichTextAdapterProvider } from '../../admin/RichText.js'
import type { ErrorComponent } from '../../admin/forms/Error.js'
@@ -176,12 +177,14 @@ export type Labels = {
}
export type BaseValidateOptions<TData, TSiblingData, TValue> = {
collectionSlug?: string
data: Partial<TData>
id?: number | string
operation?: Operation
preferences: DocumentPreferences
previousValue?: TValue
req: PayloadRequest
required?: boolean
siblingData: Partial<TSiblingData>
}
@@ -202,8 +205,6 @@ export type Validate<
options: ValidateOptions<TData, TSiblingData, TFieldConfig, TValue>,
) => Promise<string | true> | string | true
export type ClientValidate = Omit<Validate, 'req'>
export type OptionObject = {
label: LabelFunction | LabelStatic
value: string

View File

@@ -136,6 +136,7 @@ export const promise = async ({
const validationResult = await field.validate(valueToValidate, {
...field,
id,
collectionSlug: collection?.slug,
data: deepMergeWithSourceArrays(doc, data),
jsonError,
operation,

View File

@@ -31,7 +31,8 @@ import type {
import { isNumber } from '../utilities/isNumber.js'
import { isValidID } from '../utilities/isValidID.js'
export const text: Validate<string | string[], unknown, unknown, TextField> = (
export type TextFieldValidation = Validate<string, unknown, unknown, TextField>
export const text: TextFieldValidation = (
value,
{
hasMany,
@@ -83,7 +84,8 @@ export const text: Validate<string | string[], unknown, unknown, TextField> = (
return true
}
export const password: Validate<string, unknown, unknown, TextField> = (
export type PasswordFieldValidation = Validate<string, unknown, unknown, TextField>
export const password: PasswordFieldValidation = (
value,
{
maxLength: fieldMaxLength,
@@ -115,32 +117,54 @@ export const password: Validate<string, unknown, unknown, TextField> = (
return true
}
export const confirmPassword: Validate<string, unknown, unknown, TextField> = (
export type ConfirmPasswordFieldValidation = Validate<
string,
unknown,
{ password: string },
TextField
>
export const confirmPassword: ConfirmPasswordFieldValidation = (
value,
{ req: { data, t }, required },
{ req: { t }, required, siblingData },
) => {
if (required && !value) {
return t('validation:required')
}
if (
value &&
typeof data.formState === 'object' &&
'password' in data.formState &&
typeof data.formState.password === 'object' &&
'value' in data.formState.password &&
value !== data.formState.password.value
) {
if (value && value !== siblingData.password) {
return t('fields:passwordsDoNotMatch')
}
return true
}
export const email: Validate<string, unknown, unknown, EmailField> = (
export type EmailFieldValidation = Validate<string, unknown, { username?: string }, EmailField>
export const email: EmailFieldValidation = (
value,
{ req: { t }, required },
{
collectionSlug,
req: {
payload: { config },
t,
},
required,
siblingData,
},
) => {
if (collectionSlug) {
const collection = config.collections.find(({ slug }) => slug === collectionSlug)
if (
collection.auth.loginWithUsername &&
!collection.auth.loginWithUsername?.requireUsername &&
!collection.auth.loginWithUsername?.requireEmail
) {
if (!value && !siblingData?.username) {
return t('validation:required')
}
}
}
if ((value && !/\S[^\s@]*@\S+\.\S+/.test(value)) || (!value && required)) {
return t('validation:emailAddress')
}
@@ -148,18 +172,35 @@ export const email: Validate<string, unknown, unknown, EmailField> = (
return true
}
export const username: Validate<string, unknown, unknown, TextField> = (
export type UsernameFieldValidation = Validate<string, unknown, { email?: string }, TextField>
export const username: UsernameFieldValidation = (
value,
{
collectionSlug,
req: {
payload: { config },
t,
},
required,
siblingData,
},
) => {
let maxLength: number
if (collectionSlug) {
const collection = config.collections.find(({ slug }) => slug === collectionSlug)
if (
collection.auth.loginWithUsername &&
!collection.auth.loginWithUsername?.requireUsername &&
!collection.auth.loginWithUsername?.requireEmail
) {
if (!value && !siblingData?.email) {
return t('validation:required')
}
}
}
if (typeof config?.defaultMaxTextLength === 'number') maxLength = config.defaultMaxTextLength
if (value && maxLength && value.length > maxLength) {
@@ -173,7 +214,8 @@ export const username: Validate<string, unknown, unknown, TextField> = (
return true
}
export const textarea: Validate<string, unknown, unknown, TextareaField> = (
export type TextareaFieldValidation = Validate<string, unknown, unknown, TextareaField>
export const textarea: TextareaFieldValidation = (
value,
{
maxLength: fieldMaxLength,
@@ -204,10 +246,8 @@ export const textarea: Validate<string, unknown, unknown, TextareaField> = (
return true
}
export const code: Validate<string, unknown, unknown, CodeField> = (
value,
{ req: { t }, required },
) => {
export type CodeFieldValidation = Validate<string, unknown, unknown, CodeField>
export const code: CodeFieldValidation = (value, { req: { t }, required }) => {
if (required && value === undefined) {
return t('validation:required')
}
@@ -215,7 +255,13 @@ export const code: Validate<string, unknown, unknown, CodeField> = (
return true
}
export const json: Validate<string, unknown, unknown, { jsonError?: string } & JSONField> = async (
export type JSONFieldValidation = Validate<
string,
unknown,
unknown,
{ jsonError?: string } & JSONField
>
export const json: JSONFieldValidation = async (
value,
{ jsonError, jsonSchema, req: { t }, required },
) => {
@@ -281,10 +327,8 @@ export const json: Validate<string, unknown, unknown, { jsonError?: string } & J
return true
}
export const checkbox: Validate<boolean, unknown, unknown, CheckboxField> = (
value,
{ req: { t }, required },
) => {
export type CheckboxFieldValidation = Validate<boolean, unknown, unknown, CheckboxField>
export const checkbox: CheckboxFieldValidation = (value, { req: { t }, required }) => {
if ((value && typeof value !== 'boolean') || (required && typeof value !== 'boolean')) {
return t('validation:trueOrFalse')
}
@@ -292,10 +336,8 @@ export const checkbox: Validate<boolean, unknown, unknown, CheckboxField> = (
return true
}
export const date: Validate<Date, unknown, unknown, DateField> = (
value,
{ req: { t }, required },
) => {
export type DateFieldValidation = Validate<Date, unknown, unknown, DateField>
export const date: DateFieldValidation = (value, { req: { t }, required }) => {
if (value && !isNaN(Date.parse(value.toString()))) {
return true
}
@@ -311,10 +353,8 @@ export const date: Validate<Date, unknown, unknown, DateField> = (
return true
}
export const richText: Validate<object, unknown, unknown, RichTextField> = async (
value,
options,
) => {
export type RichTextFieldValidation = Validate<object, unknown, unknown, RichTextField>
export const richText: RichTextFieldValidation = async (value, options) => {
if (!options?.editor) {
throw new Error('richText field has no editor property.')
}
@@ -357,7 +397,8 @@ const validateArrayLength = (
return true
}
export const number: Validate<number | number[], unknown, unknown, NumberField> = (
export type NumberFieldValidation = Validate<number | number[], unknown, unknown, NumberField>
export const number: NumberFieldValidation = (
value,
{ hasMany, max, maxRows, min, minRows, req: { t }, required },
) => {
@@ -391,17 +432,13 @@ export const number: Validate<number | number[], unknown, unknown, NumberField>
return true
}
export const array: Validate<unknown[], unknown, unknown, ArrayField> = (
value,
{ maxRows, minRows, req: { t }, required },
) => {
export type ArrayFieldValidation = Validate<unknown[], unknown, unknown, ArrayField>
export const array: ArrayFieldValidation = (value, { maxRows, minRows, req: { t }, required }) => {
return validateArrayLength(value, { maxRows, minRows, required, t })
}
export const blocks: Validate<unknown, unknown, unknown, BlockField> = (
value,
{ maxRows, minRows, req: { t }, required },
) => {
export type BlockFieldValidation = Validate<unknown, unknown, unknown, BlockField>
export const blocks: BlockFieldValidation = (value, { maxRows, minRows, req: { t }, required }) => {
return validateArrayLength(value, { maxRows, minRows, required, t })
}
@@ -533,10 +570,8 @@ const validateFilterOptions: Validate<
return true
}
export const upload: Validate<unknown, unknown, unknown, UploadField> = (
value: string,
options,
) => {
export type UploadFieldValidation = Validate<unknown, unknown, unknown, UploadField>
export const upload: UploadFieldValidation = (value: string, options) => {
if (!value && options.required) {
return options?.req?.t('validation:required')
}
@@ -554,12 +589,13 @@ export const upload: Validate<unknown, unknown, unknown, UploadField> = (
return validateFilterOptions(value, options)
}
export const relationship: Validate<
export type RelationshipFieldValidation = Validate<
RelationshipValue,
unknown,
unknown,
RelationshipField
> = async (value, options) => {
>
export const relationship: RelationshipFieldValidation = async (value, options) => {
const {
maxRows,
minRows,
@@ -634,7 +670,8 @@ export const relationship: Validate<
return validateFilterOptions(value, options)
}
export const select: Validate<unknown, unknown, unknown, SelectField> = (
export type SelectFieldValidation = Validate<string | string[], unknown, unknown, SelectField>
export const select: SelectFieldValidation = (
value,
{ hasMany, options, req: { t }, required },
) => {
@@ -671,10 +708,8 @@ export const select: Validate<unknown, unknown, unknown, SelectField> = (
return true
}
export const radio: Validate<unknown, unknown, unknown, RadioField> = (
value,
{ options, req: { t }, required },
) => {
export type RadioFieldValidation = Validate<unknown, unknown, unknown, RadioField>
export const radio: RadioFieldValidation = (value, { options, req: { t }, required }) => {
if (value) {
const valueMatchesOption = options.some(
(option) => option === value || (typeof option !== 'string' && option.value === value),
@@ -685,10 +720,13 @@ export const radio: Validate<unknown, unknown, unknown, RadioField> = (
return required ? t('validation:required') : true
}
export const point: Validate<[number | string, number | string], unknown, unknown, PointField> = (
value = ['', ''],
{ req: { t }, required },
) => {
export type PointFieldValidation = Validate<
[number | string, number | string],
unknown,
unknown,
PointField
>
export const point: PointFieldValidation = (value = ['', ''], { req: { t }, required }) => {
const lng = parseFloat(String(value[0]))
const lat = parseFloat(String(value[1]))
if (

View File

@@ -870,7 +870,6 @@ export type {
Block,
BlockField,
CheckboxField,
ClientValidate,
CodeField,
CollapsibleField,
Condition,
@@ -928,6 +927,27 @@ export { traverseFields as afterReadTraverseFields } from './fields/hooks/afterR
export { traverseFields as beforeChangeTraverseFields } from './fields/hooks/beforeChange/traverseFields.js'
export { traverseFields as beforeValidateTraverseFields } from './fields/hooks/beforeValidate/traverseFields.js'
export { default as sortableFieldTypes } from './fields/sortableFieldTypes.js'
export type {
ArrayFieldValidation,
BlockFieldValidation,
CheckboxFieldValidation,
CodeFieldValidation,
ConfirmPasswordFieldValidation,
DateFieldValidation,
EmailFieldValidation,
JSONFieldValidation,
NumberFieldValidation,
PasswordFieldValidation,
PointFieldValidation,
RadioFieldValidation,
RelationshipFieldValidation,
RichTextFieldValidation,
SelectFieldValidation,
TextFieldValidation,
TextareaFieldValidation,
UploadFieldValidation,
UsernameFieldValidation,
} from './fields/validations.js'
export type { ClientGlobalConfig } from './globals/config/client.js'
export { createClientGlobalConfig } from './globals/config/client.js'
export type {
@@ -999,7 +1019,8 @@ export { deleteCollectionVersions } from './versions/deleteCollectionVersions.js
export { enforceMaxVersions } from './versions/enforceMaxVersions.js'
export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js'
export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js'
export { saveVersion } from './versions/saveVersion.js'
export { getDependencies }
export { saveVersion } from './versions/saveVersion.js'
export type { TypeWithVersion } from './versions/types.js'
export { deepMergeSimple } from '@payloadcms/translations/utilities'

View File

@@ -619,24 +619,6 @@ const generateAuthFieldTypes = ({
loginWithUsername: Auth['loginWithUsername']
type: 'forgotOrUnlock' | 'login' | 'register'
}): JSONSchema4 => {
const emailAuthFields = {
additionalProperties: false,
properties: { email: fieldType },
required: ['email'],
}
const usernameAuthFields = {
additionalProperties: false,
properties: { username: fieldType },
required: ['username'],
}
if (['login', 'register'].includes(type)) {
emailAuthFields.properties['password'] = fieldType
emailAuthFields.required.push('password')
usernameAuthFields.properties['password'] = fieldType
usernameAuthFields.required.push('password')
}
if (loginWithUsername) {
switch (type) {
case 'login': {
@@ -644,38 +626,57 @@ const generateAuthFieldTypes = ({
// allow username or email and require password for login
return {
additionalProperties: false,
oneOf: [emailAuthFields, usernameAuthFields],
oneOf: [
{
additionalProperties: false,
properties: { email: fieldType, password: fieldType },
required: ['email', 'password'],
},
{
additionalProperties: false,
properties: { password: fieldType, username: fieldType },
required: ['username', 'password'],
},
],
}
} else {
// allow only username and password for login
return usernameAuthFields
return {
additionalProperties: false,
properties: {
password: fieldType,
username: fieldType,
},
required: ['username', 'password'],
}
}
}
case 'register': {
const requiredFields: ('email' | 'password' | 'username')[] = ['password']
const properties: {
email?: JSONSchema4['properties']
password?: JSONSchema4['properties']
username?: JSONSchema4['properties']
} = {
password: fieldType,
username: fieldType,
}
if (loginWithUsername.requireEmail) {
// require username, email and password for registration
return {
additionalProperties: false,
properties: {
...usernameAuthFields.properties,
...emailAuthFields.properties,
},
required: [...usernameAuthFields.required, ...emailAuthFields.required],
}
} else if (loginWithUsername.allowEmailLogin) {
// allow both but only require username for registration
return {
additionalProperties: false,
properties: {
...usernameAuthFields.properties,
...emailAuthFields.properties,
},
required: usernameAuthFields.required,
}
} else {
// require only username and password for registration
return usernameAuthFields
requiredFields.push('email')
}
if (loginWithUsername.requireUsername) {
requiredFields.push('username')
}
if (loginWithUsername.requireEmail || loginWithUsername.allowEmailLogin) {
properties.email = fieldType
}
return {
additionalProperties: false,
properties,
required: requiredFields,
}
}
@@ -684,18 +685,37 @@ const generateAuthFieldTypes = ({
// allow email or username for unlock/forgot-password
return {
additionalProperties: false,
oneOf: [emailAuthFields, usernameAuthFields],
oneOf: [
{
additionalProperties: false,
properties: { email: fieldType },
required: ['email'],
},
{
additionalProperties: false,
properties: { username: fieldType },
required: ['username'],
},
],
}
} else {
// allow only username for unlock/forgot-password
return usernameAuthFields
return {
additionalProperties: false,
properties: { username: fieldType },
required: ['username'],
}
}
}
}
}
// default email (and password for login/register)
return emailAuthFields
return {
additionalProperties: false,
properties: { email: fieldType, password: fieldType },
required: ['email', 'password'],
}
}
export function authCollectionToOperationsJSONSchema(

View File

@@ -13,7 +13,7 @@ export const blockValidationHOC = (
const blockFieldData = node.fields ?? ({} as BlockFields)
const {
options: { id, operation, preferences, req },
options: { id, collectionSlug, operation, preferences, req },
} = validation
// find block
@@ -30,6 +30,7 @@ export const blockValidationHOC = (
const result = await buildStateFromSchema({
id,
collectionSlug,
data: blockFieldData,
fieldSchema: block.fields,
operation: operation === 'create' || operation === 'update' ? operation : 'update',

View File

@@ -13,7 +13,7 @@ export const linkValidation = (
return async ({
node,
validation: {
options: { id, operation, preferences, req },
options: { id, collectionSlug, operation, preferences, req },
},
}) => {
/**
@@ -22,6 +22,7 @@ export const linkValidation = (
const result = await buildStateFromSchema({
id,
collectionSlug,
data: node.fields,
fieldSchema: sanitizedFieldsWithoutText, // Sanitized in feature.server.ts
operation: operation === 'create' || operation === 'update' ? operation : 'update',

View File

@@ -44,6 +44,7 @@ export const uploadValidation = (
const result = await buildStateFromSchema({
id,
collectionSlug: node.relationTo,
data: node?.fields ?? {},
fieldSchema: collection.fields,
operation: operation === 'create' || operation === 'update' ? operation : 'update',

View File

@@ -1,6 +1,6 @@
'use client'
import type { ClientValidate, FormFieldBase } from 'payload'
import type { FormFieldBase, PayloadRequest, RichTextFieldValidation } from 'payload'
import type { BaseEditor, BaseOperation } from 'slate'
import type { HistoryEditor } from 'slate-history'
import type { ReactEditor } from 'slate-react'
@@ -56,6 +56,7 @@ const RichTextField: React.FC<
placeholder?: string
plugins: RichTextPlugin[]
richTextComponentMap: Map<string, React.ReactNode>
validate?: RichTextFieldValidation
width?: string
} & FormFieldBase
> = (props) => {
@@ -88,14 +89,14 @@ const RichTextField: React.FC<
const drawerDepth = useEditDepth()
const drawerIsOpen = drawerDepth > 1
const memoizedValidate: ClientValidate = useCallback(
const memoizedValidate = useCallback(
(value, validationOptions) => {
if (typeof validate === 'function') {
return validate(value, {
...validationOptions,
req: {
t: i18n.t,
},
} as PayloadRequest,
required,
})
}

View File

@@ -1,5 +1,5 @@
'use client'
import type { CheckboxFieldProps, ClientValidate } from 'payload'
import type { CheckboxFieldProps, CheckboxFieldValidation } from 'payload'
import React, { useCallback } from 'react'
@@ -51,7 +51,7 @@ const CheckboxFieldComponent: React.FC<CheckboxFieldProps> = (props) => {
const editDepth = useEditDepth()
const memoizedValidate: ClientValidate = useCallback(
const memoizedValidate: CheckboxFieldValidation = useCallback(
(value, options) => {
if (typeof validate === 'function') {
return validate(value, { ...options, required })

View File

@@ -1,9 +1,8 @@
'use client'
import type { FormField } from 'payload'
import React, { useCallback } from 'react'
import { confirmPassword } from 'payload/shared'
import React from 'react'
import { useFormFields } from '../../forms/Form/context.js'
import { useField } from '../../forms/useField/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { FieldError } from '../FieldError/index.js'
@@ -18,28 +17,18 @@ export type ConfirmPasswordFieldProps = {
export const ConfirmPasswordField: React.FC<ConfirmPasswordFieldProps> = (props) => {
const { disabled, path = 'confirm-password' } = props
const password = useFormFields<FormField>(([fields]) => fields?.password)
const { t } = useTranslation()
const validate = useCallback(
(value: string) => {
if (!value) {
return t('validation:required')
}
if (value === password?.value) {
return true
}
return t('fields:passwordsDoNotMatch')
},
[password, t],
)
const { setValue, showError, value } = useField({
path,
validate,
validate: (value, options) => {
return confirmPassword(value, {
name: 'confirm-password',
type: 'text',
required: true,
...options,
})
},
})
return (

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientValidate, DateField, DateFieldProps } from 'payload'
import type { DateFieldProps, DateFieldValidation } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React, { useCallback } from 'react'
@@ -43,7 +43,7 @@ const DateTimeFieldComponent: React.FC<DateFieldProps> = (props) => {
const { i18n } = useTranslation()
const memoizedValidate: ClientValidate = useCallback(
const memoizedValidate: DateFieldValidation = useCallback(
(value, options) => {
if (typeof validate === 'function') {
return validate(value, { ...options, required })

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientValidate, EmailFieldProps } from 'payload'
import type { EmailFieldProps, EmailFieldValidation } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React, { useCallback } from 'react'
@@ -39,7 +39,7 @@ const EmailFieldComponent: React.FC<EmailFieldProps> = (props) => {
const { i18n } = useTranslation()
const memoizedValidate: ClientValidate = useCallback(
const memoizedValidate: EmailFieldValidation = useCallback(
(value, options) => {
if (typeof validate === 'function') {
return validate(value, { ...options, required })

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientValidate, JSONFieldProps } from 'payload'
import type { JSONFieldProps } from 'payload'
import React, { useCallback, useEffect, useState } from 'react'
@@ -43,7 +43,7 @@ const JSONFieldComponent: React.FC<JSONFieldProps> = (props) => {
const [jsonError, setJsonError] = useState<string>()
const [hasLoadedValue, setHasLoadedValue] = useState(false)
const memoizedValidate: ClientValidate = useCallback(
const memoizedValidate = useCallback(
(value, options) => {
if (typeof validate === 'function')
return validate(value, { ...options, jsonError, required })

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientValidate, Description, PayloadRequest, Validate } from 'payload'
import type { Description, PasswordFieldValidation, PayloadRequest, Validate } from 'payload'
import { useConfig, useLocale, useTranslation } from '@payloadcms/ui'
import { password } from 'payload/shared'
@@ -31,7 +31,7 @@ export type PasswordFieldProps = {
required?: boolean
rtl?: boolean
style?: React.CSSProperties
validate?: Validate
validate?: PasswordFieldValidation
width?: string
}
@@ -63,7 +63,7 @@ const PasswordFieldComponent: React.FC<PasswordFieldProps> = (props) => {
const locale = useLocale()
const config = useConfig()
const memoizedValidate: ClientValidate = useCallback(
const memoizedValidate: PasswordFieldValidation = useCallback(
(value, options) => {
if (typeof validate === 'function') {
return validate(value, { ...options, required })

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientValidate, PointFieldProps } from 'payload'
import type { PointFieldProps, PointFieldValidation } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React, { useCallback } from 'react'
@@ -42,7 +42,7 @@ export const PointFieldComponent: React.FC<PointFieldProps> = (props) => {
const { i18n, t } = useTranslation()
const memoizedValidate: ClientValidate = useCallback(
const memoizedValidate: PointFieldValidation = useCallback(
(value, options) => {
if (typeof validate === 'function') {
return validate(value, { ...options, required })

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientValidate, Option, OptionObject, SelectFieldProps } from 'payload'
import type { Option, OptionObject, SelectFieldProps } from 'payload'
import React, { useCallback } from 'react'
@@ -51,7 +51,7 @@ const SelectFieldComponent: React.FC<SelectFieldProps> = (props) => {
const options = React.useMemo(() => formatOptions(optionsFromProps), [optionsFromProps])
const memoizedValidate: ClientValidate = useCallback(
const memoizedValidate = useCallback(
(value, validationOptions) => {
if (typeof validate === 'function')
return validate(value, { ...validationOptions, hasMany, options, required })

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientValidate, TextFieldProps } from 'payload'
import type { TextFieldProps } from 'payload'
import React, { useCallback, useEffect, useState } from 'react'
@@ -51,7 +51,7 @@ const TextFieldComponent: React.FC<TextFieldProps> = (props) => {
const { localization: localizationConfig } = useConfig()
const memoizedValidate: ClientValidate = useCallback(
const memoizedValidate = useCallback(
(value, options) => {
if (typeof validate === 'function')
return validate(value, { ...options, maxLength, minLength, required })

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientValidate, TextareaFieldProps } from 'payload'
import type { TextareaFieldProps, TextareaFieldValidation } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React, { useCallback } from 'react'
@@ -56,7 +56,7 @@ const TextareaFieldComponent: React.FC<TextareaFieldProps> = (props) => {
localizationConfig: localization || undefined,
})
const memoizedValidate: ClientValidate = useCallback(
const memoizedValidate: TextareaFieldValidation = useCallback(
(value, options) => {
if (typeof validate === 'function')
return validate(value, { ...options, maxLength, minLength, required })

View File

@@ -1,5 +1,5 @@
'use client'
import type { FormState } from 'payload'
import type { FormState, PayloadRequest } from 'payload'
import { dequal } from 'dequal/lite' // lite: no need for Map and Set support
import { useRouter } from 'next/navigation.js'
@@ -126,13 +126,20 @@ export const Form: React.FC<FormProps> = (props) => {
}
validationResult = await field.validate(valueToValidate, {
...field,
id,
config,
collectionSlug,
data,
operation,
preferences: {} as any,
req: {
payload: {
config,
},
t,
user,
} as PayloadRequest,
siblingData: contextRef.current.getSiblingData(path),
t,
user,
})
if (typeof validationResult === 'string') {
@@ -160,7 +167,7 @@ export const Form: React.FC<FormProps> = (props) => {
}
return isValid
}, [id, user, operation, t, dispatchFields, config])
}, [collectionSlug, config, dispatchFields, id, operation, t, user])
const submit = useCallback(
async (options: SubmitOptions = {}, e): Promise<void> => {
@@ -621,11 +628,11 @@ export const Form: React.FC<FormProps> = (props) => {
return (
<form
action={action}
action={typeof action === 'function' ? void action : action}
className={classes}
method={method}
noValidate
onSubmit={(e) => contextRef.current.submit({}, e)}
onSubmit={(e) => void contextRef.current.submit({}, e)}
ref={formRef}
>
<FormContext.Provider value={contextRef.current}>

View File

@@ -23,6 +23,7 @@ export type AddFieldStatePromiseArgs = {
* if all parents are localized, then the field is localized
*/
anyParentLocalized?: boolean
collectionSlug?: string
data: Data
field: Field
fieldIndex: number
@@ -47,8 +48,8 @@ export type AddFieldStatePromiseArgs = {
operation: 'create' | 'update'
passesCondition: boolean
path: string
preferences: DocumentPreferences
preferences: DocumentPreferences
/**
* Req is used for validation and defaultValue calculation. If you don't need validation,
* just create your own req and pass in the locale and the user
@@ -74,6 +75,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id,
addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized = false,
collectionSlug,
data,
field,
fieldIndex,
@@ -120,6 +122,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
validationResult = await validate(data?.[field.name], {
...field,
id,
collectionSlug,
data: fullData,
operation,
req,

View File

@@ -10,6 +10,7 @@ import { calculateDefaultValues } from './calculateDefaultValues/index.js'
import { iterateFields } from './iterateFields.js'
type Args = {
collectionSlug?: string
data?: Data
fieldSchema: FieldSchema[] | undefined
id?: number | string
@@ -32,7 +33,7 @@ export type BuildFormStateArgs = {
}
export const buildStateFromSchema = async (args: Args): Promise<FormState> => {
const { id, data: fullData = {}, fieldSchema, operation, preferences, req } = args
const { id, collectionSlug, data: fullData = {}, fieldSchema, operation, preferences, req } = args
if (fieldSchema) {
const state: FormState = {}
@@ -49,6 +50,7 @@ export const buildStateFromSchema = async (args: Args): Promise<FormState> => {
await iterateFields({
id,
addErrorPathToParent: null,
collectionSlug,
data: dataWithDefaultValues,
fields: fieldSchema,
fullData,

View File

@@ -18,6 +18,7 @@ type Args = {
* if any parents is localized, then the field is localized. @default false
*/
anyParentLocalized?: boolean
collectionSlug?: string
data: Data
fields: FieldSchema[]
filter?: (args: AddFieldStatePromiseArgs) => boolean
@@ -64,6 +65,7 @@ export const iterateFields = async ({
id,
addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized = false,
collectionSlug,
data,
fields,
filter,
@@ -98,6 +100,7 @@ export const iterateFields = async ({
id,
addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized,
collectionSlug,
data,
field,
fieldIndex,

View File

@@ -1,4 +1,6 @@
'use client'
import type { PayloadRequest } from 'payload'
import { useCallback, useMemo, useRef } from 'react'
import type { UPDATE } from '../Form/types.js'
@@ -39,7 +41,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
const processing = useFormProcessing()
const initializing = useFormInitializing()
const { user } = useAuth()
const { id } = useDocumentInfo()
const { id, collectionSlug } = useDocumentInfo()
const operation = useOperation()
const { dispatchField, field } = useFormFields(([fields, dispatch]) => ({
@@ -161,12 +163,18 @@ export const useField = <T,>(options: Options): FieldType<T> => {
typeof validate === 'function'
? await validate(valueToValidate, {
id,
config,
collectionSlug,
data: getData(),
operation,
preferences: {} as any,
req: {
payload: {
config,
},
t,
user,
} as PayloadRequest,
siblingData: getSiblingData(path),
t,
user,
})
: true
@@ -190,7 +198,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
path,
rows: field?.rows,
valid,
// validate,
validate,
value,
}
@@ -220,7 +228,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
user,
validate,
field?.rows,
field?.valid,
collectionSlug,
],
)

View File

@@ -1,4 +1,4 @@
import type { ClientValidate, FieldPermissions, FilterOptionsResult, Row } from 'payload'
import type { FieldPermissions, FilterOptionsResult, Row, Validate } from 'payload'
export type Options = {
disableFormData?: boolean
@@ -7,7 +7,7 @@ export type Options = {
* If you do not provide a `path` or a `name`, this hook will look for one using the `useFieldPath` hook.
**/
path?: string
validate?: ClientValidate
validate?: Validate
}
export type FieldType<T> = {

View File

@@ -176,6 +176,7 @@ export const buildFormState = async ({ req }: { req: PayloadRequest }): Promise<
const result = await buildStateFromSchema({
id,
collectionSlug,
data,
fieldSchema,
operation,
@@ -188,14 +189,6 @@ export const buildFormState = async ({ req }: { req: PayloadRequest }): Promise<
if (req.payload.collections[collectionSlug]?.config?.upload && formState.file) {
result.file = formState.file
}
if (
req.payload.collections[collectionSlug]?.config?.auth &&
!req.payload.collections[collectionSlug].config.auth.disableLocalStrategy
) {
if (formState.username) result.username = formState.username
if (formState.email) result.email = formState.email
}
}
return result

View File

@@ -15,7 +15,9 @@ export const AuthDebug: React.FC<UIField> = () => {
setState(userRes)
}
void fetchUser()
if (user?.id) {
void fetchUser()
}
}, [user])
return (

View File

@@ -56,7 +56,6 @@ const createFirstUser = async ({
// forget to fill out confirm password
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill(devUser.password)
await wait(500)
await page.locator('.form-submit > button').click()
await expect(page.locator('.field-type.confirm-password .field-error')).toHaveText(
'This field is required.',
@@ -66,7 +65,6 @@ const createFirstUser = async ({
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill('12')
await page.locator('#field-confirm-password').fill('12')
await wait(500)
await page.locator('.form-submit > button').click()
await expect(page.locator('.field-type.password .field-error')).toHaveText(
'This value must be longer than the minimum length of 3 characters.',
@@ -76,7 +74,6 @@ const createFirstUser = async ({
await page.locator('#field-password').fill(devUser.password)
await page.locator('#field-confirm-password').fill(devUser.password)
await page.locator('#field-custom').fill('Hello, world!')
await wait(500)
await page.locator('.form-submit > button').click()
await expect

View File

@@ -0,0 +1,38 @@
import path from 'path'
import { fileURLToPath } from 'url'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const LoginWithUsernameConfig = buildConfigWithDefaults({
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
collections: [
{
slug: 'users',
auth: {
loginWithUsername: {
requireEmail: false,
allowEmailLogin: false,
},
},
fields: [],
},
{
slug: 'login-with-either',
auth: {
loginWithUsername: {
requireEmail: false,
allowEmailLogin: true,
requireUsername: false,
},
},
fields: [],
},
],
})
export default LoginWithUsernameConfig

View File

@@ -0,0 +1,118 @@
import type { Payload } from 'payload'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import configPromise from './config.js'
let payload: Payload
describe('Login With Username Feature', () => {
beforeAll(async () => {
;({ payload } = await initPayloadInt(configPromise))
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
describe('hook execution', () => {
it('should not allow creation with neither email nor username', async () => {
let errors = []
try {
await payload.create({
collection: 'login-with-either',
data: {
email: null,
username: null,
},
})
} catch (error) {
errors = error.data.errors
}
expect(errors).toHaveLength(2)
})
})
it('should not allow removing both username and email fields', async () => {
const emailToUse = 'example@email.com'
const usernameToUse = 'exampleUser'
const exampleUser = await payload.create({
collection: 'login-with-either',
data: {
email: emailToUse,
username: usernameToUse,
password: 'test',
},
})
let errors = []
try {
await payload.update({
collection: 'login-with-either',
id: exampleUser.id,
data: {
email: null,
username: null,
},
})
} catch (error) {
errors = error.data.errors
}
expect(errors).toHaveLength(2)
errors = []
await payload.update({
collection: 'login-with-either',
id: exampleUser.id,
data: {
username: null,
},
})
expect(errors).toHaveLength(0)
try {
await payload.update({
collection: 'login-with-either',
id: exampleUser.id,
data: {
email: null,
},
})
} catch (error) {
errors = error.data.errors
}
expect(errors).toHaveLength(2)
})
it('should allow login with either username or email', async () => {
await payload.create({
collection: 'login-with-either',
data: {
email: devUser.email,
username: 'dev',
password: devUser.password,
},
})
const loginWithEmail = await payload.login({
collection: 'login-with-either',
data: {
email: devUser.email,
password: devUser.password,
},
})
expect(loginWithEmail).toHaveProperty('token')
const loginWithUsername = await payload.login({
collection: 'login-with-either',
data: {
username: 'dev',
password: devUser.password,
},
})
expect(loginWithUsername).toHaveProperty('token')
})
})

View File

@@ -0,0 +1,166 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
auth: {
users: UserAuthOperations;
'login-with-either': LoginWithEitherAuthOperations;
};
collections: {
users: User;
'login-with-either': LoginWithEither;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
db: {
defaultIDType: string;
};
globals: {};
locale: null;
user:
| (User & {
collection: 'users';
})
| (LoginWithEither & {
collection: 'login-with-either';
});
}
export interface UserAuthOperations {
forgotPassword: {
username: string;
};
login: {
password: string;
username: string;
};
registerFirstUser: {
password: string;
username: string;
};
unlock: {
username: string;
};
}
export interface LoginWithEitherAuthOperations {
forgotPassword:
| {
email: string;
}
| {
username: string;
};
login:
| {
email: string;
password: string;
}
| {
password: string;
username: string;
};
registerFirstUser: {
password: string;
username?: string;
email?: string;
};
unlock:
| {
email: string;
}
| {
username: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email?: string | null;
username: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "login-with-either".
*/
export interface LoginWithEither {
id: string;
updatedAt: string;
createdAt: string;
email?: string | null;
username?: string | null;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user:
| {
relationTo: 'users';
value: string | User;
}
| {
relationTo: 'login-with-either';
value: string | LoginWithEither;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}