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:
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
[validate, required],
|
||||
t,
|
||||
} as PayloadRequest,
|
||||
required: true,
|
||||
siblingData: {},
|
||||
})
|
||||
},
|
||||
[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,
|
||||
}}
|
||||
>
|
||||
<FieldLabel
|
||||
<PasswordInput
|
||||
AfterInput={AfterInput}
|
||||
BeforeInput={BeforeInput}
|
||||
CustomDescription={CustomDescription}
|
||||
CustomError={CustomError}
|
||||
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"
|
||||
className={className}
|
||||
errorProps={errorProps}
|
||||
inputRef={inputRef}
|
||||
label={label}
|
||||
labelProps={labelProps}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
}}
|
||||
path={path}
|
||||
placeholder={placeholder}
|
||||
readOnly={disabled}
|
||||
required={required}
|
||||
rtl={renderRTL}
|
||||
showError={showError}
|
||||
style={style}
|
||||
value={(value as string) || ''}
|
||||
width={width}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
86
packages/ui/src/fields/Password/input.tsx
Normal file
86
packages/ui/src/fields/Password/input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
packages/ui/src/fields/Password/types.ts
Normal file
26
packages/ui/src/fields/Password/types.ts
Normal 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
|
||||
}
|
||||
@@ -220,6 +220,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
|
||||
user,
|
||||
validate,
|
||||
field?.rows,
|
||||
field?.valid,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,9 +635,9 @@ 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 mapAsync([...Array(10)], async () => {
|
||||
// randomize the creation timestamp
|
||||
await wait(Math.random())
|
||||
await payload.create({
|
||||
collection: pointSlug,
|
||||
data: {
|
||||
@@ -644,7 +645,6 @@ describe('collections-graphql', () => {
|
||||
point: [Math.random(), 0],
|
||||
},
|
||||
})
|
||||
}, Math.random())
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user