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' 'use client'
import type { FormState } from 'payload' import type { FormState, LoginWithUsernameOptions } from 'payload'
import { import {
ConfirmPasswordField, ConfirmPasswordField,
@@ -15,14 +15,13 @@ import {
import { getFormState } from '@payloadcms/ui/shared' import { getFormState } from '@payloadcms/ui/shared'
import React from 'react' import React from 'react'
import { LoginField } from '../Login/LoginField/index.js' import { EmailAndUsernameFields } from '../../elements/EmailAndUsername/index.js'
export const CreateFirstUserClient: React.FC<{ export const CreateFirstUserClient: React.FC<{
initialState: FormState initialState: FormState
loginType: 'email' | 'emailOrUsername' | 'username' loginWithUsername?: LoginWithUsernameOptions | false
requireEmail?: boolean
userSlug: string userSlug: string
}> = ({ initialState, loginType, requireEmail = true, userSlug }) => { }> = ({ initialState, loginWithUsername, userSlug }) => {
const { getFieldMap } = useComponentMap() const { getFieldMap } = useComponentMap()
const { const {
@@ -58,10 +57,7 @@ export const CreateFirstUserClient: React.FC<{
redirect={admin} redirect={admin}
validationOperation="create" validationOperation="create"
> >
{['emailOrUsername', 'username'].includes(loginType) && <LoginField type="username" />} <EmailAndUsernameFields loginWithUsername={loginWithUsername} />
{['email', 'emailOrUsername'].includes(loginType) && (
<LoginField required={requireEmail} type="email" />
)}
<PasswordField <PasswordField
label={t('authentication:newPassword')} label={t('authentication:newPassword')}
name="password" 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 collectionConfig = config.collections?.find((collection) => collection?.slug === userSlug)
const { auth: authOptions } = collectionConfig const { auth: authOptions } = collectionConfig
const loginWithUsername = authOptions.loginWithUsername 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({ const { formState } = await getDocumentData({
collectionConfig, collectionConfig,
@@ -47,8 +41,7 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
<p>{req.t('authentication:beginCreateFirstUser')}</p> <p>{req.t('authentication:beginCreateFirstUser')}</p>
<CreateFirstUserClient <CreateFirstUserClient
initialState={formState} initialState={formState}
loginType={loginType} loginWithUsername={loginWithUsername}
requireEmail={emailRequired}
userSlug={userSlug} userSlug={userSlug}
/> />
</div> </div>

View File

@@ -4,9 +4,7 @@ import {
Button, Button,
CheckboxField, CheckboxField,
ConfirmPasswordField, ConfirmPasswordField,
EmailField,
PasswordField, PasswordField,
TextField,
useAuth, useAuth,
useConfig, useConfig,
useDocumentInfo, useDocumentInfo,
@@ -14,12 +12,12 @@ import {
useFormModified, useFormModified,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import { email as emailValidation } from 'payload/shared'
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { Props } from './types.js' import type { Props } from './types.js'
import { EmailAndUsernameFields } from '../../../../elements/EmailAndUsername/index.js'
import { APIKey } from './APIKey.js' import { APIKey } from './APIKey.js'
import './index.scss' import './index.scss'
@@ -140,38 +138,7 @@ export const Auth: React.FC<Props> = (props) => {
<div className={[baseClass, className].filter(Boolean).join(' ')}> <div className={[baseClass, className].filter(Boolean).join(' ')}>
{!disableLocalStrategy && ( {!disableLocalStrategy && (
<React.Fragment> <React.Fragment>
{Boolean(loginWithUsername) && ( <EmailAndUsernameFields loginWithUsername={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: {},
})
}
/>
)}
{(showPasswordFields || requirePassword) && ( {(showPasswordFields || requirePassword) && (
<div className={`${baseClass}__changing-password`}> <div className={`${baseClass}__changing-password`}>
<PasswordField <PasswordField

View File

@@ -1,16 +1,16 @@
'use client' '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 { email, username } from 'payload/shared'
import React from 'react' import React from 'react'
export type LoginFieldProps = { export type LoginFieldProps = {
required?: boolean required?: boolean
type: 'email' | 'emailOrUsername' | 'username' type: 'email' | 'emailOrUsername' | 'username'
validate?: Validate
} }
export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true }) => { export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true }) => {
const { t } = useTranslation() const { t } = useTranslation()
const config = useConfig()
if (type === 'email') { if (type === 'email') {
return ( return (
@@ -20,17 +20,7 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
name="email" name="email"
path="email" path="email"
required={required} required={required}
validate={(value) => validate={email}
email(value, {
name: 'email',
type: 'email',
data: {},
preferences: { fields: {} },
req: { t } as PayloadRequest,
required: true,
siblingData: {},
})
}
/> />
) )
} }
@@ -41,23 +31,8 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
label={t('authentication:username')} label={t('authentication:username')}
name="username" name="username"
path="username" path="username"
required required={required}
validate={(value) => validate={username}
username(value, {
name: 'username',
type: 'text',
data: {},
preferences: { fields: {} },
req: {
payload: {
config,
},
t,
} as PayloadRequest,
required: true,
siblingData: {},
})
}
/> />
) )
} }
@@ -68,36 +43,16 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
label={t('authentication:emailOrUsername')} label={t('authentication:emailOrUsername')}
name="username" name="username"
path="username" path="username"
required required={required}
validate={(value) => { validate={(value, options) => {
const passesUsername = username(value, { const passesUsername = username(
name: 'username', value,
type: 'text', options as ValidateOptions<any, { email?: string }, any, string>,
data: {}, )
preferences: { fields: {} }, const passesEmail = email(
req: { value,
payload: { options as ValidateOptions<any, { username?: string }, any, string>,
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: {},
})
if (!passesEmail && !passesUsername) { if (!passesEmail && !passesUsername) {
return `${t('general:email')}: ${passesEmail} ${t('general:username')}: ${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 { ArrayField } from '../../fields/config/types.js'
import type { ArrayFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js' import type { ErrorComponent } from '../forms/Error.js'
import type { FieldMap } from '../forms/FieldMap.js' import type { FieldMap } from '../forms/FieldMap.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js' import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -12,6 +13,7 @@ export type ArrayFieldProps = {
maxRows?: ArrayField['maxRows'] maxRows?: ArrayField['maxRows']
minRows?: ArrayField['minRows'] minRows?: ArrayField['minRows']
name?: string name?: string
validate?: ArrayFieldValidation
width?: string width?: string
} & FormFieldBase } & FormFieldBase

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import type { TextareaField } from '../../fields/config/types.js' import type { TextareaField } from '../../fields/config/types.js'
import type { TextareaFieldValidation } from '../../fields/validations.js'
import type { ErrorComponent } from '../forms/Error.js' import type { ErrorComponent } from '../forms/Error.js'
import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js' import type { DescriptionComponent, FormFieldBase, LabelComponent } from '../types.js'
@@ -9,6 +10,7 @@ export type TextareaFieldProps = {
path?: string path?: string
placeholder?: TextareaField['admin']['placeholder'] placeholder?: TextareaField['admin']['placeholder']
rows?: number rows?: number
validate?: TextareaFieldValidation
width?: string width?: string
} & FormFieldBase } & 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' import type { ErrorComponent } from '../forms/Error.js'
@@ -7,6 +13,7 @@ export type UploadFieldProps = {
name?: string name?: string
path?: string path?: string
relationTo?: UploadField['relationTo'] relationTo?: UploadField['relationTo']
validate?: UploadFieldValidation
width?: string width?: string
} & FormFieldBase } & 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' import type { Where } from '../../types/index.js'
export type Data = { export type Data = {
@@ -25,7 +25,7 @@ export type FormField = {
passesCondition?: boolean passesCondition?: boolean
rows?: Row[] rows?: Row[]
valid: boolean valid: boolean
validate?: ClientValidate validate?: Validate
value: unknown 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' import { email } from '../../fields/validations.js'
export const emailField = ({ required = true }: { required?: boolean }): Field => ({ export const emailFieldConfig: EmailField = {
name: 'email', name: 'email',
type: 'email', type: 'email',
admin: { admin: {
@@ -20,7 +20,7 @@ export const emailField = ({ required = true }: { required?: boolean }): Field =
], ],
}, },
label: ({ t }) => t('general:email'), label: ({ t }) => t('general:email'),
required, required: true,
unique: true, unique: true,
validate: email, 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' import { username } from '../../fields/validations.js'
export const usernameField: Field = { export const usernameFieldConfig: TextField = {
name: 'username', name: 'username',
type: 'text', type: 'text',
admin: { 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 type { IncomingAuthType } from './types.js'
import { accountLockFields } from './baseFields/accountLock.js' import { accountLockFields } from './baseFields/accountLock.js'
import { apiKeyFields } from './baseFields/apiKey.js' import { apiKeyFields } from './baseFields/apiKey.js'
import { baseAuthFields } from './baseFields/auth.js' import { baseAuthFields } from './baseFields/auth.js'
import { emailField } from './baseFields/email.js' import { emailFieldConfig } from './baseFields/email.js'
import { usernameField } from './baseFields/username.js' import { usernameFieldConfig } from './baseFields/username.js'
import { verificationFields } from './baseFields/verification.js' import { verificationFields } from './baseFields/verification.js'
export const getBaseAuthFields = (authConfig: IncomingAuthType): Field[] => { export const getBaseAuthFields = (authConfig: IncomingAuthType): Field[] => {
@@ -16,19 +16,24 @@ export const getBaseAuthFields = (authConfig: IncomingAuthType): Field[] => {
} }
if (!authConfig.disableLocalStrategy) { if (!authConfig.disableLocalStrategy) {
const emailFieldIndex = authFields.push(emailField({ required: true })) - 1 const emailField = { ...emailFieldConfig }
let usernameField: TextField | undefined
if (authConfig.loginWithUsername) { if (authConfig.loginWithUsername) {
if ( usernameField = { ...usernameFieldConfig }
typeof authConfig.loginWithUsername === 'object' && if (typeof authConfig.loginWithUsername === 'object') {
authConfig.loginWithUsername.requireEmail === false if (authConfig.loginWithUsername.requireEmail === false) {
) { emailField.required = false
authFields[emailFieldIndex] = 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) authFields.push(...baseAuthFields)
if (authConfig.verify) { if (authConfig.verify) {

View File

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

View File

@@ -118,10 +118,18 @@ export type AuthStrategy = {
name: string name: string
} }
export type LoginWithUsernameOptions = { export type LoginWithUsernameOptions =
allowEmailLogin?: boolean | {
requireEmail?: boolean 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 { export interface IncomingAuthType {
cookies?: { cookies?: {

View File

@@ -66,4 +66,5 @@ export const authDefaults: IncomingAuthType = {
export const loginWithUsernameDefaults: LoginWithUsernameOptions = { export const loginWithUsernameDefaults: LoginWithUsernameOptions = {
allowEmailLogin: false, allowEmailLogin: false,
requireEmail: 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 { Config, SanitizedConfig } from '../../config/types.js'
import type { CollectionConfig, SanitizedCollectionConfig } from './types.js' import type { CollectionConfig, SanitizedCollectionConfig } from './types.js'
@@ -153,14 +154,24 @@ export const sanitizeCollection = async (
sanitized.auth.strategies = [] 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, ...loginWithUsernameDefaults,
...(typeof sanitized.auth.loginWithUsername === 'boolean' ...sanitized.auth.loginWithUsername,
? {} } as LoginWithUsernameOptions
: sanitized.auth.loginWithUsername),
// 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)) sanitized.fields = mergeBaseFields(sanitized.fields, getBaseAuthFields(sanitized.auth))
} }

View File

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

View File

@@ -12,6 +12,7 @@ import type {
RequiredDataFromCollectionSlug, RequiredDataFromCollectionSlug,
} from '../config/types.js' } from '../config/types.js'
import { ensureUsernameOrEmail } from '../../auth/ensureUsernameOrEmail.js'
import executeAccess from '../../auth/executeAccess.js' import executeAccess from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js' import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js' import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
@@ -199,6 +200,17 @@ export const updateOperation = async <TSlug extends CollectionSlug>(
req, 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 // beforeValidate - Fields
// ///////////////////////////////////// // /////////////////////////////////////

View File

@@ -11,6 +11,7 @@ import type {
RequiredDataFromCollectionSlug, RequiredDataFromCollectionSlug,
} from '../config/types.js' } from '../config/types.js'
import { ensureUsernameOrEmail } from '../../auth/ensureUsernameOrEmail.js'
import executeAccess from '../../auth/executeAccess.js' import executeAccess from '../../auth/executeAccess.js'
import { generatePasswordSaltHash } from '../../auth/strategies/local/generatePasswordSaltHash.js' import { generatePasswordSaltHash } from '../../auth/strategies/local/generatePasswordSaltHash.js'
import { hasWhereAccessResult } from '../../auth/types.js' import { hasWhereAccessResult } from '../../auth/types.js'
@@ -143,6 +144,17 @@ export const updateByIDOperation = async <TSlug extends CollectionSlug>(
showHiddenFields: true, 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 // Generate data for all files and sizes
// ///////////////////////////////////// // /////////////////////////////////////

View File

@@ -8,13 +8,20 @@ import { APIError } from './APIError.js'
// This gets dynamically reassigned during compilation // This gets dynamically reassigned during compilation
export let ValidationErrorName = 'ValidationError' 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<{ export class ValidationError extends APIError<{
collection?: string collection?: string
errors: { field: string; message: string }[] errors: ValidationFieldError[]
global?: string global?: string
}> { }> {
constructor( constructor(
results: { collection?: string; errors: { field: string; message: string }[]; global?: string }, results: { collection?: string; errors: ValidationFieldError[]; global?: string },
t?: TFunction, t?: TFunction,
) { ) {
const message = t const message = t

View File

@@ -20,3 +20,4 @@ export { NotFound } from './NotFound.js'
export { QueryError } from './QueryError.js' export { QueryError } from './QueryError.js'
export { ReservedFieldName } from './ReservedFieldName.js' export { ReservedFieldName } from './ReservedFieldName.js'
export { ValidationError, ValidationErrorName } from './ValidationError.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 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 { JSONSchema4 } from 'json-schema'
import type React from 'react' import type React from 'react'
import type { DeepPartial } from 'ts-essentials'
import type { RichTextAdapter, RichTextAdapterProvider } from '../../admin/RichText.js' import type { RichTextAdapter, RichTextAdapterProvider } from '../../admin/RichText.js'
import type { ErrorComponent } from '../../admin/forms/Error.js' import type { ErrorComponent } from '../../admin/forms/Error.js'
@@ -176,12 +177,14 @@ export type Labels = {
} }
export type BaseValidateOptions<TData, TSiblingData, TValue> = { export type BaseValidateOptions<TData, TSiblingData, TValue> = {
collectionSlug?: string
data: Partial<TData> data: Partial<TData>
id?: number | string id?: number | string
operation?: Operation operation?: Operation
preferences: DocumentPreferences preferences: DocumentPreferences
previousValue?: TValue previousValue?: TValue
req: PayloadRequest req: PayloadRequest
required?: boolean
siblingData: Partial<TSiblingData> siblingData: Partial<TSiblingData>
} }
@@ -202,8 +205,6 @@ export type Validate<
options: ValidateOptions<TData, TSiblingData, TFieldConfig, TValue>, options: ValidateOptions<TData, TSiblingData, TFieldConfig, TValue>,
) => Promise<string | true> | string | true ) => Promise<string | true> | string | true
export type ClientValidate = Omit<Validate, 'req'>
export type OptionObject = { export type OptionObject = {
label: LabelFunction | LabelStatic label: LabelFunction | LabelStatic
value: string value: string

View File

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

View File

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

View File

@@ -870,7 +870,6 @@ export type {
Block, Block,
BlockField, BlockField,
CheckboxField, CheckboxField,
ClientValidate,
CodeField, CodeField,
CollapsibleField, CollapsibleField,
Condition, 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 beforeChangeTraverseFields } from './fields/hooks/beforeChange/traverseFields.js'
export { traverseFields as beforeValidateTraverseFields } from './fields/hooks/beforeValidate/traverseFields.js' export { traverseFields as beforeValidateTraverseFields } from './fields/hooks/beforeValidate/traverseFields.js'
export { default as sortableFieldTypes } from './fields/sortableFieldTypes.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 type { ClientGlobalConfig } from './globals/config/client.js'
export { createClientGlobalConfig } from './globals/config/client.js' export { createClientGlobalConfig } from './globals/config/client.js'
export type { export type {
@@ -999,7 +1019,8 @@ export { deleteCollectionVersions } from './versions/deleteCollectionVersions.js
export { enforceMaxVersions } from './versions/enforceMaxVersions.js' export { enforceMaxVersions } from './versions/enforceMaxVersions.js'
export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js' export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js'
export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js' export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js'
export { saveVersion } from './versions/saveVersion.js'
export { getDependencies } export { getDependencies }
export { saveVersion } from './versions/saveVersion.js'
export type { TypeWithVersion } from './versions/types.js' export type { TypeWithVersion } from './versions/types.js'
export { deepMergeSimple } from '@payloadcms/translations/utilities' export { deepMergeSimple } from '@payloadcms/translations/utilities'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
'use client' '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 { useField } from '../../forms/useField/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { FieldError } from '../FieldError/index.js' import { FieldError } from '../FieldError/index.js'
@@ -18,28 +17,18 @@ export type ConfirmPasswordFieldProps = {
export const ConfirmPasswordField: React.FC<ConfirmPasswordFieldProps> = (props) => { export const ConfirmPasswordField: React.FC<ConfirmPasswordFieldProps> = (props) => {
const { disabled, path = 'confirm-password' } = props const { disabled, path = 'confirm-password' } = props
const password = useFormFields<FormField>(([fields]) => fields?.password)
const { t } = useTranslation() 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({ const { setValue, showError, value } = useField({
path, path,
validate, validate: (value, options) => {
return confirmPassword(value, {
name: 'confirm-password',
type: 'text',
required: true,
...options,
})
},
}) })
return ( return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
'use client' 'use client'
import type { PayloadRequest } from 'payload'
import { useCallback, useMemo, useRef } from 'react' import { useCallback, useMemo, useRef } from 'react'
import type { UPDATE } from '../Form/types.js' import type { UPDATE } from '../Form/types.js'
@@ -39,7 +41,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
const processing = useFormProcessing() const processing = useFormProcessing()
const initializing = useFormInitializing() const initializing = useFormInitializing()
const { user } = useAuth() const { user } = useAuth()
const { id } = useDocumentInfo() const { id, collectionSlug } = useDocumentInfo()
const operation = useOperation() const operation = useOperation()
const { dispatchField, field } = useFormFields(([fields, dispatch]) => ({ const { dispatchField, field } = useFormFields(([fields, dispatch]) => ({
@@ -161,12 +163,18 @@ export const useField = <T,>(options: Options): FieldType<T> => {
typeof validate === 'function' typeof validate === 'function'
? await validate(valueToValidate, { ? await validate(valueToValidate, {
id, id,
config, collectionSlug,
data: getData(), data: getData(),
operation, operation,
preferences: {} as any,
req: {
payload: {
config,
},
t,
user,
} as PayloadRequest,
siblingData: getSiblingData(path), siblingData: getSiblingData(path),
t,
user,
}) })
: true : true
@@ -190,7 +198,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
path, path,
rows: field?.rows, rows: field?.rows,
valid, valid,
// validate, validate,
value, value,
} }
@@ -220,7 +228,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
user, user,
validate, validate,
field?.rows, 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 = { export type Options = {
disableFormData?: boolean 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. * If you do not provide a `path` or a `name`, this hook will look for one using the `useFieldPath` hook.
**/ **/
path?: string path?: string
validate?: ClientValidate validate?: Validate
} }
export type FieldType<T> = { export type FieldType<T> = {

View File

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

View File

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

View File

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