fix: misc issues with loginWithUsername (#7311)

- improves types
- fixes create-first-user fields
This commit is contained in:
Jessica Chowdhury
2024-07-23 20:14:12 +01:00
committed by GitHub
parent c405e5958f
commit b2814eb67c
13 changed files with 233 additions and 144 deletions

View File

@@ -348,15 +348,19 @@ function initCollectionsGraphQL({ config, graphqlResult }: InitCollectionsGraphQ
}
if (collectionConfig.auth) {
const authFields: Field[] = collectionConfig.auth.disableLocalStrategy
? []
: [
{
name: 'email',
type: 'email',
required: true,
},
]
const authFields: Field[] =
collectionConfig.auth.disableLocalStrategy ||
(collectionConfig.auth.loginWithUsername &&
!collectionConfig.auth.loginWithUsername.allowEmailLogin &&
!collectionConfig.auth.loginWithUsername.requireEmail)
? []
: [
{
name: 'email',
type: 'email',
required: true,
},
]
collection.graphQL.JWT = buildObjectType({
name: formatName(`${slug}JWT`),
config,

View File

@@ -9,16 +9,17 @@ import { headersWithCors } from '../../../utilities/headersWithCors.js'
export const login: CollectionRouteHandler = async ({ collection, req }) => {
const { searchParams, t } = req
const depth = searchParams.get('depth')
const authData = collection.config.auth?.loginWithUsername
? {
email: typeof req.data?.email === 'string' ? req.data.email : '',
password: typeof req.data?.password === 'string' ? req.data.password : '',
username: typeof req.data?.username === 'string' ? req.data.username : '',
}
: {
email: typeof req.data?.email === 'string' ? req.data.email : '',
password: typeof req.data?.password === 'string' ? req.data.password : '',
}
const authData =
collection.config.auth?.loginWithUsername !== false
? {
email: typeof req.data?.email === 'string' ? req.data.email : '',
password: typeof req.data?.password === 'string' ? req.data.password : '',
username: typeof req.data?.username === 'string' ? req.data.username : '',
}
: {
email: typeof req.data?.email === 'string' ? req.data.email : '',
password: typeof req.data?.password === 'string' ? req.data.password : '',
}
const result = await loginOperation({
collection,

View File

@@ -8,14 +8,15 @@ import { headersWithCors } from '../../../utilities/headersWithCors.js'
export const unlock: CollectionRouteHandler = async ({ collection, req }) => {
const { t } = req
const authData = collection.config.auth?.loginWithUsername
? {
email: typeof req.data?.email === 'string' ? req.data.email : '',
username: typeof req.data?.username === 'string' ? req.data.username : '',
}
: {
email: typeof req.data?.email === 'string' ? req.data.email : '',
}
const authData =
collection.config.auth?.loginWithUsername !== false
? {
email: typeof req.data?.email === 'string' ? req.data.email : '',
username: typeof req.data?.username === 'string' ? req.data.username : '',
}
: {
email: typeof req.data?.email === 'string' ? req.data.email : '',
}
await unlockOperation({
collection,

View File

@@ -3,7 +3,6 @@ import type { FormState } from 'payload'
import {
ConfirmPasswordField,
EmailField,
Form,
type FormProps,
FormSubmit,
@@ -16,10 +15,14 @@ import {
import { getFormState } from '@payloadcms/ui/shared'
import React from 'react'
import { LoginField } from '../Login/LoginField/index.js'
export const CreateFirstUserClient: React.FC<{
initialState: FormState
loginType: 'email' | 'emailOrUsername' | 'username'
requireEmail?: boolean
userSlug: string
}> = ({ initialState, userSlug }) => {
}> = ({ initialState, loginType, requireEmail = true, userSlug }) => {
const { getFieldMap } = useComponentMap()
const {
@@ -56,7 +59,10 @@ export const CreateFirstUserClient: React.FC<{
redirect={admin}
validationOperation="create"
>
<EmailField autoComplete="email" label={t('general:email')} name="email" required />
{['emailOrUsername', 'username'].includes(loginType) && <LoginField type="username" />}
{['email', 'emailOrUsername'].includes(loginType) && (
<LoginField required={requireEmail} type="email" />
)}
<PasswordField
autoComplete="off"
label={t('authentication:newPassword')}

View File

@@ -3,6 +3,8 @@ import type { AdminViewProps, Field } from 'payload'
import { buildStateFromSchema } from '@payloadcms/ui/forms/buildStateFromSchema'
import React from 'react'
import type { LoginFieldProps } from '../Login/LoginField/index.js'
import { CreateFirstUserClient } from './index.client.js'
import './index.scss'
@@ -16,17 +18,39 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
config: {
admin: { user: userSlug },
},
config,
},
},
} = initPageResult
const fields: Field[] = [
{
name: 'email',
type: 'email',
label: req.t('general:emailAddress'),
required: true,
},
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'
if (loginWithUsername && (loginWithUsername.allowEmailLogin || loginWithUsername.requireEmail)) {
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',
@@ -42,7 +66,7 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
]
const formState = await buildStateFromSchema({
fieldSchema: fields,
fieldSchema: fields as Field[],
operation: 'create',
preferences: { fields: {} },
req,
@@ -52,7 +76,12 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
<div className="create-first-user">
<h1>{req.t('general:welcome')}</h1>
<p>{req.t('authentication:beginCreateFirstUser')}</p>
<CreateFirstUserClient initialState={formState} userSlug={userSlug} />
<CreateFirstUserClient
initialState={formState}
loginType={loginType}
requireEmail={emailRequired}
userSlug={userSlug}
/>
</div>
)
}

View File

@@ -34,6 +34,7 @@ export const Auth: React.FC<Props> = (props) => {
readOnly,
requirePassword,
useAPIKey,
username,
verify,
} = props
@@ -64,9 +65,8 @@ export const Auth: React.FC<Props> = (props) => {
const unlock = useCallback(async () => {
const url = `${serverURL}${api}/${collectionSlug}/unlock`
const response = await fetch(url, {
body: JSON.stringify({
email,
}),
body:
loginWithUsername && username ? JSON.stringify({ username }) : JSON.stringify({ email }),
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
@@ -80,7 +80,7 @@ export const Auth: React.FC<Props> = (props) => {
} else {
toast.error(t('authentication:failedToUnlock'))
}
}, [i18n, serverURL, api, collectionSlug, email, t])
}, [i18n, serverURL, api, collectionSlug, email, username, t])
useEffect(() => {
if (!modified) {
@@ -98,6 +98,15 @@ export const Auth: React.FC<Props> = (props) => {
<div className={[baseClass, className].filter(Boolean).join(' ')}>
{!disableLocalStrategy && (
<React.Fragment>
{Boolean(loginWithUsername) && (
<TextField
disabled={disabled}
label={t('authentication:username')}
name="username"
readOnly={readOnly}
required
/>
)}
{(!loginWithUsername ||
loginWithUsername?.allowEmailLogin ||
loginWithUsername?.requireEmail) && (
@@ -110,15 +119,6 @@ export const Auth: React.FC<Props> = (props) => {
required={!loginWithUsername || loginWithUsername?.requireEmail}
/>
)}
{loginWithUsername && (
<TextField
disabled={disabled}
label={t('authentication:username')}
name="username"
readOnly={readOnly}
required
/>
)}
{(changingPassword || requirePassword) && (
<div className={`${baseClass}__changing-password`}>
<PasswordField

View File

@@ -10,5 +10,6 @@ export type Props = {
readOnly: boolean
requirePassword?: boolean
useAPIKey?: boolean
username: string
verify?: VerifyConfig | boolean
}

View File

@@ -232,6 +232,7 @@ export const DefaultEditView: React.FC = () => {
readOnly={!hasSavePermission}
requirePassword={!id}
useAPIKey={auth.useAPIKey}
username={data?.username}
verify={auth.verify}
/>
)}

View File

@@ -5,9 +5,10 @@ import { EmailField, TextField, useConfig, useTranslation } from '@payloadcms/ui
import { email, username } from 'payload/shared'
import React from 'react'
export type LoginFieldProps = {
required?: boolean
type: 'email' | 'emailOrUsername' | 'username'
}
export const LoginField: React.FC<LoginFieldProps> = ({ type }) => {
export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true }) => {
const { t } = useTranslation()
const config = useConfig()
@@ -17,7 +18,7 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type }) => {
autoComplete="email"
label={t('general:email')}
name="email"
required
required={required}
validate={(value) =>
email(value, {
name: 'email',

View File

@@ -7,7 +7,7 @@ export const emailField = ({ required = true }: { required?: boolean }): Field =
type: 'email',
admin: {
components: {
Field: required ? () => null : undefined,
Field: () => null,
},
},
hooks: {

View File

@@ -612,65 +612,111 @@ const fieldType: JSONSchema4 = {
type: 'string',
required: false,
}
const generateAuthFieldTypes = (
loginWithUsername: Auth['loginWithUsername'],
withPassword = false,
): JSONSchema4 => {
const passwordField = {
password: fieldType,
const generateAuthFieldTypes = ({
type,
loginWithUsername,
}: {
loginWithUsername: Auth['loginWithUsername']
type: 'forgotOrUnlock' | 'login' | 'register'
}): JSONSchema4 => {
const emailAuthFields = {
additionalProperties: false,
properties: { email: fieldType },
required: ['email'],
}
const usernameAuthFields = {
additionalProperties: false,
properties: { username: fieldType },
required: ['username'],
}
if (['login', 'register'].includes(type)) {
emailAuthFields.properties['password'] = fieldType
emailAuthFields.required.push('password')
usernameAuthFields.properties['password'] = fieldType
usernameAuthFields.required.push('password')
}
if (loginWithUsername) {
if (loginWithUsername.allowEmailLogin) {
return {
additionalProperties: false,
oneOf: [
{
switch (type) {
case 'login': {
if (loginWithUsername.allowEmailLogin) {
// allow username or email and require password for login
return {
additionalProperties: false,
properties: { email: fieldType, ...(withPassword ? { password: fieldType } : {}) },
required: ['email', ...(withPassword ? ['password'] : [])],
},
{
oneOf: [emailAuthFields, usernameAuthFields],
}
} else {
// allow only username and password for login
return usernameAuthFields
}
}
case 'register': {
if (loginWithUsername.requireEmail) {
// require username, email and password for registration
return {
additionalProperties: false,
properties: { username: fieldType, ...(withPassword ? { password: fieldType } : {}) },
required: ['username', ...(withPassword ? ['password'] : [])],
},
],
properties: {
...usernameAuthFields.properties,
...emailAuthFields.properties,
},
required: [...usernameAuthFields.required, ...emailAuthFields.required],
}
} else if (loginWithUsername.allowEmailLogin) {
// allow both but only require username for registration
return {
additionalProperties: false,
properties: {
...usernameAuthFields.properties,
...emailAuthFields.properties,
},
required: usernameAuthFields.required,
}
} else {
// require only username and password for registration
return usernameAuthFields
}
}
case 'forgotOrUnlock': {
if (loginWithUsername.allowEmailLogin) {
// allow email or username for unlock/forgot-password
return {
additionalProperties: false,
oneOf: [emailAuthFields, usernameAuthFields],
}
} else {
// allow only username for unlock/forgot-password
return usernameAuthFields
}
}
}
return {
additionalProperties: false,
properties: {
username: fieldType,
...(withPassword ? { password: fieldType } : {}),
},
required: ['username', ...(withPassword ? ['password'] : [])],
}
}
return {
additionalProperties: false,
properties: {
email: fieldType,
...(withPassword ? { password: fieldType } : {}),
},
required: ['email', ...(withPassword ? ['password'] : [])],
}
// default email (and password for login/register)
return emailAuthFields
}
export function authCollectionToOperationsJSONSchema(
config: SanitizedCollectionConfig,
): JSONSchema4 {
const loginWithUsername = config.auth?.loginWithUsername
const generatedFields: JSONSchema4 = generateAuthFieldTypes(loginWithUsername)
const generatedFieldsWithPassword: JSONSchema4 = generateAuthFieldTypes(loginWithUsername, true)
const loginUserFields: JSONSchema4 = generateAuthFieldTypes({ type: 'login', loginWithUsername })
const forgotOrUnlockUserFields: JSONSchema4 = generateAuthFieldTypes({
type: 'forgotOrUnlock',
loginWithUsername,
})
const registerUserFields: JSONSchema4 = generateAuthFieldTypes({
type: 'register',
loginWithUsername,
})
const properties: JSONSchema4['properties'] = {
forgotPassword: generatedFields,
login: generatedFieldsWithPassword,
registerFirstUser: generatedFieldsWithPassword,
unlock: generatedFields,
forgotPassword: forgotOrUnlockUserFields,
login: loginUserFields,
registerFirstUser: registerUserFields,
unlock: forgotOrUnlockUserFields,
}
return {

View File

@@ -39,55 +39,53 @@ export const DocumentFields: React.FC<Args> = ({
const hasSidebarFields = sidebarFields && sidebarFields.length > 0
return (
<React.Fragment>
<div
className={[
baseClass,
hasSidebarFields ? `${baseClass}--has-sidebar` : `${baseClass}--no-sidebar`,
forceSidebarWrap && `${baseClass}--force-sidebar-wrap`,
]
.filter(Boolean)
.join(' ')}
>
<div className={`${baseClass}__main`}>
<Gutter className={`${baseClass}__edit`}>
<header className={`${baseClass}__header`}>
{description && (
<div className={`${baseClass}__sub-header`}>
{/* <ViewDescription description={description} /> */}
</div>
)}
</header>
{BeforeFields}
<RenderFields
className={`${baseClass}__fields`}
fieldMap={mainFields}
forceRender={10}
path=""
permissions={docPermissions?.fields}
readOnly={readOnly}
schemaPath={schemaPath}
/>
{AfterFields}
</Gutter>
</div>
{hasSidebarFields && (
<div className={`${baseClass}__sidebar-wrap`}>
<div className={`${baseClass}__sidebar`}>
<div className={`${baseClass}__sidebar-fields`}>
<RenderFields
fieldMap={sidebarFields}
forceRender={10}
path=""
permissions={docPermissions?.fields}
readOnly={readOnly}
schemaPath={schemaPath}
/>
<div
className={[
baseClass,
hasSidebarFields ? `${baseClass}--has-sidebar` : `${baseClass}--no-sidebar`,
forceSidebarWrap && `${baseClass}--force-sidebar-wrap`,
]
.filter(Boolean)
.join(' ')}
>
<div className={`${baseClass}__main`}>
<Gutter className={`${baseClass}__edit`}>
<header className={`${baseClass}__header`}>
{description && (
<div className={`${baseClass}__sub-header`}>
{/* <ViewDescription description={description} /> */}
</div>
)}
</header>
{BeforeFields}
<RenderFields
className={`${baseClass}__fields`}
fieldMap={mainFields}
forceRender={10}
path=""
permissions={docPermissions?.fields}
readOnly={readOnly}
schemaPath={schemaPath}
/>
{AfterFields}
</Gutter>
</div>
{hasSidebarFields && (
<div className={`${baseClass}__sidebar-wrap`}>
<div className={`${baseClass}__sidebar`}>
<div className={`${baseClass}__sidebar-fields`}>
<RenderFields
fieldMap={sidebarFields}
forceRender={10}
path=""
permissions={docPermissions?.fields}
readOnly={readOnly}
schemaPath={schemaPath}
/>
</div>
</div>
)}
</div>
</React.Fragment>
</div>
)}
</div>
)
}

View File

@@ -193,6 +193,7 @@ export const buildFormState = async ({ req }: { req: PayloadRequest }): Promise<
req.payload.collections[collectionSlug]?.config?.auth &&
!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