Skip to content

Commit fd99a30

Browse files
authored
feat: distinct error for unverified email login (#11647)
<!-- Thank you for the PR! Please go through the checklist below and make sure you've completed all the steps. Please review the [CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md) document in this repository if you haven't already. The following items will ensure that your PR is handled as smoothly as possible: - PR Title must follow conventional commits format. For example, `feat: my new feature`, `fix(plugin-seo): my fix`. - Minimal description explained as if explained to someone not immediately familiar with the code. - Provide before/after screenshots or code diffs if applicable. - Link any related issues/discussions from GitHub or Discord. - Add review comments if necessary to explain to the reviewer the logic behind a change ### What? ### Why? ### How? Fixes # --> ### What? This PR adds a new error to be thrown when logging in while having `verify: true` set but no email has been verified for the user yet. ### Why? To have a more descriptive, actionable error thrown in this case as opposed to the generic "Invalid email or password." This gives users more insight into why the login failed. ### How? Introducing a new error: `UnverifiedEmail` and adjusting the check to be separate from `if (!user) { ... }`. Fixes #11358 Notes: - In terms of account enumeration: this should not be a concern here as the check for throwing this error comes _after_ the check for valid args as well as the find for the user. This means that credentials must be on hand, both an email and password, before seeing this error. - I have an int test written in `/test/auth/int.spec.ts` for this, however whenever I try to commit it I get an error stating that the `eslint@9.14.0` module was not found during `lint-staged`. <details> <summary>Int test</summary> ```ts it('should respond with unverifiedEmail if email is unverified on login', async () => { await payload.create({ collection: publicUsersSlug, data: { email: 'user@example.com', password: 'test', }, }) const response = await restClient.POST(`/${publicUsersSlug}/login`, { body: JSON.stringify({ email: 'user@example.com', password: 'test', }), }) expect(response.status).toBe(403) const responseData = await response.json() expect(responseData.errors[0].message).toBe('Please verify your email before logging in.') }) ``` </details> Demo of toast: ![Login-Payload-03-11-2025_11_52_PM-unverified-after](https://github.com/user-attachments/assets/55112f61-1d1f-41b9-93e6-8a4d66365b81)
1 parent a44a252 commit fd99a30

Some content is hidden

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

42 files changed

+66
-2
lines changed

packages/payload/src/auth/operations/login.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import type { PayloadRequest, Where } from '../../types/index.js'
99
import type { User } from '../types.js'
1010

1111
import { buildAfterOperation } from '../../collections/operations/utils.js'
12-
import { AuthenticationError, LockedAuth, ValidationError } from '../../errors/index.js'
12+
import {
13+
AuthenticationError,
14+
LockedAuth,
15+
UnverifiedEmail,
16+
ValidationError,
17+
} from '../../errors/index.js'
1318
import { afterRead } from '../../fields/hooks/afterRead/index.js'
1419
import { Forbidden } from '../../index.js'
1520
import { killTransaction } from '../../utilities/killTransaction.js'
@@ -179,10 +184,14 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
179184
where: whereConstraint,
180185
})
181186

182-
if (!user || (args.collection.config.auth.verify && user._verified === false)) {
187+
if (!user) {
183188
throw new AuthenticationError(req.t, Boolean(canLoginWithUsername && sanitizedUsername))
184189
}
185190

191+
if (args.collection.config.auth.verify && user._verified === false) {
192+
throw new UnverifiedEmail({ t: req.t })
193+
}
194+
186195
user.collection = collectionConfig.slug
187196
user._strategy = 'local-jwt'
188197

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { TFunction } from '@payloadcms/translations'
2+
3+
import { en } from '@payloadcms/translations/languages/en'
4+
import { status as httpStatus } from 'http-status'
5+
6+
import { APIError } from './APIError.js'
7+
8+
export class UnverifiedEmail extends APIError {
9+
constructor({ t }: { t?: TFunction }) {
10+
super(
11+
t ? t('error:unverifiedEmail') : en.translations.error.unverifiedEmail,
12+
httpStatus.FORBIDDEN,
13+
)
14+
}
15+
}

packages/payload/src/errors/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ export { MissingFile } from './MissingFile.js'
2020
export { NotFound } from './NotFound.js'
2121
export { QueryError } from './QueryError.js'
2222
export { ReservedFieldName } from './ReservedFieldName.js'
23+
export { UnverifiedEmail } from './UnverifiedEmail.js'
2324
export { ValidationError, ValidationErrorName } from './ValidationError.js'
2425
export type { ValidationFieldError } from './ValidationError.js'

packages/payload/src/errors/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export type ErrorName =
1515
| 'MissingFile'
1616
| 'NotFound'
1717
| 'QueryError'
18+
| 'UnverifiedEmail'
1819
| 'ValidationError'

packages/translations/src/clientKeys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export const clientTranslationKeys = createClientTranslationKeys([
8181
'error:unauthorizedAdmin',
8282
'error:unknown',
8383
'error:unspecific',
84+
'error:unverifiedEmail',
8485
'error:userEmailAlreadyRegistered',
8586
'error:usernameAlreadyRegistered',
8687
'error:tokenNotProvided',

packages/translations/src/languages/ar.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export const arTranslations: DefaultTranslationsObject = {
119119
unknown: 'حدث خطأ غير معروف.',
120120
unPublishingDocument: 'حدث خطأ أثناء إلغاء نشر هذا المستند.',
121121
unspecific: 'حدث خطأ.',
122+
unverifiedEmail: 'يرجى التحقق من بريدك الإلكتروني قبل تسجيل الدخول.',
122123
userEmailAlreadyRegistered: 'يوجد مستخدم مسجل بالفعل بهذا البريد الإلكتروني.',
123124
userLocked: 'تمّ قفل هذا المستخدم نظرًا لوجود عدد كبير من محاولات تسجيل الدّخول الغير ناجحة.',
124125
usernameAlreadyRegistered: 'المستخدم بالاسم المعطى مسجل بالفعل.',

packages/translations/src/languages/az.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export const azTranslations: DefaultTranslationsObject = {
120120
unknown: 'Naməlum bir xəta baş verdi.',
121121
unPublishingDocument: 'Bu sənədin nəşrini ləğv etmək zamanı problem baş verdi.',
122122
unspecific: 'Xəta baş verdi.',
123+
unverifiedEmail: 'Zəhmət olmasa, daxil olmadan əvvəl e-poçtunuzu təsdiqləyin.',
123124
userEmailAlreadyRegistered: 'Verilən e-poçt ünvanı ilə artıq istifadəçi qeydiyyatdan keçib.',
124125
userLocked: 'Bu istifadəçi çoxsaylı uğursuz giriş cəhdləri səbəbindən kilidlənib.',
125126
usernameAlreadyRegistered: 'Verilən istifadəçi adı ilə artıq qeydiyyatdan keçmişdir.',

packages/translations/src/languages/bg.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export const bgTranslations: DefaultTranslationsObject = {
120120
unknown: 'Неизвестна грешка.',
121121
unPublishingDocument: 'Имаше проблем при скриването на този документ.',
122122
unspecific: 'Грешка.',
123+
unverifiedEmail: 'Моля, потвърдете своя имейл, преди да влезете.',
123124
userEmailAlreadyRegistered: 'Потребител с дадения имейл вече е регистриран.',
124125
userLocked: 'Този потребител има прекалено много невалидни опити за влизане и е заключен.',
125126
usernameAlreadyRegistered: 'Потребител със зададеното потребителско име вече е регистриран.',

packages/translations/src/languages/ca.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export const caTranslations: DefaultTranslationsObject = {
121121
unknown: "S'ha produït un error desconegut.",
122122
unPublishingDocument: 'Hi ha hagut un problema mentre es despublicava aquest document.',
123123
unspecific: "S'ha produït un error.",
124+
unverifiedEmail: 'Si us plau, verifica el teu correu electrònic abans d’iniciar sessió.',
124125
userEmailAlreadyRegistered: 'Ja hi ha un usuari registrat amb aquest correu electrònic.',
125126
userLocked: "Aquest usuari està bloquejat per massa intents fallits d'inici de sessió.",
126127
usernameAlreadyRegistered: "Ja hi ha un usuari registrat amb aquest nom d'usuari.",

packages/translations/src/languages/cs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export const csTranslations: DefaultTranslationsObject = {
120120
unknown: 'Došlo k neznámé chybě.',
121121
unPublishingDocument: 'Při zrušení publikování tohoto dokumentu došlo k chybě.',
122122
unspecific: 'Došlo k chybě.',
123+
unverifiedEmail: 'Před přihlášením ověřte svůj e-mail.',
123124
userEmailAlreadyRegistered: 'Uživatel s daným e-mailem je již zaregistrován.',
124125
userLocked: 'Tento uživatel je uzamčen kvůli příliš mnoha neúspěšným pokusům o přihlášení.',
125126
usernameAlreadyRegistered: 'Uživatel se zadaným uživatelským jménem je již zaregistrován.',

0 commit comments

Comments
 (0)