diff --git a/packages/next/src/elements/EmailAndUsername/index.tsx b/packages/next/src/elements/EmailAndUsername/index.tsx new file mode 100644 index 0000000000..dd07c31d1f --- /dev/null +++ b/packages/next/src/elements/EmailAndUsername/index.tsx @@ -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 = ({ 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 ( + + {showEmailField && ( + + )} + + {showUsernameField && ( + + )} + + ) +} diff --git a/packages/next/src/views/CreateFirstUser/index.client.tsx b/packages/next/src/views/CreateFirstUser/index.client.tsx index d4f47a9be1..d0e1faf1a8 100644 --- a/packages/next/src/views/CreateFirstUser/index.client.tsx +++ b/packages/next/src/views/CreateFirstUser/index.client.tsx @@ -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) && } - {['email', 'emailOrUsername'].includes(loginType) && ( - - )} + = 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 = async ({ initPageRe

{req.t('authentication:beginCreateFirstUser')}

diff --git a/packages/next/src/views/Edit/Default/Auth/index.tsx b/packages/next/src/views/Edit/Default/Auth/index.tsx index 4562e503f7..83a0ae44d8 100644 --- a/packages/next/src/views/Edit/Default/Auth/index.tsx +++ b/packages/next/src/views/Edit/Default/Auth/index.tsx @@ -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) => {
{!disableLocalStrategy && ( - {Boolean(loginWithUsername) && ( - - )} - {(!loginWithUsername || - loginWithUsername?.allowEmailLogin || - loginWithUsername?.requireEmail) && ( - - emailValidation(value, { - name: 'email', - type: 'email', - data: {}, - preferences: { fields: {} }, - req: { t } as any, - required: true, - siblingData: {}, - }) - } - /> - )} + {(showPasswordFields || requirePassword) && (
= ({ type, required = true }) => { const { t } = useTranslation() - const config = useConfig() if (type === 'email') { return ( @@ -20,17 +20,7 @@ export const LoginField: React.FC = ({ 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 = ({ 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 = ({ 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, + ) + const passesEmail = email( + value, + options as ValidateOptions, + ) if (!passesEmail && !passesUsername) { return `${t('general:email')}: ${passesEmail} ${t('general:username')}: ${passesUsername}` diff --git a/packages/payload/src/admin/fields/Array.ts b/packages/payload/src/admin/fields/Array.ts index 0721189a87..ca609e4676 100644 --- a/packages/payload/src/admin/fields/Array.ts +++ b/packages/payload/src/admin/fields/Array.ts @@ -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 diff --git a/packages/payload/src/admin/fields/Blocks.ts b/packages/payload/src/admin/fields/Blocks.ts index 494702e1a6..ad35fd998a 100644 --- a/packages/payload/src/admin/fields/Blocks.ts +++ b/packages/payload/src/admin/fields/Blocks.ts @@ -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 diff --git a/packages/payload/src/admin/fields/Checkbox.ts b/packages/payload/src/admin/fields/Checkbox.ts index 5fa9c22502..d0d3ddc713 100644 --- a/packages/payload/src/admin/fields/Checkbox.ts +++ b/packages/payload/src/admin/fields/Checkbox.ts @@ -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 diff --git a/packages/payload/src/admin/fields/Code.ts b/packages/payload/src/admin/fields/Code.ts index dd9158369a..44099636c1 100644 --- a/packages/payload/src/admin/fields/Code.ts +++ b/packages/payload/src/admin/fields/Code.ts @@ -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 diff --git a/packages/payload/src/admin/fields/Date.ts b/packages/payload/src/admin/fields/Date.ts index 9c40c6ee28..4090225f30 100644 --- a/packages/payload/src/admin/fields/Date.ts +++ b/packages/payload/src/admin/fields/Date.ts @@ -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 diff --git a/packages/payload/src/admin/fields/Email.ts b/packages/payload/src/admin/fields/Email.ts index 3b6aef47d7..9957d796b5 100644 --- a/packages/payload/src/admin/fields/Email.ts +++ b/packages/payload/src/admin/fields/Email.ts @@ -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 diff --git a/packages/payload/src/admin/fields/JSON.ts b/packages/payload/src/admin/fields/JSON.ts index 3d1d9bf57d..1c16d2f346 100644 --- a/packages/payload/src/admin/fields/JSON.ts +++ b/packages/payload/src/admin/fields/JSON.ts @@ -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 name?: string path?: string + validate?: JSONFieldValidation width?: string } & FormFieldBase diff --git a/packages/payload/src/admin/fields/Number.ts b/packages/payload/src/admin/fields/Number.ts index 94a18bd8cc..9bd34a34c9 100644 --- a/packages/payload/src/admin/fields/Number.ts +++ b/packages/payload/src/admin/fields/Number.ts @@ -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 diff --git a/packages/payload/src/admin/fields/Point.ts b/packages/payload/src/admin/fields/Point.ts index 0a60c9d062..dbeb8ff2a1 100644 --- a/packages/payload/src/admin/fields/Point.ts +++ b/packages/payload/src/admin/fields/Point.ts @@ -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 diff --git a/packages/payload/src/admin/fields/Radio.ts b/packages/payload/src/admin/fields/Radio.ts index 19a4c18274..38f61d5c0a 100644 --- a/packages/payload/src/admin/fields/Radio.ts +++ b/packages/payload/src/admin/fields/Radio.ts @@ -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 diff --git a/packages/payload/src/admin/fields/Relationship.ts b/packages/payload/src/admin/fields/Relationship.ts index 525cec6963..8cbe119d76 100644 --- a/packages/payload/src/admin/fields/Relationship.ts +++ b/packages/payload/src/admin/fields/Relationship.ts @@ -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 diff --git a/packages/payload/src/admin/fields/RichText.ts b/packages/payload/src/admin/fields/RichText.ts index ce4707ecef..e5c72795f0 100644 --- a/packages/payload/src/admin/fields/RichText.ts +++ b/packages/payload/src/admin/fields/RichText.ts @@ -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 + validate?: RichTextFieldValidation width?: string } & FormFieldBase diff --git a/packages/payload/src/admin/fields/Select.ts b/packages/payload/src/admin/fields/Select.ts index 4c830f0b67..45f2a75d0c 100644 --- a/packages/payload/src/admin/fields/Select.ts +++ b/packages/payload/src/admin/fields/Select.ts @@ -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 diff --git a/packages/payload/src/admin/fields/Text.ts b/packages/payload/src/admin/fields/Text.ts index 9f01b7de70..9a215e0274 100644 --- a/packages/payload/src/admin/fields/Text.ts +++ b/packages/payload/src/admin/fields/Text.ts @@ -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 path?: string placeholder?: TextField['admin']['placeholder'] + validate?: TextFieldValidation width?: string } & FormFieldBase diff --git a/packages/payload/src/admin/fields/Textarea.ts b/packages/payload/src/admin/fields/Textarea.ts index fd7c6977cf..00970fa155 100644 --- a/packages/payload/src/admin/fields/Textarea.ts +++ b/packages/payload/src/admin/fields/Textarea.ts @@ -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 diff --git a/packages/payload/src/admin/fields/Upload.ts b/packages/payload/src/admin/fields/Upload.ts index 27496fcc2c..617770b2e5 100644 --- a/packages/payload/src/admin/fields/Upload.ts +++ b/packages/payload/src/admin/fields/Upload.ts @@ -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 diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts index a540cb9540..d738609b89 100644 --- a/packages/payload/src/admin/forms/Form.ts +++ b/packages/payload/src/admin/forms/Form.ts @@ -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 } diff --git a/packages/payload/src/auth/baseFields/email.ts b/packages/payload/src/auth/baseFields/email.ts index 548d289627..3b1fe7c9d9 100644 --- a/packages/payload/src/auth/baseFields/email.ts +++ b/packages/payload/src/auth/baseFields/email.ts @@ -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, -}) +} diff --git a/packages/payload/src/auth/baseFields/username.ts b/packages/payload/src/auth/baseFields/username.ts index 198de4fe9a..2d85cd8d88 100644 --- a/packages/payload/src/auth/baseFields/username.ts +++ b/packages/payload/src/auth/baseFields/username.ts @@ -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: { diff --git a/packages/payload/src/auth/ensureUsernameOrEmail.ts b/packages/payload/src/auth/ensureUsernameOrEmail.ts new file mode 100644 index 0000000000..0113243a1b --- /dev/null +++ b/packages/payload/src/auth/ensureUsernameOrEmail.ts @@ -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 = { + authOptions: AuthCollection['config']['auth'] + collectionSlug: string + data: RequiredDataFromCollectionSlug + req: PayloadRequest +} & ( + | { + operation: 'create' + originalDoc?: never + } + | { + operation: 'update' + originalDoc: RequiredDataFromCollectionSlug + } +) +export const ensureUsernameOrEmail = ({ + authOptions: { disableLocalStrategy, loginWithUsername }, + collectionSlug, + data, + operation, + originalDoc, + req, +}: ValidateUsernameOrEmailArgs) => { + // 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 +} diff --git a/packages/payload/src/auth/getAuthFields.ts b/packages/payload/src/auth/getAuthFields.ts index e8736ac27f..e02803d5f4 100644 --- a/packages/payload/src/auth/getAuthFields.ts +++ b/packages/payload/src/auth/getAuthFields.ts @@ -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) { diff --git a/packages/payload/src/auth/operations/registerFirstUser.ts b/packages/payload/src/auth/operations/registerFirstUser.ts index 90b3514816..a8cb4997a7 100644 --- a/packages/payload/src/auth/operations/registerFirstUser.ts +++ b/packages/payload/src/auth/operations/registerFirstUser.ts @@ -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 = { collection: Collection @@ -44,6 +45,14 @@ export const registerFirstUserOperation = async ( try { const shouldCommit = await initTransaction(req) + ensureUsernameOrEmail({ + authOptions: config.auth, + collectionSlug: slug, + data, + operation: 'create', + req, + }) + const doc = await payload.db.findOne({ collection: config.slug, req, diff --git a/packages/payload/src/auth/types.ts b/packages/payload/src/auth/types.ts index 43d07c5127..8954f40be5 100644 --- a/packages/payload/src/auth/types.ts +++ b/packages/payload/src/auth/types.ts @@ -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?: { diff --git a/packages/payload/src/collections/config/defaults.ts b/packages/payload/src/collections/config/defaults.ts index cc7d51becc..e3d292b652 100644 --- a/packages/payload/src/collections/config/defaults.ts +++ b/packages/payload/src/collections/config/defaults.ts @@ -66,4 +66,5 @@ export const authDefaults: IncomingAuthType = { export const loginWithUsernameDefaults: LoginWithUsernameOptions = { allowEmailLogin: false, requireEmail: false, + requireUsername: true, } diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index b8c6e44a82..0ef1045155 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -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)) } diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index ff3e52a621..2d5c88f073 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -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 ( try { const shouldCommit = await initTransaction(args.req) + ensureUsernameOrEmail({ + authOptions: args.collection.config.auth, + collectionSlug: args.collection.config.slug, + data: args.data, + operation: 'create', + req: args.req, + }) + // ///////////////////////////////////// // beforeOperation - Collection // ///////////////////////////////////// diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index 4295b34c5a..3e1e49d8f6 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -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 ( req, }) + if (args.collection.config.auth) { + ensureUsernameOrEmail({ + authOptions: args.collection.config.auth, + collectionSlug: args.collection.config.slug, + data: args.data, + operation: 'update', + originalDoc, + req: args.req, + }) + } + // ///////////////////////////////////// // beforeValidate - Fields // ///////////////////////////////////// diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index b4df42a105..0a07606c0a 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -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 ( showHiddenFields: true, }) + if (args.collection.config.auth) { + ensureUsernameOrEmail({ + 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 // ///////////////////////////////////// diff --git a/packages/payload/src/errors/ValidationError.ts b/packages/payload/src/errors/ValidationError.ts index 8c1df50f6a..103f9ee475 100644 --- a/packages/payload/src/errors/ValidationError.ts +++ b/packages/payload/src/errors/ValidationError.ts @@ -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 diff --git a/packages/payload/src/errors/index.ts b/packages/payload/src/errors/index.ts index d0781e4f09..e152bb7f53 100644 --- a/packages/payload/src/errors/index.ts +++ b/packages/payload/src/errors/index.ts @@ -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' diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 410649d8e0..20ac7d6151 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -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 = { + collectionSlug?: string data: Partial id?: number | string operation?: Operation preferences: DocumentPreferences previousValue?: TValue req: PayloadRequest + required?: boolean siblingData: Partial } @@ -202,8 +205,6 @@ export type Validate< options: ValidateOptions, ) => Promise | string | true -export type ClientValidate = Omit - export type OptionObject = { label: LabelFunction | LabelStatic value: string diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index ebee543b0f..5606b755a2 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -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, diff --git a/packages/payload/src/fields/validations.ts b/packages/payload/src/fields/validations.ts index b7427a563c..8cc3f2dde5 100644 --- a/packages/payload/src/fields/validations.ts +++ b/packages/payload/src/fields/validations.ts @@ -31,7 +31,8 @@ import type { import { isNumber } from '../utilities/isNumber.js' import { isValidID } from '../utilities/isValidID.js' -export const text: Validate = ( +export type TextFieldValidation = Validate +export const text: TextFieldValidation = ( value, { hasMany, @@ -83,7 +84,8 @@ export const text: Validate = ( return true } -export const password: Validate = ( +export type PasswordFieldValidation = Validate +export const password: PasswordFieldValidation = ( value, { maxLength: fieldMaxLength, @@ -115,32 +117,54 @@ export const password: Validate = ( return true } -export const confirmPassword: Validate = ( +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 = ( +export type EmailFieldValidation = Validate +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 = ( return true } -export const username: Validate = ( +export type UsernameFieldValidation = Validate +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 = ( return true } -export const textarea: Validate = ( +export type TextareaFieldValidation = Validate +export const textarea: TextareaFieldValidation = ( value, { maxLength: fieldMaxLength, @@ -204,10 +246,8 @@ export const textarea: Validate = ( return true } -export const code: Validate = ( - value, - { req: { t }, required }, -) => { +export type CodeFieldValidation = Validate +export const code: CodeFieldValidation = (value, { req: { t }, required }) => { if (required && value === undefined) { return t('validation:required') } @@ -215,7 +255,13 @@ export const code: Validate = ( return true } -export const json: Validate = 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 = ( - value, - { req: { t }, required }, -) => { +export type CheckboxFieldValidation = Validate +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 = ( return true } -export const date: Validate = ( - value, - { req: { t }, required }, -) => { +export type DateFieldValidation = Validate +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 = ( return true } -export const richText: Validate = async ( - value, - options, -) => { +export type RichTextFieldValidation = Validate +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 = ( +export type NumberFieldValidation = Validate +export const number: NumberFieldValidation = ( value, { hasMany, max, maxRows, min, minRows, req: { t }, required }, ) => { @@ -391,17 +432,13 @@ export const number: Validate return true } -export const array: Validate = ( - value, - { maxRows, minRows, req: { t }, required }, -) => { +export type ArrayFieldValidation = Validate +export const array: ArrayFieldValidation = (value, { maxRows, minRows, req: { t }, required }) => { return validateArrayLength(value, { maxRows, minRows, required, t }) } -export const blocks: Validate = ( - value, - { maxRows, minRows, req: { t }, required }, -) => { +export type BlockFieldValidation = Validate +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 = ( - value: string, - options, -) => { +export type UploadFieldValidation = Validate +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 = ( 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 = ( +export type SelectFieldValidation = Validate +export const select: SelectFieldValidation = ( value, { hasMany, options, req: { t }, required }, ) => { @@ -671,10 +708,8 @@ export const select: Validate = ( return true } -export const radio: Validate = ( - value, - { options, req: { t }, required }, -) => { +export type RadioFieldValidation = Validate +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 = ( 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 ( diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 92ec53b575..17c6c2594d 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -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' diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index cd2f63795d..b3c6c2d0ef 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -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( diff --git a/packages/richtext-lexical/src/features/blocks/validate.ts b/packages/richtext-lexical/src/features/blocks/validate.ts index 6b8d79dacb..489d8c2f90 100644 --- a/packages/richtext-lexical/src/features/blocks/validate.ts +++ b/packages/richtext-lexical/src/features/blocks/validate.ts @@ -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', diff --git a/packages/richtext-lexical/src/features/link/validate.ts b/packages/richtext-lexical/src/features/link/validate.ts index 43addabbf6..ec6927d616 100644 --- a/packages/richtext-lexical/src/features/link/validate.ts +++ b/packages/richtext-lexical/src/features/link/validate.ts @@ -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', diff --git a/packages/richtext-lexical/src/features/upload/validate.ts b/packages/richtext-lexical/src/features/upload/validate.ts index e9e31cf6a8..d869fec4cc 100644 --- a/packages/richtext-lexical/src/features/upload/validate.ts +++ b/packages/richtext-lexical/src/features/upload/validate.ts @@ -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', diff --git a/packages/richtext-slate/src/field/RichText.tsx b/packages/richtext-slate/src/field/RichText.tsx index f69152e603..b9d5ee8170 100644 --- a/packages/richtext-slate/src/field/RichText.tsx +++ b/packages/richtext-slate/src/field/RichText.tsx @@ -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 + 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, }) } diff --git a/packages/ui/src/fields/Checkbox/index.tsx b/packages/ui/src/fields/Checkbox/index.tsx index 6687a9939e..06d2adf44b 100644 --- a/packages/ui/src/fields/Checkbox/index.tsx +++ b/packages/ui/src/fields/Checkbox/index.tsx @@ -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 = (props) => { const editDepth = useEditDepth() - const memoizedValidate: ClientValidate = useCallback( + const memoizedValidate: CheckboxFieldValidation = useCallback( (value, options) => { if (typeof validate === 'function') { return validate(value, { ...options, required }) diff --git a/packages/ui/src/fields/ConfirmPassword/index.tsx b/packages/ui/src/fields/ConfirmPassword/index.tsx index c1c4ac9442..9397fd7d08 100644 --- a/packages/ui/src/fields/ConfirmPassword/index.tsx +++ b/packages/ui/src/fields/ConfirmPassword/index.tsx @@ -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 = (props) => { const { disabled, path = 'confirm-password' } = props - - const password = useFormFields(([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 ( diff --git a/packages/ui/src/fields/DateTime/index.tsx b/packages/ui/src/fields/DateTime/index.tsx index fa598a01bb..940ce0de23 100644 --- a/packages/ui/src/fields/DateTime/index.tsx +++ b/packages/ui/src/fields/DateTime/index.tsx @@ -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 = (props) => { const { i18n } = useTranslation() - const memoizedValidate: ClientValidate = useCallback( + const memoizedValidate: DateFieldValidation = useCallback( (value, options) => { if (typeof validate === 'function') { return validate(value, { ...options, required }) diff --git a/packages/ui/src/fields/Email/index.tsx b/packages/ui/src/fields/Email/index.tsx index 5e4be3ca00..7129153b94 100644 --- a/packages/ui/src/fields/Email/index.tsx +++ b/packages/ui/src/fields/Email/index.tsx @@ -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 = (props) => { const { i18n } = useTranslation() - const memoizedValidate: ClientValidate = useCallback( + const memoizedValidate: EmailFieldValidation = useCallback( (value, options) => { if (typeof validate === 'function') { return validate(value, { ...options, required }) diff --git a/packages/ui/src/fields/JSON/index.tsx b/packages/ui/src/fields/JSON/index.tsx index 2408107273..9d4fa19210 100644 --- a/packages/ui/src/fields/JSON/index.tsx +++ b/packages/ui/src/fields/JSON/index.tsx @@ -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 = (props) => { const [jsonError, setJsonError] = useState() const [hasLoadedValue, setHasLoadedValue] = useState(false) - const memoizedValidate: ClientValidate = useCallback( + const memoizedValidate = useCallback( (value, options) => { if (typeof validate === 'function') return validate(value, { ...options, jsonError, required }) diff --git a/packages/ui/src/fields/Password/index.tsx b/packages/ui/src/fields/Password/index.tsx index c5512b2abb..9eb995fcc8 100644 --- a/packages/ui/src/fields/Password/index.tsx +++ b/packages/ui/src/fields/Password/index.tsx @@ -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 = (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 }) diff --git a/packages/ui/src/fields/Point/index.tsx b/packages/ui/src/fields/Point/index.tsx index 7fc951c5b2..bfd2961b30 100644 --- a/packages/ui/src/fields/Point/index.tsx +++ b/packages/ui/src/fields/Point/index.tsx @@ -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 = (props) => { const { i18n, t } = useTranslation() - const memoizedValidate: ClientValidate = useCallback( + const memoizedValidate: PointFieldValidation = useCallback( (value, options) => { if (typeof validate === 'function') { return validate(value, { ...options, required }) diff --git a/packages/ui/src/fields/Select/index.tsx b/packages/ui/src/fields/Select/index.tsx index 715beab5e9..aed138a147 100644 --- a/packages/ui/src/fields/Select/index.tsx +++ b/packages/ui/src/fields/Select/index.tsx @@ -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 = (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 }) diff --git a/packages/ui/src/fields/Text/index.tsx b/packages/ui/src/fields/Text/index.tsx index 99d6fe36a7..28879ef8b9 100644 --- a/packages/ui/src/fields/Text/index.tsx +++ b/packages/ui/src/fields/Text/index.tsx @@ -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 = (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 }) diff --git a/packages/ui/src/fields/Textarea/index.tsx b/packages/ui/src/fields/Textarea/index.tsx index 2d61204a28..4f73163591 100644 --- a/packages/ui/src/fields/Textarea/index.tsx +++ b/packages/ui/src/fields/Textarea/index.tsx @@ -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 = (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 }) diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index cf17120b6b..f99fbbb051 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -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 = (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 = (props) => { } return isValid - }, [id, user, operation, t, dispatchFields, config]) + }, [collectionSlug, config, dispatchFields, id, operation, t, user]) const submit = useCallback( async (options: SubmitOptions = {}, e): Promise => { @@ -621,11 +628,11 @@ export const Form: React.FC = (props) => { return (
contextRef.current.submit({}, e)} + onSubmit={(e) => void contextRef.current.submit({}, e)} ref={formRef} > diff --git a/packages/ui/src/forms/buildStateFromSchema/addFieldStatePromise.ts b/packages/ui/src/forms/buildStateFromSchema/addFieldStatePromise.ts index e5d2ccda48..42fba6b24e 100644 --- a/packages/ui/src/forms/buildStateFromSchema/addFieldStatePromise.ts +++ b/packages/ui/src/forms/buildStateFromSchema/addFieldStatePromise.ts @@ -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, diff --git a/packages/ui/src/forms/buildStateFromSchema/index.tsx b/packages/ui/src/forms/buildStateFromSchema/index.tsx index 1c9787f2d9..3b492cf6a5 100644 --- a/packages/ui/src/forms/buildStateFromSchema/index.tsx +++ b/packages/ui/src/forms/buildStateFromSchema/index.tsx @@ -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 => { - 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 => { await iterateFields({ id, addErrorPathToParent: null, + collectionSlug, data: dataWithDefaultValues, fields: fieldSchema, fullData, diff --git a/packages/ui/src/forms/buildStateFromSchema/iterateFields.ts b/packages/ui/src/forms/buildStateFromSchema/iterateFields.ts index 456b079818..84ac8331c6 100644 --- a/packages/ui/src/forms/buildStateFromSchema/iterateFields.ts +++ b/packages/ui/src/forms/buildStateFromSchema/iterateFields.ts @@ -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, diff --git a/packages/ui/src/forms/useField/index.tsx b/packages/ui/src/forms/useField/index.tsx index f28cdb3f1e..ea6d3a18b1 100644 --- a/packages/ui/src/forms/useField/index.tsx +++ b/packages/ui/src/forms/useField/index.tsx @@ -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 = (options: Options): FieldType => { 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 = (options: Options): FieldType => { 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 = (options: Options): FieldType => { path, rows: field?.rows, valid, - // validate, + validate, value, } @@ -220,7 +228,7 @@ export const useField = (options: Options): FieldType => { user, validate, field?.rows, - field?.valid, + collectionSlug, ], ) diff --git a/packages/ui/src/forms/useField/types.ts b/packages/ui/src/forms/useField/types.ts index 97bceb8eaa..82a5faff41 100644 --- a/packages/ui/src/forms/useField/types.ts +++ b/packages/ui/src/forms/useField/types.ts @@ -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 = { diff --git a/packages/ui/src/utilities/buildFormState.ts b/packages/ui/src/utilities/buildFormState.ts index f1cc86b6e1..76a1eb6d52 100644 --- a/packages/ui/src/utilities/buildFormState.ts +++ b/packages/ui/src/utilities/buildFormState.ts @@ -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 diff --git a/test/auth/AuthDebug.tsx b/test/auth/AuthDebug.tsx index 65c90fa739..785adcf071 100644 --- a/test/auth/AuthDebug.tsx +++ b/test/auth/AuthDebug.tsx @@ -15,7 +15,9 @@ export const AuthDebug: React.FC = () => { setState(userRes) } - void fetchUser() + if (user?.id) { + void fetchUser() + } }, [user]) return ( diff --git a/test/auth/e2e.spec.ts b/test/auth/e2e.spec.ts index 7795ff10f8..c37d44cc1d 100644 --- a/test/auth/e2e.spec.ts +++ b/test/auth/e2e.spec.ts @@ -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 diff --git a/test/login-with-username/config.ts b/test/login-with-username/config.ts new file mode 100644 index 0000000000..9b9207166f --- /dev/null +++ b/test/login-with-username/config.ts @@ -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 diff --git a/test/login-with-username/int.spec.ts b/test/login-with-username/int.spec.ts new file mode 100644 index 0000000000..02d4bd16cf --- /dev/null +++ b/test/login-with-username/int.spec.ts @@ -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') + }) +}) diff --git a/test/login-with-username/payload-types.ts b/test/login-with-username/payload-types.ts new file mode 100644 index 0000000000..630b287afe --- /dev/null +++ b/test/login-with-username/payload-types.ts @@ -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 {} +} \ No newline at end of file diff --git a/test/login-with-username/tsconfig.json b/test/login-with-username/tsconfig.json new file mode 100644 index 0000000000..3c43903cfd --- /dev/null +++ b/test/login-with-username/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +}