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 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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
[validate, required],
|
t,
|
||||||
|
} as PayloadRequest,
|
||||||
|
required: true,
|
||||||
|
siblingData: {},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[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',
|
|
||||||
disabled && 'read-only',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
style={{
|
|
||||||
...style,
|
|
||||||
width,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FieldLabel
|
|
||||||
CustomLabel={CustomLabel}
|
CustomLabel={CustomLabel}
|
||||||
label={label}
|
|
||||||
required={required}
|
|
||||||
{...(labelProps || {})}
|
|
||||||
/>
|
|
||||||
<div className={`${fieldBaseClass}__wrap`}>
|
|
||||||
<FieldError CustomError={CustomError} path={path} {...(errorProps || {})} />
|
|
||||||
<input
|
|
||||||
autoComplete={autoComplete}
|
autoComplete={autoComplete}
|
||||||
disabled={disabled}
|
className={className}
|
||||||
id={`field-${path.replace(/\./g, '__')}`}
|
errorProps={errorProps}
|
||||||
name={path}
|
inputRef={inputRef}
|
||||||
onChange={setValue}
|
label={label}
|
||||||
type="password"
|
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) || ''}
|
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,
|
user,
|
||||||
validate,
|
validate,
|
||||||
field?.rows,
|
field?.rows,
|
||||||
|
field?.valid,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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!')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,9 +635,9 @@ 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: {
|
||||||
@@ -644,7 +645,6 @@ describe('collections-graphql', () => {
|
|||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user