Skip to content

Commit 7b3b021

Browse files
feat: ability to login with email, username or both (#7086)
`auth.loginWithUsername`: ```ts auth: { loginWithUsername: { allowEmailLogin: true, // default: false requireEmail: false, // default: false } } ``` #### `allowEmailLogin` This property will allow you to determine if users should be able to login with either email or username. If set to `false`, the default value, then users will only be able to login with usernames when using the `loginWithUsername` property. #### `requireEmail` Require that users also provide emails when using usernames.
1 parent a3af360 commit 7b3b021

File tree

67 files changed

+1676
-1126
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+1676
-1126
lines changed

docs/authentication/overview.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,50 @@ The following options are available:
8383
| **`disableLocalStrategy`** | Advanced - disable Payload's built-in local auth strategy. Only use this property if you have replaced Payload's auth mechanisms with your own. |
8484
| **`forgotPassword`** | Customize the way that the `forgotPassword` operation functions. [More details](./email#forgot-password). |
8585
| **`lockTime`** | Set the time (in milliseconds) that a user should be locked out if they fail authentication more times than `maxLoginAttempts` allows for. |
86+
| **`loginWithUsername`** | Ability to allow users to login with username/password. [More](/docs/authentication/overview#login-with-username) |
8687
| **`maxLoginAttempts`** | Only allow a user to attempt logging in X amount of times. Automatically locks out a user from authenticating if this limit is passed. Set to `0` to disable. |
8788
| **`strategies`** | Advanced - an array of custom authentification strategies to extend this collection's authentication with. [More details](./custom-strategies). |
8889
| **`tokenExpiration`** | How long (in seconds) to keep the user logged in. JWTs and HTTP-only cookies will both expire at the same time. |
8990
| **`useAPIKey`** | Payload Authentication provides for API keys to be set on each user within an Authentication-enabled Collection. [More details](./api-keys). |
9091
| **`verify`** | Set to `true` or pass an object with verification options to require users to verify by email before they are allowed to log into your app. [More details](./email#email-verification). |
9192

93+
### Login With Username
94+
95+
You can allow users to login with their username instead of their email address by setting the `loginWithUsername` property to `true`.
96+
97+
Example:
98+
99+
```ts
100+
{
101+
slug: 'customers',
102+
auth: {
103+
loginWithUsername: true,
104+
},
105+
}
106+
```
107+
108+
Or, you can pass an object with additional options:
109+
110+
```ts
111+
{
112+
slug: 'customers',
113+
auth: {
114+
loginWithUsername: {
115+
allowEmailLogin: true, // default: false
116+
requireEmail: false, // default: false
117+
},
118+
},
119+
}
120+
```
121+
122+
**`allowEmailLogin`**
123+
124+
If set to `true`, users can log in with either their username or email address. If set to `false`, users can only log in with their username.
125+
126+
**`requireEmail`**
127+
128+
If set to `true`, an email address is required when creating a new user. If set to `false`, email is not required upon creation.
129+
92130
## Admin Auto-Login
93131

94132
For testing and demo purposes you may want to skip forcing the admin user to login in order to access the [Admin Panel](../admin/overview). Typically, all users should be required to login to access the Admin Panel, however, you can speed up local development time by enabling auto-login.

packages/graphql/src/resolvers/auth/unlock.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ function unlockResolver(collection: Collection) {
88
async function resolver(_, args, context: Context) {
99
const options = {
1010
collection,
11-
data: { email: args.email },
11+
data: { email: args.email, username: args.username },
1212
req: isolateObjectProperty(context.req, 'transactionID'),
1313
}
1414

packages/graphql/src/schema/initCollections.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -429,12 +429,24 @@ function initCollectionsGraphQL({ config, graphqlResult }: InitCollectionsGraphQ
429429
}
430430

431431
if (!collectionConfig.auth.disableLocalStrategy) {
432+
const authArgs = {}
433+
434+
const canLoginWithEmail =
435+
!collectionConfig.auth.loginWithUsername ||
436+
collectionConfig.auth.loginWithUsername?.allowEmailLogin
437+
const canLoginWithUsername = collectionConfig.auth.loginWithUsername
438+
439+
if (canLoginWithEmail) {
440+
authArgs['email'] = { type: new GraphQLNonNull(GraphQLString) }
441+
}
442+
if (canLoginWithUsername) {
443+
authArgs['username'] = { type: new GraphQLNonNull(GraphQLString) }
444+
}
445+
432446
if (collectionConfig.auth.maxLoginAttempts > 0) {
433447
graphqlResult.Mutation.fields[`unlock${singularName}`] = {
434448
type: new GraphQLNonNull(GraphQLBoolean),
435-
args: {
436-
email: { type: new GraphQLNonNull(GraphQLString) },
437-
},
449+
args: authArgs,
438450
resolve: unlock(collection),
439451
}
440452
}
@@ -455,9 +467,8 @@ function initCollectionsGraphQL({ config, graphqlResult }: InitCollectionsGraphQ
455467
},
456468
}),
457469
args: {
458-
email: { type: GraphQLString },
470+
...authArgs,
459471
password: { type: GraphQLString },
460-
username: { type: GraphQLString },
461472
},
462473
resolve: login(collection),
463474
}
@@ -466,8 +477,8 @@ function initCollectionsGraphQL({ config, graphqlResult }: InitCollectionsGraphQ
466477
type: new GraphQLNonNull(GraphQLBoolean),
467478
args: {
468479
disableEmail: { type: GraphQLBoolean },
469-
email: { type: new GraphQLNonNull(GraphQLString) },
470480
expiration: { type: GraphQLInt },
481+
...authArgs,
471482
},
472483
resolve: forgotPassword(collection),
473484
}

packages/next/src/routes/rest/auth/unlock.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,18 @@ import { headersWithCors } from '../../../utilities/headersWithCors.js'
88
export const unlock: CollectionRouteHandler = async ({ collection, req }) => {
99
const { t } = req
1010

11+
const authData = collection.config.auth?.loginWithUsername
12+
? {
13+
email: typeof req.data?.email === 'string' ? req.data.email : '',
14+
username: typeof req.data?.username === 'string' ? req.data.username : '',
15+
}
16+
: {
17+
email: typeof req.data?.email === 'string' ? req.data.email : '',
18+
}
19+
1120
await unlockOperation({
1221
collection,
13-
data: { email: req.data.email as string },
22+
data: authData,
1423
req,
1524
})
1625

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,16 @@ export const Auth: React.FC<Props> = (props) => {
9898
<div className={[baseClass, className].filter(Boolean).join(' ')}>
9999
{!disableLocalStrategy && (
100100
<React.Fragment>
101-
{!loginWithUsername && (
101+
{(!loginWithUsername ||
102+
loginWithUsername?.allowEmailLogin ||
103+
loginWithUsername?.requireEmail) && (
102104
<EmailField
103105
autoComplete="email"
104106
disabled={disabled}
105107
label={t('general:email')}
106108
name="email"
107109
readOnly={readOnly}
108-
required
110+
required={!loginWithUsername || loginWithUsername?.requireEmail}
109111
/>
110112
)}
111113
{loginWithUsername && (

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export type Props = {
55
collectionSlug: SanitizedCollectionConfig['slug']
66
disableLocalStrategy?: boolean
77
email: string
8-
loginWithUsername: boolean
8+
loginWithUsername: SanitizedCollectionConfig['auth']['loginWithUsername']
99
operation: 'create' | 'update'
1010
readOnly: boolean
1111
requirePassword?: boolean
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'use client'
2+
import type { PayloadRequest } from 'payload'
3+
4+
import { EmailField, TextField, useConfig, useTranslation } from '@payloadcms/ui'
5+
import { email, username } from 'payload/shared'
6+
import React from 'react'
7+
export type LoginFieldProps = {
8+
type: 'email' | 'emailOrUsername' | 'username'
9+
}
10+
export const LoginField: React.FC<LoginFieldProps> = ({ type }) => {
11+
const { t } = useTranslation()
12+
const config = useConfig()
13+
14+
if (type === 'email') {
15+
return (
16+
<EmailField
17+
autoComplete="email"
18+
label={t('general:email')}
19+
name="email"
20+
required
21+
validate={(value) =>
22+
email(value, {
23+
name: 'email',
24+
type: 'email',
25+
data: {},
26+
preferences: { fields: {} },
27+
req: { t } as PayloadRequest,
28+
required: true,
29+
siblingData: {},
30+
})
31+
}
32+
/>
33+
)
34+
}
35+
36+
if (type === 'username') {
37+
return (
38+
<TextField
39+
label={t('authentication:username')}
40+
name="username"
41+
required
42+
validate={(value) =>
43+
username(value, {
44+
name: 'username',
45+
type: 'text',
46+
data: {},
47+
preferences: { fields: {} },
48+
req: {
49+
payload: {
50+
config,
51+
},
52+
t,
53+
} as PayloadRequest,
54+
required: true,
55+
siblingData: {},
56+
})
57+
}
58+
/>
59+
)
60+
}
61+
62+
if (type === 'emailOrUsername') {
63+
return (
64+
<TextField
65+
label={t('authentication:emailOrUsername')}
66+
name="username"
67+
required
68+
validate={(value) => {
69+
const passesUsername = username(value, {
70+
name: 'username',
71+
type: 'text',
72+
data: {},
73+
preferences: { fields: {} },
74+
req: {
75+
payload: {
76+
config,
77+
},
78+
t,
79+
} as PayloadRequest,
80+
required: true,
81+
siblingData: {},
82+
})
83+
const passesEmail = email(value, {
84+
name: 'username',
85+
type: 'email',
86+
data: {},
87+
preferences: { fields: {} },
88+
req: {
89+
payload: {
90+
config,
91+
},
92+
t,
93+
} as PayloadRequest,
94+
required: true,
95+
siblingData: {},
96+
})
97+
98+
if (!passesEmail && !passesUsername) {
99+
return `${t('general:email')}: ${passesEmail} ${t('general:username')}: ${passesUsername}`
100+
}
101+
102+
return true
103+
}}
104+
/>
105+
)
106+
}
107+
108+
return null
109+
}

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

Lines changed: 17 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,12 @@ const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.
88

99
import type { FormState, PayloadRequest } from 'payload'
1010

11-
import {
12-
EmailField,
13-
Form,
14-
FormSubmit,
15-
PasswordField,
16-
TextField,
17-
useConfig,
18-
useTranslation,
19-
} from '@payloadcms/ui'
20-
import { email, password, text } from 'payload/shared'
11+
import { Form, FormSubmit, PasswordField, useConfig, useTranslation } from '@payloadcms/ui'
12+
import { password } from 'payload/shared'
2113

14+
import type { LoginFieldProps } from '../LoginField/index.js'
15+
16+
import { LoginField } from '../LoginField/index.js'
2217
import './index.scss'
2318

2419
export const LoginForm: React.FC<{
@@ -36,7 +31,17 @@ export const LoginForm: React.FC<{
3631
} = config
3732

3833
const collectionConfig = config.collections?.find((collection) => collection?.slug === userSlug)
39-
const loginWithUsername = collectionConfig?.auth?.loginWithUsername
34+
const { auth: authOptions } = collectionConfig
35+
const loginWithUsername = authOptions.loginWithUsername
36+
const canLoginWithEmail =
37+
!authOptions.loginWithUsername || authOptions.loginWithUsername.allowEmailLogin
38+
const canLoginWithUsername = authOptions.loginWithUsername
39+
40+
const [loginType] = React.useState<LoginFieldProps['type']>(() => {
41+
if (canLoginWithEmail && canLoginWithUsername) return 'emailOrUsername'
42+
if (canLoginWithUsername) return 'username'
43+
return 'email'
44+
})
4045

4146
const { t } = useTranslation()
4247

@@ -75,47 +80,7 @@ export const LoginForm: React.FC<{
7580
waitForAutocomplete
7681
>
7782
<div className={`${baseClass}__inputWrap`}>
78-
{loginWithUsername ? (
79-
<TextField
80-
label={t('authentication:username')}
81-
name="username"
82-
required
83-
validate={(value) =>
84-
text(value, {
85-
name: 'username',
86-
type: 'text',
87-
data: {},
88-
preferences: { fields: {} },
89-
req: {
90-
payload: {
91-
config,
92-
},
93-
t,
94-
} as PayloadRequest,
95-
required: true,
96-
siblingData: {},
97-
})
98-
}
99-
/>
100-
) : (
101-
<EmailField
102-
autoComplete="email"
103-
label={t('general:email')}
104-
name="email"
105-
required
106-
validate={(value) =>
107-
email(value, {
108-
name: 'email',
109-
type: 'email',
110-
data: {},
111-
preferences: { fields: {} },
112-
req: { t } as PayloadRequest,
113-
required: true,
114-
siblingData: {},
115-
})
116-
}
117-
/>
118-
)}
83+
<LoginField type={loginType} />
11984
<PasswordField
12085
autoComplete="off"
12186
label={t('general:password')}

packages/payload/src/auth/baseFields/accountLock.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Field } from '../../fields/config/types.js'
22

3-
export default [
3+
export const accountLockFields: Field[] = [
44
{
55
name: 'loginAttempts',
66
type: 'number',

packages/payload/src/auth/baseFields/apiKey.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ const encryptKey: FieldHook = ({ req, value }) =>
77
const decryptKey: FieldHook = ({ req, value }) =>
88
value ? req.payload.decrypt(value as string) : undefined
99

10-
// eslint-disable-next-line no-restricted-exports
11-
export default [
10+
export const apiKeyFields = [
1211
{
1312
name: 'enableAPIKey',
1413
type: 'checkbox',

0 commit comments

Comments
 (0)