fix: validates password and confirm password on the server (#7410)

Fixes https://github.com/payloadcms/payload/issues/7380

Adjusts how the password/confirm-password fields are validated. Moves
validation to the server, adds them to a custom schema under the schema
path `${collectionSlug}.auth` for auth enabled collections.
This commit is contained in:
Jarrod Flesch
2024-07-31 14:55:08 -04:00
committed by GitHub
parent 3d89508ce3
commit 290ffd3287
26 changed files with 430 additions and 209 deletions

View File

@@ -35,18 +35,17 @@ export const CreateFirstUserClient: React.FC<{
const fieldMap = getFieldMap({ collectionSlug: userSlug })
const onChange: FormProps['onChange'][0] = React.useCallback(
async ({ formState: prevFormState }) => {
return getFormState({
async ({ formState: prevFormState }) =>
getFormState({
apiRoute,
body: {
collectionSlug: userSlug,
formState: prevFormState,
operation: 'create',
schemaPath: userSlug,
schemaPath: `_${userSlug}.auth`,
},
serverURL,
})
},
}),
[apiRoute, userSlug, serverURL],
)
@@ -64,14 +63,15 @@ export const CreateFirstUserClient: React.FC<{
<LoginField required={requireEmail} type="email" />
)}
<PasswordField
autoComplete="off"
label={t('authentication:newPassword')}
name="password"
path="password"
required
/>
<ConfirmPasswordField />
<RenderFields
fieldMap={fieldMap}
forceRender
operation="create"
path=""
readOnly={false}

View File

@@ -1,10 +1,10 @@
import type { AdminViewProps, Field } from 'payload'
import type { AdminViewProps } from 'payload'
import { buildStateFromSchema } from '@payloadcms/ui/forms/buildStateFromSchema'
import React from 'react'
import type { LoginFieldProps } from '../Login/LoginField/index.js'
import { getDocumentData } from '../Document/getDocumentData.js'
import { CreateFirstUserClient } from './index.client.js'
import './index.scss'
@@ -12,6 +12,7 @@ export { generateCreateFirstUserMetadata } from './meta.js'
export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageResult }) => {
const {
locale,
req,
req: {
payload: {
@@ -26,7 +27,6 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
const collectionConfig = config.collections?.find((collection) => collection?.slug === userSlug)
const { auth: authOptions } = collectionConfig
const loginWithUsername = authOptions.loginWithUsername
const loginWithEmail = !loginWithUsername || loginWithUsername.allowEmailLogin
const emailRequired = loginWithUsername && loginWithUsername.requireEmail
let loginType: LoginFieldProps['type'] = loginWithUsername ? 'username' : 'email'
@@ -34,42 +34,11 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
loginType = 'emailOrUsername'
}
const emailField = {
name: 'email',
type: 'email',
label: req.t('general:emailAddress'),
required: emailRequired ? true : false,
}
const usernameField = {
name: 'username',
type: 'text',
label: req.t('authentication:username'),
required: true,
}
const fields = [
...(loginWithUsername ? [usernameField] : []),
...(emailRequired || loginWithEmail ? [emailField] : []),
{
name: 'password',
type: 'text',
label: req.t('general:password'),
required: true,
},
{
name: 'confirm-password',
type: 'text',
label: req.t('authentication:confirmPassword'),
required: true,
},
]
const formState = await buildStateFromSchema({
fieldSchema: fields as Field[],
operation: 'create',
preferences: { fields: {} },
const { formState } = await getDocumentData({
collectionConfig,
locale,
req,
schemaPath: `_${collectionConfig.slug}.auth`,
})
return (

View File

@@ -15,8 +15,11 @@ export const getDocumentData = async (args: {
id?: number | string
locale: Locale
req: PayloadRequest
schemaPath?: string
}): Promise<Data> => {
const { id, collectionConfig, globalConfig, locale, req } = args
const { id, collectionConfig, globalConfig, locale, req, schemaPath: schemaPathFromProps } = args
const schemaPath = schemaPathFromProps || collectionConfig?.slug || globalConfig?.slug
try {
const formState = await buildFormState({
@@ -28,7 +31,7 @@ export const getDocumentData = async (args: {
globalSlug: globalConfig?.slug,
locale: locale?.code,
operation: (collectionConfig && id) || globalConfig ? 'update' : 'create',
schemaPath: collectionConfig?.slug || globalConfig?.slug,
schemaPath,
},
},
})

View File

@@ -78,7 +78,7 @@ export const APIKey: React.FC<{ enabled: boolean; readOnly?: boolean }> = ({
if (!apiKeyValue && enabled) {
setValue(initialAPIKey)
}
if (!enabled) {
if (!enabled && apiKeyValue) {
setValue(null)
}
}, [apiKeyValue, enabled, setValue, initialAPIKey])
@@ -100,6 +100,7 @@ export const APIKey: React.FC<{ enabled: boolean; readOnly?: boolean }> = ({
<div className={[fieldBaseClass, 'api-key', 'read-only'].filter(Boolean).join(' ')}>
<FieldLabel CustomLabel={APIKeyLabel} htmlFor={path} />
<input
aria-label="API Key"
className={highlightedField ? 'highlight' : undefined}
disabled
id="apiKey"

View File

@@ -14,6 +14,7 @@ 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'
@@ -34,6 +35,8 @@ export const Auth: React.FC<Props> = (props) => {
operation,
readOnly,
requirePassword,
setSchemaPath,
setValidateBeforeSubmit,
useAPIKey,
username,
verify,
@@ -42,6 +45,7 @@ export const Auth: React.FC<Props> = (props) => {
const { permissions } = useAuth()
const [changingPassword, setChangingPassword] = useState(requirePassword)
const enableAPIKey = useFormFields(([fields]) => (fields && fields?.enableAPIKey) || null)
const forceOpenChangePassword = useFormFields(([fields]) => (fields && fields?.password) || null)
const dispatchFields = useFormFields((reducer) => reducer[1])
const modified = useFormModified()
const { i18n, t } = useTranslation()
@@ -70,15 +74,32 @@ export const Auth: React.FC<Props> = (props) => {
}, [permissions, collectionSlug])
const handleChangePassword = useCallback(
(state: boolean) => {
if (!state) {
(showPasswordFields: boolean) => {
if (showPasswordFields) {
setValidateBeforeSubmit(true)
setSchemaPath(`_${collectionSlug}.auth`)
dispatchFields({
type: 'UPDATE',
errorMessage: t('validation:required'),
path: 'password',
valid: false,
})
dispatchFields({
type: 'UPDATE',
errorMessage: t('validation:required'),
path: 'confirm-password',
valid: false,
})
} else {
setValidateBeforeSubmit(false)
setSchemaPath(collectionSlug)
dispatchFields({ type: 'REMOVE', path: 'password' })
dispatchFields({ type: 'REMOVE', path: 'confirm-password' })
}
setChangingPassword(state)
setChangingPassword(showPasswordFields)
},
[dispatchFields],
[dispatchFields, t, collectionSlug, setSchemaPath, setValidateBeforeSubmit],
)
const unlock = useCallback(async () => {
@@ -99,7 +120,7 @@ export const Auth: React.FC<Props> = (props) => {
} else {
toast.error(t('authentication:failedToUnlock'))
}
}, [i18n, serverURL, api, collectionSlug, email, username, t])
}, [i18n, serverURL, api, collectionSlug, email, username, t, loginWithUsername])
useEffect(() => {
if (!modified) {
@@ -113,6 +134,8 @@ export const Auth: React.FC<Props> = (props) => {
const disabled = readOnly || isInitializing
const showPasswordFields = changingPassword || forceOpenChangePassword
return (
<div className={[baseClass, className].filter(Boolean).join(' ')}>
{!disableLocalStrategy && (
@@ -136,22 +159,33 @@ export const Auth: React.FC<Props> = (props) => {
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: {},
})
}
/>
)}
{(changingPassword || requirePassword) && (
{(showPasswordFields || requirePassword) && (
<div className={`${baseClass}__changing-password`}>
<PasswordField
autoComplete="off"
disabled={disabled}
label={t('authentication:newPassword')}
name="password"
path="password"
required
/>
<ConfirmPasswordField disabled={readOnly} />
</div>
)}
<div className={`${baseClass}__controls`}>
{changingPassword && !requirePassword && (
{showPasswordFields && !requirePassword && (
<Button
buttonStyle="secondary"
disabled={disabled}
@@ -161,7 +195,7 @@ export const Auth: React.FC<Props> = (props) => {
{t('general:cancel')}
</Button>
)}
{!changingPassword && !requirePassword && (
{!showPasswordFields && !requirePassword && (
<Button
buttonStyle="secondary"
disabled={disabled}

View File

@@ -9,6 +9,8 @@ export type Props = {
operation: 'create' | 'update'
readOnly: boolean
requirePassword?: boolean
setSchemaPath: (path: string) => void
setValidateBeforeSubmit: (validate: boolean) => void
useAPIKey?: boolean
username: string
verify?: VerifyConfig | boolean

View File

@@ -17,7 +17,7 @@ import {
} from '@payloadcms/ui'
import { formatAdminURL, getFormState } from '@payloadcms/ui/shared'
import { useRouter, useSearchParams } from 'next/navigation.js'
import React, { Fragment, useCallback } from 'react'
import React, { Fragment, useCallback, useState } from 'react'
import { LeaveWithoutSaving } from '../../../elements/LeaveWithoutSaving/index.js'
import { Auth } from './Auth/index.js'
@@ -102,6 +102,9 @@ export const DefaultEditView: React.FC = () => {
const classes = [baseClass, id && `${baseClass}--is-editing`].filter(Boolean).join(' ')
const [schemaPath, setSchemaPath] = React.useState(entitySlug)
const [validateBeforeSubmit, setValidateBeforeSubmit] = useState(false)
const onSave = useCallback(
(json) => {
reportUpdate({
@@ -158,7 +161,6 @@ export const DefaultEditView: React.FC = () => {
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
const docPreferences = await getDocPreferences()
return getFormState({
apiRoute,
body: {
@@ -168,12 +170,12 @@ export const DefaultEditView: React.FC = () => {
formState: prevFormState,
globalSlug,
operation,
schemaPath: entitySlug,
schemaPath,
},
serverURL,
})
},
[serverURL, apiRoute, id, operation, entitySlug, collectionSlug, globalSlug, getDocPreferences],
[apiRoute, collectionSlug, schemaPath, getDocPreferences, globalSlug, id, operation, serverURL],
)
return (
@@ -182,7 +184,7 @@ export const DefaultEditView: React.FC = () => {
<Form
action={action}
className={`${baseClass}__form`}
disableValidationOnSubmit
disableValidationOnSubmit={!validateBeforeSubmit}
disabled={isInitializing || !hasSavePermission}
initialState={!isInitializing && initialState}
isInitializing={isInitializing}
@@ -231,6 +233,8 @@ export const DefaultEditView: React.FC = () => {
operation={operation}
readOnly={!hasSavePermission}
requirePassword={!id}
setSchemaPath={setSchemaPath}
setValidateBeforeSubmit={setValidateBeforeSubmit}
useAPIKey={auth.useAPIKey}
username={data?.username}
verify={auth.verify}
@@ -255,7 +259,7 @@ export const DefaultEditView: React.FC = () => {
docPermissions={docPermissions}
fieldMap={fieldMap}
readOnly={!hasSavePermission}
schemaPath={entitySlug}
schemaPath={schemaPath}
/>
{AfterDocument}
</Form>

View File

@@ -18,6 +18,7 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
autoComplete="email"
label={t('general:email')}
name="email"
path="email"
required={required}
validate={(value) =>
email(value, {
@@ -39,6 +40,7 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
<TextField
label={t('authentication:username')}
name="username"
path="username"
required
validate={(value) =>
username(value, {
@@ -65,6 +67,7 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
<TextField
label={t('authentication:emailOrUsername')}
name="username"
path="username"
required
validate={(value) => {
const passesUsername = username(value, {

View File

@@ -6,11 +6,10 @@ import React from 'react'
const baseClass = 'login__form'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
import type { FormState, PayloadRequest } from 'payload'
import type { FormState } from 'payload'
import { Form, FormSubmit, PasswordField, useConfig, useTranslation } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import { password } from 'payload/shared'
import type { LoginFieldProps } from '../LoginField/index.js'
@@ -82,28 +81,7 @@ export const LoginForm: React.FC<{
>
<div className={`${baseClass}__inputWrap`}>
<LoginField type={loginType} />
<PasswordField
autoComplete="off"
label={t('general:password')}
name="password"
required
validate={(value) =>
password(value, {
name: 'password',
type: 'text',
data: {},
preferences: { fields: {} },
req: {
payload: {
config,
},
t,
} as PayloadRequest,
required: true,
siblingData: {},
})
}
/>
<PasswordField label={t('general:password')} name="password" required />
</div>
<Link
href={formatAdminURL({

View File

@@ -9,7 +9,6 @@ import {
PasswordField,
useAuth,
useConfig,
useFormFields,
useTranslation,
} from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
@@ -64,7 +63,7 @@ export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
toast.success(i18n.t('general:updatedSuccessfully'))
}
},
[fetchFullUser, history, adminRoute, i18n],
[adminRoute, fetchFullUser, history, i18n, loginRoute],
)
return (
@@ -74,42 +73,15 @@ export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
method="POST"
onSuccess={onSuccess}
>
<PasswordToConfirm />
<PasswordField
label={i18n.t('authentication:newPassword')}
name="password"
path="password"
required
/>
<ConfirmPasswordField />
<HiddenField forceUsePathFromProps name="token" value={token} />
<FormSubmit>{i18n.t('authentication:resetPassword')}</FormSubmit>
</Form>
)
}
const PasswordToConfirm = () => {
const { t } = useTranslation()
const { value: confirmValue } = useFormFields(
([fields]) => (fields && fields?.['confirm-password']) || null,
)
const validate = React.useCallback(
(value: string) => {
if (!value) {
return t('validation:required')
}
if (value === confirmValue) {
return true
}
return t('fields:passwordsDoNotMatch')
},
[confirmValue, t],
)
return (
<PasswordField
autoComplete="off"
label={t('authentication:newPassword')}
name="password"
required
validate={validate}
/>
)
}

View File

@@ -70,6 +70,7 @@ export const resetPasswordOperation = async (args: Arguments): Promise<Result> =
const { hash, salt } = await generatePasswordSaltHash({
collection: collectionConfig,
password: data.password,
req,
})
user.salt = salt

View File

@@ -1,15 +1,10 @@
import crypto from 'crypto'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { PayloadRequest } from '../../../types/index.js'
import { ValidationError } from '../../../errors/index.js'
const defaultPasswordValidator = (password: string): string | true => {
if (!password) return 'No password was given'
if (password.length < 3) return 'Password must be at least 3 characters'
return true
}
import { password } from '../../../fields/validations.js'
function randomBytes(): Promise<Buffer> {
return new Promise((resolve, reject) =>
@@ -28,13 +23,23 @@ function pbkdf2Promisified(password: string, salt: string): Promise<Buffer> {
type Args = {
collection: SanitizedCollectionConfig
password: string
req: PayloadRequest
}
export const generatePasswordSaltHash = async ({
collection,
password,
password: passwordToSet,
req,
}: Args): Promise<{ hash: string; salt: string }> => {
const validationResult = defaultPasswordValidator(password)
const validationResult = password(passwordToSet, {
name: 'password',
type: 'text',
data: {},
preferences: { fields: {} },
req,
required: true,
siblingData: {},
})
if (typeof validationResult === 'string') {
throw new ValidationError({
@@ -46,7 +51,7 @@ export const generatePasswordSaltHash = async ({
const saltBuffer = await randomBytes()
const salt = saltBuffer.toString('hex')
const hashRaw = await pbkdf2Promisified(password, salt)
const hashRaw = await pbkdf2Promisified(passwordToSet, salt)
const hash = hashRaw.toString('hex')
return { hash, salt }

View File

@@ -55,7 +55,7 @@ export const registerLocalStrategy = async ({
})
}
const { hash, salt } = await generatePasswordSaltHash({ collection, password })
const { hash, salt } = await generatePasswordSaltHash({ collection, password, req })
const sanitizedDoc = { ...doc }
if (sanitizedDoc.password) delete sanitizedDoc.password

View File

@@ -263,6 +263,7 @@ export const updateByIDOperation = async <TSlug extends CollectionSlug>(
const { hash, salt } = await generatePasswordSaltHash({
collection: collectionConfig,
password,
req,
})
dataToUpdate.salt = salt
dataToUpdate.hash = hash

View File

@@ -87,7 +87,7 @@ export const password: Validate<string, unknown, unknown, TextField> = (
value,
{
maxLength: fieldMaxLength,
minLength,
minLength = 3,
req: {
payload: { config },
t,
@@ -115,6 +115,28 @@ export const password: Validate<string, unknown, unknown, TextField> = (
return true
}
export const confirmPassword: Validate<string, unknown, unknown, TextField> = (
value,
{ req: { data, t }, required },
) => {
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
) {
return t('fields:passwordsDoNotMatch')
}
return true
}
export const email: Validate<string, unknown, unknown, EmailField> = (
value,
{ req: { t }, required },
@@ -691,6 +713,7 @@ export default {
blocks,
checkbox,
code,
confirmPassword,
date,
email,
json,

View File

@@ -13,10 +13,11 @@ import './index.scss'
export type ConfirmPasswordFieldProps = {
disabled?: boolean
path?: string
}
export const ConfirmPasswordField: React.FC<ConfirmPasswordFieldProps> = (props) => {
const { disabled } = props
const { disabled, path = 'confirm-password' } = props
const password = useFormFields<FormField>(([fields]) => fields?.password)
const { t } = useTranslation()
@@ -36,10 +37,7 @@ export const ConfirmPasswordField: React.FC<ConfirmPasswordFieldProps> = (props)
[password, t],
)
const path = 'confirm-password'
const { setValue, showError, value } = useField({
disableFormData: true,
path,
validate,
})
@@ -58,6 +56,7 @@ export const ConfirmPasswordField: React.FC<ConfirmPasswordFieldProps> = (props)
<div className={`${fieldBaseClass}__wrap`}>
<FieldError path={path} />
<input
aria-label={t('authentication:confirmPassword')}
autoComplete="off"
disabled={!!disabled}
id="field-confirm-password"

View File

@@ -1,53 +1,90 @@
'use client'
import type { ClientValidate, Description, FormFieldBase , Validate } from 'payload'
import type { ClientValidate, Description, PayloadRequest, Validate } from 'payload'
import { useConfig, useLocale, useTranslation } from '@payloadcms/ui'
import { password } from 'payload/shared'
import React, { useCallback } from 'react'
import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js'
import { FieldError } from '../FieldError/index.js'
import { FieldLabel } from '../FieldLabel/index.js'
import { fieldBaseClass } from '../shared/index.js'
import { isFieldRTL } from '../shared/index.js'
import './index.scss'
import { PasswordInput } from './input.js'
export type PasswordFieldProps = {
AfterInput?: React.ReactElement
BeforeInput?: React.ReactElement
CustomDescription?: React.ReactElement
CustomError?: React.ReactElement
CustomLabel?: React.ReactElement
autoComplete?: string
className?: string
description?: Description
disabled?: boolean
errorProps?: any // unknown type
inputRef?: React.RefObject<HTMLInputElement>
label?: string
labelProps?: any // unknown type
name: string
path?: string
placeholder?: string
required?: boolean
rtl?: boolean
style?: React.CSSProperties
validate?: Validate
width?: string
} & FormFieldBase
}
const PasswordFieldComponent: React.FC<PasswordFieldProps> = (props) => {
const {
name,
AfterInput,
BeforeInput,
CustomDescription,
CustomError,
CustomLabel,
autoComplete,
className,
disabled: disabledFromProps,
errorProps,
inputRef,
label,
labelProps,
path: pathFromProps,
placeholder,
required,
rtl,
style,
validate,
width,
} = props
const { t } = useTranslation()
const locale = useLocale()
const config = useConfig()
const memoizedValidate: ClientValidate = useCallback(
(value, options) => {
if (typeof validate === 'function') {
return validate(value, { ...options, required })
}
return password(value, {
name: 'password',
type: 'text',
data: {},
preferences: { fields: {} },
req: {
payload: {
config,
},
t,
} as PayloadRequest,
required: true,
siblingData: {},
})
},
[validate, required],
[validate, config, t, required],
)
const { formInitializing, formProcessing, path, setValue, showError, value } = useField({
@@ -57,41 +94,39 @@ const PasswordFieldComponent: React.FC<PasswordFieldProps> = (props) => {
const disabled = disabledFromProps || formInitializing || formProcessing
const renderRTL = isFieldRTL({
fieldLocalized: false,
fieldRTL: rtl,
locale,
localizationConfig: config.localization || undefined,
})
return (
<div
className={[
fieldBaseClass,
'password',
className,
showError && 'error',
disabled && 'read-only',
]
.filter(Boolean)
.join(' ')}
style={{
...style,
width,
<PasswordInput
AfterInput={AfterInput}
BeforeInput={BeforeInput}
CustomDescription={CustomDescription}
CustomError={CustomError}
CustomLabel={CustomLabel}
autoComplete={autoComplete}
className={className}
errorProps={errorProps}
inputRef={inputRef}
label={label}
labelProps={labelProps}
onChange={(e) => {
setValue(e.target.value)
}}
>
<FieldLabel
CustomLabel={CustomLabel}
label={label}
required={required}
{...(labelProps || {})}
/>
<div className={`${fieldBaseClass}__wrap`}>
<FieldError CustomError={CustomError} path={path} {...(errorProps || {})} />
<input
autoComplete={autoComplete}
disabled={disabled}
id={`field-${path.replace(/\./g, '__')}`}
name={path}
onChange={setValue}
type="password"
value={(value as string) || ''}
/>
</div>
</div>
path={path}
placeholder={placeholder}
readOnly={disabled}
required={required}
rtl={renderRTL}
showError={showError}
style={style}
value={(value as string) || ''}
width={width}
/>
)
}

View File

@@ -0,0 +1,86 @@
'use client'
import type { ChangeEvent } from 'react'
import React from 'react'
import type { PasswordInputProps } from './types.js'
import { FieldError } from '../FieldError/index.js'
import { FieldLabel } from '../FieldLabel/index.js'
import { fieldBaseClass } from '../shared/index.js'
import './index.scss'
export const PasswordInput: React.FC<PasswordInputProps> = (props) => {
const {
AfterInput,
BeforeInput,
CustomDescription,
CustomError,
CustomLabel,
autoComplete = 'off',
className,
errorProps,
inputRef,
label,
labelProps,
onChange,
onKeyDown,
path,
placeholder,
readOnly,
required,
rtl,
showError,
style,
value,
width,
} = props
return (
<div
className={[
fieldBaseClass,
'password',
className,
showError && 'error',
readOnly && 'read-only',
]
.filter(Boolean)
.join(' ')}
style={{
...style,
width,
}}
>
<FieldLabel
CustomLabel={CustomLabel}
htmlFor={`field-${path.replace(/\./g, '__')}`}
label={label}
required={required}
{...(labelProps || {})}
/>
<div className={`${fieldBaseClass}__wrap`}>
<FieldError CustomError={CustomError} path={path} {...(errorProps || {})} />
<div>
{BeforeInput !== undefined && BeforeInput}
<input
aria-label={label}
autoComplete={autoComplete}
data-rtl={rtl}
disabled={readOnly}
id={`field-${path.replace(/\./g, '__')}`}
name={path}
onChange={onChange as (e: ChangeEvent<HTMLInputElement>) => void}
onKeyDown={onKeyDown}
placeholder={placeholder}
ref={inputRef}
type="password"
value={value || ''}
/>
{AfterInput !== undefined && AfterInput}
</div>
{CustomDescription !== undefined && CustomDescription}
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import type { ErrorProps, LabelProps } from 'payload'
import type { ChangeEvent } from 'react'
export type PasswordInputProps = {
AfterInput?: React.ReactElement
BeforeInput?: React.ReactElement
CustomDescription?: React.ReactElement
CustomError?: React.ReactElement
CustomLabel?: React.ReactElement
autoComplete?: string
className?: string
errorProps: ErrorProps
inputRef?: React.RefObject<HTMLInputElement>
label: string
labelProps: LabelProps
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
path: string
placeholder?: string
readOnly?: boolean
required?: boolean
rtl?: boolean
showError?: boolean
style?: React.CSSProperties
value?: string
width?: string
}

View File

@@ -220,6 +220,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
user,
validate,
field?.rows,
field?.valid,
],
)

View File

@@ -1,5 +1,7 @@
import type { I18n } from '@payloadcms/translations'
import type { SanitizedConfig } from 'payload'
import type { Field, SanitizedConfig } from 'payload'
import { confirmPassword, password } from 'payload/shared'
import type { FieldSchemaMap } from './types.js'
@@ -14,6 +16,28 @@ export const buildFieldSchemaMap = (args: {
const result: FieldSchemaMap = new Map()
config.collections.forEach((collection) => {
if (collection.auth && !collection.auth.disableLocalStrategy) {
// register schema with auth schemaPath
const baseAuthFields: Field[] = [
{
name: 'password',
type: 'text',
label: i18n.t('general:password'),
required: true,
validate: password,
},
{
name: 'confirm-password',
type: 'text',
label: i18n.t('authentication:confirmPassword'),
required: true,
validate: confirmPassword,
},
]
result.set(`_${collection.slug}.auth`, [...collection.fields, ...baseAuthFields])
}
traverseFields({
config,
fields: collection.fields,

View File

@@ -194,8 +194,6 @@ export const buildFormState = async ({ req }: { req: PayloadRequest }): Promise<
!req.payload.collections[collectionSlug].config.auth.disableLocalStrategy
) {
if (formState.username) result.username = formState.username
if (formState.password) result.password = formState.password
if (formState['confirm-password']) result['confirm-password'] = formState['confirm-password']
if (formState.email) result.email = formState.email
}
}

View File

@@ -31,6 +31,9 @@ export interface Config {
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
db: {
defaultIDType: string;
};
globals: {
settings: Setting;
test: Test;
@@ -52,26 +55,32 @@ export interface UserAuthOperations {
email: string;
};
login: {
password: string;
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
};
}
export interface NonAdminUserAuthOperations {
forgotPassword: {
email: string;
};
login: {
password: string;
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -13,6 +13,7 @@ import type { Config } from './payload-types.js'
import {
ensureCompilationIsDone,
exactText,
getRoutes,
initPageConsoleErrorCatch,
saveDocAndAssert,
@@ -34,8 +35,6 @@ const headers = {
}
const createFirstUser = async ({
customAdminRoutes,
customRoutes,
page,
serverURL,
}: {
@@ -49,19 +48,35 @@ const createFirstUser = async ({
routes: { createFirstUser: createFirstUserRoute },
},
routes: { admin: adminRoute },
} = getRoutes({
customAdminRoutes,
customRoutes,
})
} = getRoutes({})
// wait for create first user route
await page.goto(serverURL + `${adminRoute}${createFirstUserRoute}`)
// 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.',
)
// make them match, but does not pass password validation
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.',
)
await page.locator('#field-email').fill(devUser.email)
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
@@ -109,7 +124,7 @@ describe('auth', () => {
})
describe('authenticated users', () => {
beforeAll(({ browser }) => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, slug)
})
@@ -118,15 +133,35 @@ describe('auth', () => {
const emailBeforeSave = await page.locator('#field-email').inputValue()
await page.locator('#change-password').click()
await page.locator('#field-password').fill('password')
// should fail to save without confirm password
await page.locator('#action-save').click()
await expect(
page.locator('.field-type.confirm-password .tooltip--show', {
hasText: exactText('This field is required.'),
}),
).toBeVisible()
// should fail to save with incorrect confirm password
await page.locator('#field-confirm-password').fill('wrong password')
await page.locator('#action-save').click()
await expect(
page.locator('.field-type.confirm-password .tooltip--show', {
hasText: exactText('Passwords do not match.'),
}),
).toBeVisible()
// should succeed with matching confirm password
await page.locator('#field-confirm-password').fill('password')
await saveDocAndAssert(page)
await saveDocAndAssert(page, '#action-save')
// should still have the same email
await expect(page.locator('#field-email')).toHaveValue(emailBeforeSave)
})
test('should have up-to-date user in `useAuth` hook', async () => {
await page.goto(url.account)
await page.waitForURL(url.account)
await expect(page.locator('#users-api-result')).toHaveText('Hello, world!')
await expect(page.locator('#users-api-result')).toHaveText('')
await expect(page.locator('#use-auth-result')).toHaveText('Hello, world!')
const field = page.locator('#field-custom')
await field.fill('Goodbye, world!')

View File

@@ -20,6 +20,9 @@ export interface Config {
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
db: {
defaultIDType: string;
};
globals: {};
locale: null;
user:
@@ -38,39 +41,48 @@ export interface UserAuthOperations {
email: string;
};
login: {
password: string;
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
};
}
export interface ApiKeyAuthOperations {
forgotPassword: {
email: string;
};
login: {
password: string;
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
};
}
export interface PublicUserAuthOperations {
forgotPassword: {
email: string;
};
login: {
password: string;
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -207,6 +219,6 @@ export interface Auth {
declare module 'payload' {
// @ts-ignore
// @ts-ignore
export interface GeneratedTypes extends Config {}
}
}

View File

@@ -3,6 +3,7 @@ import type { Payload } from 'payload'
import { fileURLToPath } from 'node:url'
import path from 'path'
import { getFileByPath, mapAsync } from 'payload'
import { wait } from 'payload/shared'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type { Post } from './payload-types.js'
@@ -634,17 +635,16 @@ describe('collections-graphql', () => {
it('should sort find results by nearest distance', async () => {
// creating twice as many records as we are querying to get a random sample
await mapAsync([...Array(10)], () => {
// setTimeout used to randomize the creation timestamp
setTimeout(async () => {
await payload.create({
collection: pointSlug,
data: {
// only randomize longitude to make distance comparison easy
point: [Math.random(), 0],
},
})
}, Math.random())
await mapAsync([...Array(10)], async () => {
// randomize the creation timestamp
await wait(Math.random())
await payload.create({
collection: pointSlug,
data: {
// only randomize longitude to make distance comparison easy
point: [Math.random(), 0],
},
})
})
const nearQuery = `
@@ -1185,7 +1185,7 @@ describe('collections-graphql', () => {
expect(errors[0].message).toEqual('The following field is invalid: password')
expect(errors[0].path[0]).toEqual('test2')
expect(errors[0].extensions.name).toEqual('ValidationError')
expect(errors[0].extensions.data.errors[0].message).toEqual('No password was given')
expect(errors[0].extensions.data.errors[0].message).toEqual('This field is required.')
expect(errors[0].extensions.data.errors[0].field).toEqual('password')
expect(Array.isArray(errors[1].locations)).toEqual(true)