Skip to content

Commit 290ffd3

Browse files
fix: validates password and confirm password on the server (#7410)
Fixes #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.
1 parent 3d89508 commit 290ffd3

File tree

26 files changed

+430
-209
lines changed

26 files changed

+430
-209
lines changed

packages/next/src/views/CreateFirstUser/index.client.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,17 @@ export const CreateFirstUserClient: React.FC<{
3535
const fieldMap = getFieldMap({ collectionSlug: userSlug })
3636

3737
const onChange: FormProps['onChange'][0] = React.useCallback(
38-
async ({ formState: prevFormState }) => {
39-
return getFormState({
38+
async ({ formState: prevFormState }) =>
39+
getFormState({
4040
apiRoute,
4141
body: {
4242
collectionSlug: userSlug,
4343
formState: prevFormState,
4444
operation: 'create',
45-
schemaPath: userSlug,
45+
schemaPath: `_${userSlug}.auth`,
4646
},
4747
serverURL,
48-
})
49-
},
48+
}),
5049
[apiRoute, userSlug, serverURL],
5150
)
5251

@@ -64,14 +63,15 @@ export const CreateFirstUserClient: React.FC<{
6463
<LoginField required={requireEmail} type="email" />
6564
)}
6665
<PasswordField
67-
autoComplete="off"
6866
label={t('authentication:newPassword')}
6967
name="password"
68+
path="password"
7069
required
7170
/>
7271
<ConfirmPasswordField />
7372
<RenderFields
7473
fieldMap={fieldMap}
74+
forceRender
7575
operation="create"
7676
path=""
7777
readOnly={false}

packages/next/src/views/CreateFirstUser/index.tsx

Lines changed: 7 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
import type { AdminViewProps, Field } from 'payload'
1+
import type { AdminViewProps } from 'payload'
22

3-
import { buildStateFromSchema } from '@payloadcms/ui/forms/buildStateFromSchema'
43
import React from 'react'
54

65
import type { LoginFieldProps } from '../Login/LoginField/index.js'
76

7+
import { getDocumentData } from '../Document/getDocumentData.js'
88
import { CreateFirstUserClient } from './index.client.js'
99
import './index.scss'
1010

1111
export { generateCreateFirstUserMetadata } from './meta.js'
1212

1313
export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageResult }) => {
1414
const {
15+
locale,
1516
req,
1617
req: {
1718
payload: {
@@ -26,50 +27,18 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
2627
const collectionConfig = config.collections?.find((collection) => collection?.slug === userSlug)
2728
const { auth: authOptions } = collectionConfig
2829
const loginWithUsername = authOptions.loginWithUsername
29-
const loginWithEmail = !loginWithUsername || loginWithUsername.allowEmailLogin
3030
const emailRequired = loginWithUsername && loginWithUsername.requireEmail
3131

3232
let loginType: LoginFieldProps['type'] = loginWithUsername ? 'username' : 'email'
3333
if (loginWithUsername && (loginWithUsername.allowEmailLogin || loginWithUsername.requireEmail)) {
3434
loginType = 'emailOrUsername'
3535
}
3636

37-
const emailField = {
38-
name: 'email',
39-
type: 'email',
40-
label: req.t('general:emailAddress'),
41-
required: emailRequired ? true : false,
42-
}
43-
44-
const usernameField = {
45-
name: 'username',
46-
type: 'text',
47-
label: req.t('authentication:username'),
48-
required: true,
49-
}
50-
51-
const fields = [
52-
...(loginWithUsername ? [usernameField] : []),
53-
...(emailRequired || loginWithEmail ? [emailField] : []),
54-
{
55-
name: 'password',
56-
type: 'text',
57-
label: req.t('general:password'),
58-
required: true,
59-
},
60-
{
61-
name: 'confirm-password',
62-
type: 'text',
63-
label: req.t('authentication:confirmPassword'),
64-
required: true,
65-
},
66-
]
67-
68-
const formState = await buildStateFromSchema({
69-
fieldSchema: fields as Field[],
70-
operation: 'create',
71-
preferences: { fields: {} },
37+
const { formState } = await getDocumentData({
38+
collectionConfig,
39+
locale,
7240
req,
41+
schemaPath: `_${collectionConfig.slug}.auth`,
7342
})
7443

7544
return (

packages/next/src/views/Document/getDocumentData.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ export const getDocumentData = async (args: {
1515
id?: number | string
1616
locale: Locale
1717
req: PayloadRequest
18+
schemaPath?: string
1819
}): Promise<Data> => {
19-
const { id, collectionConfig, globalConfig, locale, req } = args
20+
const { id, collectionConfig, globalConfig, locale, req, schemaPath: schemaPathFromProps } = args
21+
22+
const schemaPath = schemaPathFromProps || collectionConfig?.slug || globalConfig?.slug
2023

2124
try {
2225
const formState = await buildFormState({
@@ -28,7 +31,7 @@ export const getDocumentData = async (args: {
2831
globalSlug: globalConfig?.slug,
2932
locale: locale?.code,
3033
operation: (collectionConfig && id) || globalConfig ? 'update' : 'create',
31-
schemaPath: collectionConfig?.slug || globalConfig?.slug,
34+
schemaPath,
3235
},
3336
},
3437
})

packages/next/src/views/Edit/Default/Auth/APIKey.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export const APIKey: React.FC<{ enabled: boolean; readOnly?: boolean }> = ({
7878
if (!apiKeyValue && enabled) {
7979
setValue(initialAPIKey)
8080
}
81-
if (!enabled) {
81+
if (!enabled && apiKeyValue) {
8282
setValue(null)
8383
}
8484
}, [apiKeyValue, enabled, setValue, initialAPIKey])
@@ -100,6 +100,7 @@ export const APIKey: React.FC<{ enabled: boolean; readOnly?: boolean }> = ({
100100
<div className={[fieldBaseClass, 'api-key', 'read-only'].filter(Boolean).join(' ')}>
101101
<FieldLabel CustomLabel={APIKeyLabel} htmlFor={path} />
102102
<input
103+
aria-label="API Key"
103104
className={highlightedField ? 'highlight' : undefined}
104105
disabled
105106
id="apiKey"

packages/next/src/views/Edit/Default/Auth/index.tsx

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
useFormModified,
1515
useTranslation,
1616
} from '@payloadcms/ui'
17+
import { email as emailValidation } from 'payload/shared'
1718
import React, { useCallback, useEffect, useMemo, useState } from 'react'
1819
import { toast } from 'sonner'
1920

@@ -34,6 +35,8 @@ export const Auth: React.FC<Props> = (props) => {
3435
operation,
3536
readOnly,
3637
requirePassword,
38+
setSchemaPath,
39+
setValidateBeforeSubmit,
3740
useAPIKey,
3841
username,
3942
verify,
@@ -42,6 +45,7 @@ export const Auth: React.FC<Props> = (props) => {
4245
const { permissions } = useAuth()
4346
const [changingPassword, setChangingPassword] = useState(requirePassword)
4447
const enableAPIKey = useFormFields(([fields]) => (fields && fields?.enableAPIKey) || null)
48+
const forceOpenChangePassword = useFormFields(([fields]) => (fields && fields?.password) || null)
4549
const dispatchFields = useFormFields((reducer) => reducer[1])
4650
const modified = useFormModified()
4751
const { i18n, t } = useTranslation()
@@ -70,15 +74,32 @@ export const Auth: React.FC<Props> = (props) => {
7074
}, [permissions, collectionSlug])
7175

7276
const handleChangePassword = useCallback(
73-
(state: boolean) => {
74-
if (!state) {
77+
(showPasswordFields: boolean) => {
78+
if (showPasswordFields) {
79+
setValidateBeforeSubmit(true)
80+
setSchemaPath(`_${collectionSlug}.auth`)
81+
dispatchFields({
82+
type: 'UPDATE',
83+
errorMessage: t('validation:required'),
84+
path: 'password',
85+
valid: false,
86+
})
87+
dispatchFields({
88+
type: 'UPDATE',
89+
errorMessage: t('validation:required'),
90+
path: 'confirm-password',
91+
valid: false,
92+
})
93+
} else {
94+
setValidateBeforeSubmit(false)
95+
setSchemaPath(collectionSlug)
7596
dispatchFields({ type: 'REMOVE', path: 'password' })
7697
dispatchFields({ type: 'REMOVE', path: 'confirm-password' })
7798
}
7899

79-
setChangingPassword(state)
100+
setChangingPassword(showPasswordFields)
80101
},
81-
[dispatchFields],
102+
[dispatchFields, t, collectionSlug, setSchemaPath, setValidateBeforeSubmit],
82103
)
83104

84105
const unlock = useCallback(async () => {
@@ -99,7 +120,7 @@ export const Auth: React.FC<Props> = (props) => {
99120
} else {
100121
toast.error(t('authentication:failedToUnlock'))
101122
}
102-
}, [i18n, serverURL, api, collectionSlug, email, username, t])
123+
}, [i18n, serverURL, api, collectionSlug, email, username, t, loginWithUsername])
103124

104125
useEffect(() => {
105126
if (!modified) {
@@ -113,6 +134,8 @@ export const Auth: React.FC<Props> = (props) => {
113134

114135
const disabled = readOnly || isInitializing
115136

137+
const showPasswordFields = changingPassword || forceOpenChangePassword
138+
116139
return (
117140
<div className={[baseClass, className].filter(Boolean).join(' ')}>
118141
{!disableLocalStrategy && (
@@ -136,22 +159,33 @@ export const Auth: React.FC<Props> = (props) => {
136159
name="email"
137160
readOnly={readOnly}
138161
required={!loginWithUsername || loginWithUsername?.requireEmail}
162+
validate={(value) =>
163+
emailValidation(value, {
164+
name: 'email',
165+
type: 'email',
166+
data: {},
167+
preferences: { fields: {} },
168+
req: { t } as any,
169+
required: true,
170+
siblingData: {},
171+
})
172+
}
139173
/>
140174
)}
141-
{(changingPassword || requirePassword) && (
175+
{(showPasswordFields || requirePassword) && (
142176
<div className={`${baseClass}__changing-password`}>
143177
<PasswordField
144-
autoComplete="off"
145178
disabled={disabled}
146179
label={t('authentication:newPassword')}
147180
name="password"
181+
path="password"
148182
required
149183
/>
150184
<ConfirmPasswordField disabled={readOnly} />
151185
</div>
152186
)}
153187
<div className={`${baseClass}__controls`}>
154-
{changingPassword && !requirePassword && (
188+
{showPasswordFields && !requirePassword && (
155189
<Button
156190
buttonStyle="secondary"
157191
disabled={disabled}
@@ -161,7 +195,7 @@ export const Auth: React.FC<Props> = (props) => {
161195
{t('general:cancel')}
162196
</Button>
163197
)}
164-
{!changingPassword && !requirePassword && (
198+
{!showPasswordFields && !requirePassword && (
165199
<Button
166200
buttonStyle="secondary"
167201
disabled={disabled}

packages/next/src/views/Edit/Default/Auth/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export type Props = {
99
operation: 'create' | 'update'
1010
readOnly: boolean
1111
requirePassword?: boolean
12+
setSchemaPath: (path: string) => void
13+
setValidateBeforeSubmit: (validate: boolean) => void
1214
useAPIKey?: boolean
1315
username: string
1416
verify?: VerifyConfig | boolean

packages/next/src/views/Edit/Default/index.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '@payloadcms/ui'
1818
import { formatAdminURL, getFormState } from '@payloadcms/ui/shared'
1919
import { useRouter, useSearchParams } from 'next/navigation.js'
20-
import React, { Fragment, useCallback } from 'react'
20+
import React, { Fragment, useCallback, useState } from 'react'
2121

2222
import { LeaveWithoutSaving } from '../../../elements/LeaveWithoutSaving/index.js'
2323
import { Auth } from './Auth/index.js'
@@ -102,6 +102,9 @@ export const DefaultEditView: React.FC = () => {
102102

103103
const classes = [baseClass, id && `${baseClass}--is-editing`].filter(Boolean).join(' ')
104104

105+
const [schemaPath, setSchemaPath] = React.useState(entitySlug)
106+
const [validateBeforeSubmit, setValidateBeforeSubmit] = useState(false)
107+
105108
const onSave = useCallback(
106109
(json) => {
107110
reportUpdate({
@@ -158,7 +161,6 @@ export const DefaultEditView: React.FC = () => {
158161
const onChange: FormProps['onChange'][0] = useCallback(
159162
async ({ formState: prevFormState }) => {
160163
const docPreferences = await getDocPreferences()
161-
162164
return getFormState({
163165
apiRoute,
164166
body: {
@@ -168,12 +170,12 @@ export const DefaultEditView: React.FC = () => {
168170
formState: prevFormState,
169171
globalSlug,
170172
operation,
171-
schemaPath: entitySlug,
173+
schemaPath,
172174
},
173175
serverURL,
174176
})
175177
},
176-
[serverURL, apiRoute, id, operation, entitySlug, collectionSlug, globalSlug, getDocPreferences],
178+
[apiRoute, collectionSlug, schemaPath, getDocPreferences, globalSlug, id, operation, serverURL],
177179
)
178180

179181
return (
@@ -182,7 +184,7 @@ export const DefaultEditView: React.FC = () => {
182184
<Form
183185
action={action}
184186
className={`${baseClass}__form`}
185-
disableValidationOnSubmit
187+
disableValidationOnSubmit={!validateBeforeSubmit}
186188
disabled={isInitializing || !hasSavePermission}
187189
initialState={!isInitializing && initialState}
188190
isInitializing={isInitializing}
@@ -231,6 +233,8 @@ export const DefaultEditView: React.FC = () => {
231233
operation={operation}
232234
readOnly={!hasSavePermission}
233235
requirePassword={!id}
236+
setSchemaPath={setSchemaPath}
237+
setValidateBeforeSubmit={setValidateBeforeSubmit}
234238
useAPIKey={auth.useAPIKey}
235239
username={data?.username}
236240
verify={auth.verify}
@@ -255,7 +259,7 @@ export const DefaultEditView: React.FC = () => {
255259
docPermissions={docPermissions}
256260
fieldMap={fieldMap}
257261
readOnly={!hasSavePermission}
258-
schemaPath={entitySlug}
262+
schemaPath={schemaPath}
259263
/>
260264
{AfterDocument}
261265
</Form>

packages/next/src/views/Login/LoginField/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
1818
autoComplete="email"
1919
label={t('general:email')}
2020
name="email"
21+
path="email"
2122
required={required}
2223
validate={(value) =>
2324
email(value, {
@@ -39,6 +40,7 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
3940
<TextField
4041
label={t('authentication:username')}
4142
name="username"
43+
path="username"
4244
required
4345
validate={(value) =>
4446
username(value, {
@@ -65,6 +67,7 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
6567
<TextField
6668
label={t('authentication:emailOrUsername')}
6769
name="username"
70+
path="username"
6871
required
6972
validate={(value) => {
7073
const passesUsername = username(value, {

0 commit comments

Comments
 (0)