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 fieldMap = getFieldMap({ collectionSlug: userSlug })
const onChange: FormProps['onChange'][0] = React.useCallback( const onChange: FormProps['onChange'][0] = React.useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) =>
return getFormState({ getFormState({
apiRoute, apiRoute,
body: { body: {
collectionSlug: userSlug, collectionSlug: userSlug,
formState: prevFormState, formState: prevFormState,
operation: 'create', operation: 'create',
schemaPath: userSlug, schemaPath: `_${userSlug}.auth`,
}, },
serverURL, serverURL,
}) }),
},
[apiRoute, userSlug, serverURL], [apiRoute, userSlug, serverURL],
) )
@@ -64,14 +63,15 @@ export const CreateFirstUserClient: React.FC<{
<LoginField required={requireEmail} type="email" /> <LoginField required={requireEmail} type="email" />
)} )}
<PasswordField <PasswordField
autoComplete="off"
label={t('authentication:newPassword')} label={t('authentication:newPassword')}
name="password" name="password"
path="password"
required required
/> />
<ConfirmPasswordField /> <ConfirmPasswordField />
<RenderFields <RenderFields
fieldMap={fieldMap} fieldMap={fieldMap}
forceRender
operation="create" operation="create"
path="" path=""
readOnly={false} 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 React from 'react'
import type { LoginFieldProps } from '../Login/LoginField/index.js' import type { LoginFieldProps } from '../Login/LoginField/index.js'
import { getDocumentData } from '../Document/getDocumentData.js'
import { CreateFirstUserClient } from './index.client.js' import { CreateFirstUserClient } from './index.client.js'
import './index.scss' import './index.scss'
@@ -12,6 +12,7 @@ export { generateCreateFirstUserMetadata } from './meta.js'
export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageResult }) => { export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageResult }) => {
const { const {
locale,
req, req,
req: { req: {
payload: { payload: {
@@ -26,7 +27,6 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
const collectionConfig = config.collections?.find((collection) => collection?.slug === userSlug) const collectionConfig = config.collections?.find((collection) => collection?.slug === userSlug)
const { auth: authOptions } = collectionConfig const { auth: authOptions } = collectionConfig
const loginWithUsername = authOptions.loginWithUsername const loginWithUsername = authOptions.loginWithUsername
const loginWithEmail = !loginWithUsername || loginWithUsername.allowEmailLogin
const emailRequired = loginWithUsername && loginWithUsername.requireEmail const emailRequired = loginWithUsername && loginWithUsername.requireEmail
let loginType: LoginFieldProps['type'] = loginWithUsername ? 'username' : 'email' let loginType: LoginFieldProps['type'] = loginWithUsername ? 'username' : 'email'
@@ -34,42 +34,11 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
loginType = 'emailOrUsername' loginType = 'emailOrUsername'
} }
const emailField = { const { formState } = await getDocumentData({
name: 'email', collectionConfig,
type: 'email', locale,
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: {} },
req, req,
schemaPath: `_${collectionConfig.slug}.auth`,
}) })
return ( return (

View File

@@ -15,8 +15,11 @@ export const getDocumentData = async (args: {
id?: number | string id?: number | string
locale: Locale locale: Locale
req: PayloadRequest req: PayloadRequest
schemaPath?: string
}): Promise<Data> => { }): 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 { try {
const formState = await buildFormState({ const formState = await buildFormState({
@@ -28,7 +31,7 @@ export const getDocumentData = async (args: {
globalSlug: globalConfig?.slug, globalSlug: globalConfig?.slug,
locale: locale?.code, locale: locale?.code,
operation: (collectionConfig && id) || globalConfig ? 'update' : 'create', 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) { if (!apiKeyValue && enabled) {
setValue(initialAPIKey) setValue(initialAPIKey)
} }
if (!enabled) { if (!enabled && apiKeyValue) {
setValue(null) setValue(null)
} }
}, [apiKeyValue, enabled, setValue, initialAPIKey]) }, [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(' ')}> <div className={[fieldBaseClass, 'api-key', 'read-only'].filter(Boolean).join(' ')}>
<FieldLabel CustomLabel={APIKeyLabel} htmlFor={path} /> <FieldLabel CustomLabel={APIKeyLabel} htmlFor={path} />
<input <input
aria-label="API Key"
className={highlightedField ? 'highlight' : undefined} className={highlightedField ? 'highlight' : undefined}
disabled disabled
id="apiKey" id="apiKey"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import {
PasswordField, PasswordField,
useAuth, useAuth,
useConfig, useConfig,
useFormFields,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared' import { formatAdminURL } from '@payloadcms/ui/shared'
@@ -64,7 +63,7 @@ export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
toast.success(i18n.t('general:updatedSuccessfully')) toast.success(i18n.t('general:updatedSuccessfully'))
} }
}, },
[fetchFullUser, history, adminRoute, i18n], [adminRoute, fetchFullUser, history, i18n, loginRoute],
) )
return ( return (
@@ -74,42 +73,15 @@ export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
method="POST" method="POST"
onSuccess={onSuccess} onSuccess={onSuccess}
> >
<PasswordToConfirm /> <PasswordField
label={i18n.t('authentication:newPassword')}
name="password"
path="password"
required
/>
<ConfirmPasswordField /> <ConfirmPasswordField />
<HiddenField forceUsePathFromProps name="token" value={token} /> <HiddenField forceUsePathFromProps name="token" value={token} />
<FormSubmit>{i18n.t('authentication:resetPassword')}</FormSubmit> <FormSubmit>{i18n.t('authentication:resetPassword')}</FormSubmit>
</Form> </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({ const { hash, salt } = await generatePasswordSaltHash({
collection: collectionConfig, collection: collectionConfig,
password: data.password, password: data.password,
req,
}) })
user.salt = salt user.salt = salt

View File

@@ -1,15 +1,10 @@
import crypto from 'crypto' import crypto from 'crypto'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { PayloadRequest } from '../../../types/index.js'
import { ValidationError } from '../../../errors/index.js' import { ValidationError } from '../../../errors/index.js'
import { password } from '../../../fields/validations.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
}
function randomBytes(): Promise<Buffer> { function randomBytes(): Promise<Buffer> {
return new Promise((resolve, reject) => return new Promise((resolve, reject) =>
@@ -28,13 +23,23 @@ function pbkdf2Promisified(password: string, salt: string): Promise<Buffer> {
type Args = { type Args = {
collection: SanitizedCollectionConfig collection: SanitizedCollectionConfig
password: string password: string
req: PayloadRequest
} }
export const generatePasswordSaltHash = async ({ export const generatePasswordSaltHash = async ({
collection, collection,
password, password: passwordToSet,
req,
}: Args): Promise<{ hash: string; salt: string }> => { }: 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') { if (typeof validationResult === 'string') {
throw new ValidationError({ throw new ValidationError({
@@ -46,7 +51,7 @@ export const generatePasswordSaltHash = async ({
const saltBuffer = await randomBytes() const saltBuffer = await randomBytes()
const salt = saltBuffer.toString('hex') const salt = saltBuffer.toString('hex')
const hashRaw = await pbkdf2Promisified(password, salt) const hashRaw = await pbkdf2Promisified(passwordToSet, salt)
const hash = hashRaw.toString('hex') const hash = hashRaw.toString('hex')
return { hash, salt } 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 } const sanitizedDoc = { ...doc }
if (sanitizedDoc.password) delete sanitizedDoc.password if (sanitizedDoc.password) delete sanitizedDoc.password

View File

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

View File

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

View File

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

View File

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

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, user,
validate, validate,
field?.rows, field?.rows,
field?.valid,
], ],
) )

View File

@@ -1,5 +1,7 @@
import type { I18n } from '@payloadcms/translations' 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' import type { FieldSchemaMap } from './types.js'
@@ -14,6 +16,28 @@ export const buildFieldSchemaMap = (args: {
const result: FieldSchemaMap = new Map() const result: FieldSchemaMap = new Map()
config.collections.forEach((collection) => { 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({ traverseFields({
config, config,
fields: collection.fields, fields: collection.fields,

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import type { Config } from './payload-types.js'
import { import {
ensureCompilationIsDone, ensureCompilationIsDone,
exactText,
getRoutes, getRoutes,
initPageConsoleErrorCatch, initPageConsoleErrorCatch,
saveDocAndAssert, saveDocAndAssert,
@@ -34,8 +35,6 @@ const headers = {
} }
const createFirstUser = async ({ const createFirstUser = async ({
customAdminRoutes,
customRoutes,
page, page,
serverURL, serverURL,
}: { }: {
@@ -49,19 +48,35 @@ const createFirstUser = async ({
routes: { createFirstUser: createFirstUserRoute }, routes: { createFirstUser: createFirstUserRoute },
}, },
routes: { admin: adminRoute }, routes: { admin: adminRoute },
} = getRoutes({ } = getRoutes({})
customAdminRoutes,
customRoutes,
})
// wait for create first user route
await page.goto(serverURL + `${adminRoute}${createFirstUserRoute}`) 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-email').fill(devUser.email)
await page.locator('#field-password').fill(devUser.password) await page.locator('#field-password').fill(devUser.password)
await page.locator('#field-confirm-password').fill(devUser.password) await page.locator('#field-confirm-password').fill(devUser.password)
await page.locator('#field-custom').fill('Hello, world!') await page.locator('#field-custom').fill('Hello, world!')
await wait(500) await wait(500)
await page.locator('.form-submit > button').click() await page.locator('.form-submit > button').click()
await expect await expect
@@ -109,7 +124,7 @@ describe('auth', () => {
}) })
describe('authenticated users', () => { describe('authenticated users', () => {
beforeAll(({ browser }) => { beforeAll(() => {
url = new AdminUrlUtil(serverURL, slug) url = new AdminUrlUtil(serverURL, slug)
}) })
@@ -118,15 +133,35 @@ describe('auth', () => {
const emailBeforeSave = await page.locator('#field-email').inputValue() const emailBeforeSave = await page.locator('#field-email').inputValue()
await page.locator('#change-password').click() await page.locator('#change-password').click()
await page.locator('#field-password').fill('password') 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 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) await expect(page.locator('#field-email')).toHaveValue(emailBeforeSave)
}) })
test('should have up-to-date user in `useAuth` hook', async () => { test('should have up-to-date user in `useAuth` hook', async () => {
await page.goto(url.account) await page.goto(url.account)
await page.waitForURL(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!') await expect(page.locator('#use-auth-result')).toHaveText('Hello, world!')
const field = page.locator('#field-custom') const field = page.locator('#field-custom')
await field.fill('Goodbye, world!') await field.fill('Goodbye, world!')

View File

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

View File

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