From eae511161d884b6cb342ec8de5b6f0629da08d24 Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:47:03 +0100 Subject: [PATCH 01/12] refactor(api): make /api/token route textual description more logical --- .../identity-access-management/application/token/token.route.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/identity-access-management/application/token/token.route.js b/api/src/identity-access-management/application/token/token.route.js index 3ee5dba6fe2..be543de6785 100644 --- a/api/src/identity-access-management/application/token/token.route.js +++ b/api/src/identity-access-management/application/token/token.route.js @@ -37,7 +37,7 @@ export const tokenRoutes = [ handler: (request, h) => tokenController.createToken(request, h), tags: ['identity-access-management', 'api', 'token'], notes: [ - "Cette route permet d'obtenir un refresh token et access token à partir d'un couple identifiant / mot de passe" + + "Cette route permet d'obtenir un access token et un refresh token à partir d'un couple identifiant / mot de passe" + " ou un access token à partir d'un refresh token valide.", ], }, From 4c3d18fe238b928af2c30740852146cbe5e19094 Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:39:01 +0100 Subject: [PATCH 02/12] fix(api): don't let UserNotFoundError or PasswordNotMatching errors popup transform them into MissingOrInvalidCredentialsError instead. --- ...nd-user-for-oidc-reconciliation.usecase.js | 78 +++++++++++-------- ...er-for-oidc-reconciliation.usecase.test.js | 43 ++++++++++ 2 files changed, 89 insertions(+), 32 deletions(-) create mode 100644 api/tests/identity-access-management/integration/domain/usecases/find-user-for-oidc-reconciliation.usecase.test.js diff --git a/api/src/identity-access-management/domain/usecases/find-user-for-oidc-reconciliation.usecase.js b/api/src/identity-access-management/domain/usecases/find-user-for-oidc-reconciliation.usecase.js index 2bfdfea8eaa..d863459e091 100644 --- a/api/src/identity-access-management/domain/usecases/find-user-for-oidc-reconciliation.usecase.js +++ b/api/src/identity-access-management/domain/usecases/find-user-for-oidc-reconciliation.usecase.js @@ -1,4 +1,10 @@ -import { AuthenticationKeyExpired, DifferentExternalIdentifierError } from '../errors.js'; +import { UserNotFoundError } from '../../../shared/domain/errors.js'; +import { + AuthenticationKeyExpired, + DifferentExternalIdentifierError, + MissingOrInvalidCredentialsError, + PasswordNotMatching, +} from '../errors.js'; /** * @typedef {function} findUserForOidcReconciliation @@ -23,41 +29,49 @@ const findUserForOidcReconciliation = async function ({ authenticationMethodRepository, userRepository, }) { - const sessionContentAndUserInfo = await authenticationSessionService.getByKey(authenticationKey); - if (!sessionContentAndUserInfo) { - throw new AuthenticationKeyExpired(); - } + try { + const sessionContentAndUserInfo = await authenticationSessionService.getByKey(authenticationKey); + if (!sessionContentAndUserInfo) { + throw new AuthenticationKeyExpired(); + } - const foundUser = await pixAuthenticationService.getUserByUsernameAndPassword({ - username: email, - password, - userRepository, - }); - - const authenticationMethods = await authenticationMethodRepository.findByUserId({ userId: foundUser.id }); - const oidcAuthenticationMethod = authenticationMethods.find( - (authenticationMethod) => authenticationMethod.identityProvider === identityProvider, - ); - - const isSameExternalIdentifier = - oidcAuthenticationMethod?.externalIdentifier === sessionContentAndUserInfo.userInfo.externalIdentityId; - if (oidcAuthenticationMethod && !isSameExternalIdentifier) { - throw new DifferentExternalIdentifierError(); - } + const foundUser = await pixAuthenticationService.getUserByUsernameAndPassword({ + username: email, + password, + userRepository, + }); + + const authenticationMethods = await authenticationMethodRepository.findByUserId({ userId: foundUser.id }); + const oidcAuthenticationMethod = authenticationMethods.find( + (authenticationMethod) => authenticationMethod.identityProvider === identityProvider, + ); - sessionContentAndUserInfo.userInfo.userId = foundUser.id; - await authenticationSessionService.update(authenticationKey, sessionContentAndUserInfo); + const isSameExternalIdentifier = + oidcAuthenticationMethod?.externalIdentifier === sessionContentAndUserInfo.userInfo.externalIdentityId; + if (oidcAuthenticationMethod && !isSameExternalIdentifier) { + throw new DifferentExternalIdentifierError(); + } - const fullNameFromPix = `${foundUser.firstName} ${foundUser.lastName}`; - const fullNameFromExternalIdentityProvider = `${sessionContentAndUserInfo.userInfo.firstName} ${sessionContentAndUserInfo.userInfo.lastName}`; + sessionContentAndUserInfo.userInfo.userId = foundUser.id; + await authenticationSessionService.update(authenticationKey, sessionContentAndUserInfo); - return { - fullNameFromPix, - fullNameFromExternalIdentityProvider, - email: foundUser.email, - username: foundUser.username, - authenticationMethods, - }; + const fullNameFromPix = `${foundUser.firstName} ${foundUser.lastName}`; + const fullNameFromExternalIdentityProvider = `${sessionContentAndUserInfo.userInfo.firstName} ${sessionContentAndUserInfo.userInfo.lastName}`; + + return { + fullNameFromPix, + fullNameFromExternalIdentityProvider, + email: foundUser.email, + username: foundUser.username, + authenticationMethods, + }; + } catch (error) { + if (error instanceof UserNotFoundError || error instanceof PasswordNotMatching) { + throw new MissingOrInvalidCredentialsError(); + } else { + throw error; + } + } }; export { findUserForOidcReconciliation }; diff --git a/api/tests/identity-access-management/integration/domain/usecases/find-user-for-oidc-reconciliation.usecase.test.js b/api/tests/identity-access-management/integration/domain/usecases/find-user-for-oidc-reconciliation.usecase.test.js new file mode 100644 index 00000000000..133276407ca --- /dev/null +++ b/api/tests/identity-access-management/integration/domain/usecases/find-user-for-oidc-reconciliation.usecase.test.js @@ -0,0 +1,43 @@ +import { MissingOrInvalidCredentialsError } from '../../../../../src/identity-access-management/domain/errors.js'; +import { authenticationSessionService } from '../../../../../src/identity-access-management/domain/services/authentication-session.service.js'; +import { usecases } from '../../../../../src/identity-access-management/domain/usecases/index.js'; +import { databaseBuilder, expect } from '../../../../test-helper.js'; + +describe('Integration | Identity Access Management | Domain | UseCase | findUserForOidcReconciliation', function () { + context('when user exists', function () { + const email = 'some_user@example.net'; + + beforeEach(async function () { + databaseBuilder.factory.buildUser.withRawPassword({ email }); + await databaseBuilder.commit(); + }); + + context('when given password is wrong', function () { + it('throws a MissingOrInvalidCredentialsError', async function () { + // given + const authenticationContent = { + sessionContent: {}, + userInfo: { + email, + firstName: 'aFirstName', + lastName: 'aLastName', + externalIdentityId: 'some-user-unique-id', + }, + }; + const authenticationKey = await authenticationSessionService.save(authenticationContent); + + const wrongPassword = 'a wrong password'; + + // when & then + await expect( + usecases.findUserForOidcReconciliation({ + authenticationKey, + email, + password: wrongPassword, + identityProvider: 'OIDC_EXAMPLE_NET', + }), + ).to.be.rejectedWith(MissingOrInvalidCredentialsError); + }); + }); + }); +}); From 1c668cfda78e562d9e27e8b09533749cd8b16877 Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Thu, 9 Jan 2025 17:46:14 +0100 Subject: [PATCH 03/12] test(mon-pix): remove useless test now that UserNotFoundError is not poping up from server --- .../login-or-register-oidc-test.js | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js b/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js index 349d6b93238..bc14266f1aa 100644 --- a/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js +++ b/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js @@ -293,26 +293,6 @@ module('Unit | Component | authentication | login-or-register-oidc', function (h }); }); - module('when user is not found', function () { - test('should display error', async function (assert) { - // given - const component = createGlimmerComponent('authentication/login-or-register-oidc'); - component.args.onLogin = sinon.stub().rejects({ errors: [{ status: '404' }] }); - component.email = 'glace.alo@example.net'; - component.password = 'pix123'; - const eventStub = { preventDefault: sinon.stub() }; - - // when - await component.login(eventStub); - - // then - assert.strictEqual( - component.loginErrorMessage, - t('pages.login-or-register-oidc.error.login-unauthorized-error'), - ); - }); - }); - module('when there is an account conflict', function () { test('should display error', async function (assert) { // given From a874ffd608c1f6b96fe238e299351f00e48b8cd9 Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Wed, 8 Jan 2025 18:41:13 +0100 Subject: [PATCH 04/12] feat(api): add a code to AuthenticationKeyExpired error --- .../application/http-error-mapper-configuration.js | 2 +- api/src/identity-access-management/domain/errors.js | 4 ++-- .../application/http-error-mapper-configuration_test.js | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/api/src/identity-access-management/application/http-error-mapper-configuration.js b/api/src/identity-access-management/application/http-error-mapper-configuration.js index a516b0bc3ee..fdd445ad88c 100644 --- a/api/src/identity-access-management/application/http-error-mapper-configuration.js +++ b/api/src/identity-access-management/application/http-error-mapper-configuration.js @@ -15,7 +15,7 @@ import { const authenticationDomainErrorMappingConfiguration = [ { name: AuthenticationKeyExpired.name, - httpErrorFn: (error) => new HttpErrors.UnauthorizedError(error.message), + httpErrorFn: (error) => new HttpErrors.UnauthorizedError(error.message, error.code), }, { name: DifferentExternalIdentifierError.name, diff --git a/api/src/identity-access-management/domain/errors.js b/api/src/identity-access-management/domain/errors.js index a0746efa112..a3ba21d7c4f 100644 --- a/api/src/identity-access-management/domain/errors.js +++ b/api/src/identity-access-management/domain/errors.js @@ -1,8 +1,8 @@ import { DomainError } from '../../shared/domain/errors.js'; class AuthenticationKeyExpired extends DomainError { - constructor(message = 'This authentication key has expired.') { - super(message); + constructor() { + super('This authentication key has expired.', 'EXPIRED_AUTHENTICATION_KEY'); } } diff --git a/api/tests/identity-access-management/unit/application/http-error-mapper-configuration_test.js b/api/tests/identity-access-management/unit/application/http-error-mapper-configuration_test.js index 4e0daabce37..94c37adc7d7 100644 --- a/api/tests/identity-access-management/unit/application/http-error-mapper-configuration_test.js +++ b/api/tests/identity-access-management/unit/application/http-error-mapper-configuration_test.js @@ -25,16 +25,17 @@ describe('Unit | Identity Access Management | Application | HttpErrorMapperConfi context('when mapping "AuthenticationKeyExpired"', function () { it('returns an UnauthorizedError Http Error', function () { - //given + // given const httpErrorMapper = authenticationDomainErrorMappingConfiguration.find( (httpErrorMapper) => httpErrorMapper.name === AuthenticationKeyExpired.name, ); - //when + // when const error = httpErrorMapper.httpErrorFn(new AuthenticationKeyExpired()); - //then + // then expect(error).to.be.instanceOf(HttpErrors.UnauthorizedError); + expect(error.code).to.equal('EXPIRED_AUTHENTICATION_KEY'); }); }); From cd5ae131d171e7ba9b230f7683931ed7dd0766ff Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Thu, 9 Jan 2025 16:35:14 +0100 Subject: [PATCH 05/12] test(mon-pix): better test descriptions --- .../authentication/login-or-register-oidc-test.js | 8 ++++---- .../components/authentication/oidc-reconciliation-test.js | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js b/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js index bc14266f1aa..964898fd365 100644 --- a/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js +++ b/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js @@ -36,7 +36,7 @@ module('Unit | Component | authentication | login-or-register-oidc', function (h }); }); - module('completes with error', function () { + module('when there are errors', function () { module('when authentication key has expired', function () { test('displays error', async function (assert) { // given @@ -126,7 +126,7 @@ module('Unit | Component | authentication | login-or-register-oidc', function (h }); }); - test('displays error message with details', async function (assert) { + test('displays an error message with details', async function (assert) { // given const component = createGlimmerComponent('authentication/login-or-register-oidc'); const sessionService = stubSessionService(this.owner, { isAuthenticated: false }); @@ -143,7 +143,7 @@ module('Unit | Component | authentication | login-or-register-oidc', function (h assert.strictEqual(component.registerErrorMessage, `${t('common.error')} (some detail)`); }); - test('displays default error message', async function (assert) { + test('displays a default error message', async function (assert) { // given const component = createGlimmerComponent('authentication/login-or-register-oidc'); const sessionService = stubSessionService(this.owner, { isAuthenticated: false }); @@ -255,7 +255,7 @@ module('Unit | Component | authentication | login-or-register-oidc', function (h }); }); - module('completes with error', function () { + module('when there are errors', function () { module('when form is invalid', function () { test('does not request api for reconciliation', async function (assert) { // given diff --git a/mon-pix/tests/unit/components/authentication/oidc-reconciliation-test.js b/mon-pix/tests/unit/components/authentication/oidc-reconciliation-test.js index 8b0e7dc35f2..dc6240ecc61 100644 --- a/mon-pix/tests/unit/components/authentication/oidc-reconciliation-test.js +++ b/mon-pix/tests/unit/components/authentication/oidc-reconciliation-test.js @@ -163,7 +163,7 @@ module('Unit | Component | authentication | oidc-reconciliation', function (hook assert.false(component.isLoading); }); }); - module('completes with error', function () { + module('when there are errors', function () { module('when authentication key has expired', function () { test('should display error', async function (assert) { // given @@ -187,8 +187,8 @@ module('Unit | Component | authentication | oidc-reconciliation', function (hook }); }); - module('when an error happens', function () { - test('should display generic error message', async function (assert) { + module('when the error has no specific handling', function () { + test('displays a default error message', async function (assert) { // given const component = createGlimmerComponent('authentication/oidc-reconciliation'); const sessionService = stubSessionService(this.owner, { isAuthenticated: false }); From 63926264b74aebfc2f81a169b2e35f94978124a2 Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Wed, 8 Jan 2025 18:45:38 +0100 Subject: [PATCH 06/12] refactor(mon-pix): reorder functions for better logical order --- .../authentication/login-or-register-oidc.js | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/mon-pix/app/components/authentication/login-or-register-oidc.js b/mon-pix/app/components/authentication/login-or-register-oidc.js index bbe7f0684ee..074abf956e8 100644 --- a/mon-pix/app/components/authentication/login-or-register-oidc.js +++ b/mon-pix/app/components/authentication/login-or-register-oidc.js @@ -58,6 +58,32 @@ export default class LoginOrRegisterOidcComponent extends Component { return this.url.dataProtectionPolicyUrl; } + @action + async login(event) { + event.preventDefault(); + + this.loginErrorMessage = null; + + if (!this.isFormValid) return; + + this.isLoginLoading = true; + + try { + await this.args.onLogin({ enteredEmail: this.email, enteredPassword: this.password }); + } catch (error) { + const status = get(error, 'errors[0].status'); + + const errorsMapping = { + 401: this.intl.t(ERROR_INPUT_MESSAGE_MAP['expiredAuthenticationKey']), + 404: this.intl.t(ERROR_INPUT_MESSAGE_MAP['loginUnauthorizedError']), + 409: this.intl.t(ERROR_INPUT_MESSAGE_MAP['accountConflict']), + }; + this.loginErrorMessage = errorsMapping[status] || this.intl.t(ERROR_INPUT_MESSAGE_MAP['unknownError']); + } finally { + this.isLoginLoading = false; + } + } + @action async register() { if (!this.isTermsOfServiceValidated) { @@ -123,32 +149,6 @@ export default class LoginOrRegisterOidcComponent extends Component { return isEmailValid(this.email) && !isEmpty(this.password); } - @action - async login(event) { - event.preventDefault(); - - this.loginErrorMessage = null; - - if (!this.isFormValid) return; - - this.isLoginLoading = true; - - try { - await this.args.onLogin({ enteredEmail: this.email, enteredPassword: this.password }); - } catch (error) { - const status = get(error, 'errors[0].status'); - - const errorsMapping = { - 401: this.intl.t(ERROR_INPUT_MESSAGE_MAP['expiredAuthenticationKey']), - 404: this.intl.t(ERROR_INPUT_MESSAGE_MAP['loginUnauthorizedError']), - 409: this.intl.t(ERROR_INPUT_MESSAGE_MAP['accountConflict']), - }; - this.loginErrorMessage = errorsMapping[status] || this.intl.t(ERROR_INPUT_MESSAGE_MAP['unknownError']); - } finally { - this.isLoginLoading = false; - } - } - @action onChange(event) { this.isTermsOfServiceValidated = !!event.target.checked; From 702b51273eca15cc5e691e8b727cee1dc9cc78d3 Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Thu, 9 Jan 2025 09:16:46 +0100 Subject: [PATCH 07/12] feat(mon-pix): add an errorMessages service --- mon-pix/app/services/error-messages.js | 57 ++++++++++ .../unit/services/error-messages-test.js | 100 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 mon-pix/app/services/error-messages.js create mode 100644 mon-pix/tests/unit/services/error-messages-test.js diff --git a/mon-pix/app/services/error-messages.js b/mon-pix/app/services/error-messages.js new file mode 100644 index 00000000000..f044dcd1877 --- /dev/null +++ b/mon-pix/app/services/error-messages.js @@ -0,0 +1,57 @@ +import Service, { inject as service } from '@ember/service'; +import get from 'lodash/get'; + +const ERROR_CODE_MAPPING = { + EXPIRED_AUTHENTICATION_KEY: { + i18nKey: 'pages.login-or-register-oidc.error.expired-authentication-key', + }, + INVALID_LOCALE_FORMAT: { + i18nKey: 'pages.sign-up.errors.invalid-locale-format', + formatOptionsFn: (error) => { + return { invalidLocale: error.meta.locale }; + }, + }, + LOCALE_NOT_SUPPORTED: { + i18nKey: 'pages.sign-up.errors.locale-not-supported', + formatOptionsFn: (error) => { + return { localeNotSupported: error.meta.locale }; + }, + }, +}; + +const HTTP_STATUS_MAPPING = { + 401: { + i18nKey: 'pages.login-or-register-oidc.error.login-unauthorized-error', + }, + 409: { + i18nKey: 'pages.login-or-register-oidc.error.account-conflict', + }, +}; + +export default class ErrorMessagesService extends Service { + @service intl; + + getErrorMessage(error) { + error = get(error, 'errors[0]') || error; + const mapping = ERROR_CODE_MAPPING[error.code] || HTTP_STATUS_MAPPING[error.status]; + + let i18nKey; + let formatOptionsFn; + let formatOptions; + let formattedGenericErrorDetails; + if (mapping) { + ({ i18nKey, formatOptionsFn } = mapping); + formatOptions = formatOptionsFn && formatOptionsFn(error); + } else { + i18nKey = 'common.error'; + formattedGenericErrorDetails = error.detail && ` (${error.detail})`; + } + + let message = this.intl.t(i18nKey, formatOptions); + if (formattedGenericErrorDetails) { + message += formattedGenericErrorDetails; + } + + return message; + } +} diff --git a/mon-pix/tests/unit/services/error-messages-test.js b/mon-pix/tests/unit/services/error-messages-test.js new file mode 100644 index 00000000000..0e13942f76b --- /dev/null +++ b/mon-pix/tests/unit/services/error-messages-test.js @@ -0,0 +1,100 @@ +import { t } from 'ember-intl/test-support'; +import { setupTest } from 'ember-qunit'; +import { module, test } from 'qunit'; + +import setupIntl from '../../helpers/setup-intl'; + +module('Unit | Service | errorMessages', function (hooks) { + setupTest(hooks); + setupIntl(hooks); + + module('getErrorMessage', function () { + module('when error is a simple error', function () { + module('when no mapping is found', function () { + test('returns a default message', async function (assert) { + // given + const errorMessages = this.owner.lookup('service:errorMessages'); + const error = { status: '500' }; + + // when + const message = errorMessages.getErrorMessage(error); + + // then + assert.strictEqual(message, t('common.error')); + }); + }); + + module('when both error code and error status are present', function () { + test('matches first on error code', async function (assert) { + // given + const errorMessages = this.owner.lookup('service:errorMessages'); + const error = { status: '401', code: 'EXPIRED_AUTHENTICATION_KEY' }; + + // when + const message = errorMessages.getErrorMessage(error); + + // then + assert.strictEqual(message, t('pages.login-or-register-oidc.error.expired-authentication-key')); + }); + }); + + module('when only error status is present', function () { + test('matches on error status', async function (assert) { + // given + const errorMessages = this.owner.lookup('service:errorMessages'); + const error = { status: '401' }; + + // when + const message = errorMessages.getErrorMessage(error); + + // then + assert.strictEqual(message, t('pages.login-or-register-oidc.error.login-unauthorized-error')); + }); + }); + }); + + module('when error is a JSON:API error', function () { + module('when no mapping is found', function () { + test('returns a default message', async function (assert) { + // given + const errorMessages = this.owner.lookup('service:errorMessages'); + const error = { errors: [{ status: '500' }] }; + + // when + const message = errorMessages.getErrorMessage(error); + + // then + assert.strictEqual(message, t('common.error')); + }); + }); + + module('when both error code and error status are present', function () { + test('matches first on error code', async function (assert) { + // given + const errorMessages = this.owner.lookup('service:errorMessages'); + const error = { errors: [{ status: '401', code: 'EXPIRED_AUTHENTICATION_KEY' }] }; + + // when + const message = errorMessages.getErrorMessage(error); + + // then + assert.strictEqual(message, t('pages.login-or-register-oidc.error.expired-authentication-key')); + }); + }); + + module('when only error status is present', function () { + test('matches on error status', async function (assert) { + // given + const errorMessages = this.owner.lookup('service:errorMessages'); + const error = { errors: [{ status: '401' }] }; + + // when + const message = errorMessages.getErrorMessage(error); + + // then + assert.strictEqual(message, t('pages.login-or-register-oidc.error.login-unauthorized-error')); + }); + }); + }); + }); +}); From ae63bfc71ef9fb0bedf297370c5953313472db56 Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Thu, 9 Jan 2025 18:06:40 +0100 Subject: [PATCH 08/12] feat(mon-pix): leverage the new errorMessages service in OIDC forms --- .../authentication/login-or-register-oidc.js | 35 +++---------------- .../authentication/oidc-reconciliation.js | 10 ++---- .../login-or-register-oidc-test.js | 6 ++-- .../oidc-reconciliation-test.js | 4 +-- 4 files changed, 13 insertions(+), 42 deletions(-) diff --git a/mon-pix/app/components/authentication/login-or-register-oidc.js b/mon-pix/app/components/authentication/login-or-register-oidc.js index 074abf956e8..876f8071191 100644 --- a/mon-pix/app/components/authentication/login-or-register-oidc.js +++ b/mon-pix/app/components/authentication/login-or-register-oidc.js @@ -9,11 +9,7 @@ import isEmailValid from '../../utils/email-validator'; const ERROR_INPUT_MESSAGE_MAP = { termsOfServiceNotSelected: 'pages.login-or-register-oidc.error.error-message', - unknownError: 'common.error', - expiredAuthenticationKey: 'pages.login-or-register-oidc.error.expired-authentication-key', invalidEmail: 'pages.login-or-register-oidc.error.invalid-email', - accountConflict: 'pages.login-or-register-oidc.error.account-conflict', - loginUnauthorizedError: 'pages.login-or-register-oidc.error.login-unauthorized-error', }; export default class LoginOrRegisterOidcComponent extends Component { @@ -23,6 +19,7 @@ export default class LoginOrRegisterOidcComponent extends Component { @service oidcIdentityProviders; @service store; @service url; + @service errorMessages; @tracked isTermsOfServiceValidated = false; @tracked loginErrorMessage = null; @@ -70,15 +67,8 @@ export default class LoginOrRegisterOidcComponent extends Component { try { await this.args.onLogin({ enteredEmail: this.email, enteredPassword: this.password }); - } catch (error) { - const status = get(error, 'errors[0].status'); - - const errorsMapping = { - 401: this.intl.t(ERROR_INPUT_MESSAGE_MAP['expiredAuthenticationKey']), - 404: this.intl.t(ERROR_INPUT_MESSAGE_MAP['loginUnauthorizedError']), - 409: this.intl.t(ERROR_INPUT_MESSAGE_MAP['accountConflict']), - }; - this.loginErrorMessage = errorsMapping[status] || this.intl.t(ERROR_INPUT_MESSAGE_MAP['unknownError']); + } catch (responseError) { + this.loginErrorMessage = this.errorMessages.getErrorMessage(responseError); } finally { this.isLoginLoading = false; } @@ -102,24 +92,7 @@ export default class LoginOrRegisterOidcComponent extends Component { }); } catch (responseError) { const error = get(responseError, 'errors[0]'); - switch (error?.code) { - case 'INVALID_LOCALE_FORMAT': - this.registerErrorMessage = this.intl.t('pages.sign-up.errors.invalid-locale-format', { - invalidLocale: error.meta.locale, - }); - return; - case 'LOCALE_NOT_SUPPORTED': - this.registerErrorMessage = this.intl.t('pages.sign-up.errors.locale-not-supported', { - localeNotSupported: error.meta.locale, - }); - return; - } - if (error.status === '401') { - this.registerErrorMessage = this.intl.t(ERROR_INPUT_MESSAGE_MAP['expiredAuthenticationKey']); - } else { - this.registerErrorMessage = - this.intl.t(ERROR_INPUT_MESSAGE_MAP['unknownError']) + (error.detail ? ` (${error.detail})` : ''); - } + this.registerErrorMessage = this.errorMessages.getErrorMessage(error); } finally { this.isRegisterLoading = false; } diff --git a/mon-pix/app/components/authentication/oidc-reconciliation.js b/mon-pix/app/components/authentication/oidc-reconciliation.js index 95e5068d2c3..d17932aca8b 100644 --- a/mon-pix/app/components/authentication/oidc-reconciliation.js +++ b/mon-pix/app/components/authentication/oidc-reconciliation.js @@ -2,12 +2,12 @@ import { action } from '@ember/object'; import { service } from '@ember/service'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import get from 'lodash/get'; export default class OidcReconciliationComponent extends Component { @service intl; @service oidcIdentityProviders; @service session; + @service errorMessages; @tracked reconcileErrorMessage = null; @tracked isLoading = false; @@ -49,12 +49,8 @@ export default class OidcReconciliationComponent extends Component { identityProviderSlug: this.args.identityProviderSlug, hostSlug: 'user/reconcile', }); - } catch (error) { - const status = get(error, 'errors[0].status'); - const errorsMapping = { - 401: this.intl.t('pages.login-or-register-oidc.error.expired-authentication-key'), - }; - this.reconcileErrorMessage = errorsMapping[status] || this.intl.t('common.error'); + } catch (responseError) { + this.reconcileErrorMessage = this.errorMessages.getErrorMessage(responseError); } finally { this.isLoading = false; } diff --git a/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js b/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js index 964898fd365..2fc6ad4edf3 100644 --- a/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js +++ b/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js @@ -43,7 +43,7 @@ module('Unit | Component | authentication | login-or-register-oidc', function (h const component = createGlimmerComponent('authentication/login-or-register-oidc'); const sessionService = stubSessionService(this.owner, { isAuthenticated: false }); - sessionService.authenticate.rejects({ errors: [{ status: '401' }] }); + sessionService.authenticate.rejects({ errors: [{ status: '401', code: 'EXPIRED_AUTHENTICATION_KEY' }] }); component.args.identityProviderSlug = 'super-idp'; component.args.authenticationKey = 'super-key'; @@ -277,7 +277,9 @@ module('Unit | Component | authentication | login-or-register-oidc', function (h test('should display error', async function (assert) { // given const component = createGlimmerComponent('authentication/login-or-register-oidc'); - component.args.onLogin = sinon.stub().rejects({ errors: [{ status: '401' }] }); + component.args.onLogin = sinon + .stub() + .rejects({ errors: [{ status: '401', code: 'EXPIRED_AUTHENTICATION_KEY' }] }); component.email = 'glace.alo@example.net'; component.password = 'pix123'; const eventStub = { preventDefault: sinon.stub() }; diff --git a/mon-pix/tests/unit/components/authentication/oidc-reconciliation-test.js b/mon-pix/tests/unit/components/authentication/oidc-reconciliation-test.js index dc6240ecc61..47d15a1668f 100644 --- a/mon-pix/tests/unit/components/authentication/oidc-reconciliation-test.js +++ b/mon-pix/tests/unit/components/authentication/oidc-reconciliation-test.js @@ -169,7 +169,7 @@ module('Unit | Component | authentication | oidc-reconciliation', function (hook // given const component = createGlimmerComponent('authentication/oidc-reconciliation'); const sessionService = stubSessionService(this.owner, { isAuthenticated: false }); - sessionService.authenticate.rejects({ errors: [{ status: '401' }] }); + sessionService.authenticate.rejects({ errors: [{ status: '401', code: 'EXPIRED_AUTHENTICATION_KEY' }] }); component.args.identityProviderSlug = 'super-idp'; component.args.authenticationKey = 'super-key'; @@ -192,7 +192,7 @@ module('Unit | Component | authentication | oidc-reconciliation', function (hook // given const component = createGlimmerComponent('authentication/oidc-reconciliation'); const sessionService = stubSessionService(this.owner, { isAuthenticated: false }); - sessionService.authenticate.rejects({ errors: [{ status: '400' }] }); + sessionService.authenticate.rejects({ errors: [{ status: '500' }] }); component.args.identityProviderSlug = 'super-idp'; component.args.authenticationKey = 'super-key'; From 541bc12cd33fa1fb0f1f1493efecb5f901af9b3e Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Thu, 9 Jan 2025 19:43:37 +0100 Subject: [PATCH 09/12] test(api): improve blocked users seeds --- .../data/team-acces/build-blocked-users.js | 27 ++++++++++++++++--- .../build-temporary-blocked-user.js | 7 ++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/api/db/seeds/data/team-acces/build-blocked-users.js b/api/db/seeds/data/team-acces/build-blocked-users.js index c5d5ed7f21a..5327fe5adb8 100644 --- a/api/db/seeds/data/team-acces/build-blocked-users.js +++ b/api/db/seeds/data/team-acces/build-blocked-users.js @@ -1,21 +1,40 @@ +import { config } from '../../../../src/shared/config.js'; import { DEFAULT_PASSWORD } from '../../../constants.js'; -function _buildUserBeforeBeingBlocked(databaseBuilder) { +function _buildBlockedUser(databaseBuilder) { const blockedUser = databaseBuilder.factory.buildUser.withRawPassword({ firstName: 'Goldi', lastName: 'Locks', email: 'blocked@example.net', - username: 'goldi.locks0101', + username: 'goldi.locks', rawPassword: DEFAULT_PASSWORD, cgu: false, }); databaseBuilder.factory.buildUserLogin({ userId: blockedUser.id, - failureCount: 49, + failureCount: config.login.blockingLimitFailureCount, + blockedAt: new Date(), + }); +} + +function _buildAlmostBlockedUser(databaseBuilder) { + const almostBlockedUser = databaseBuilder.factory.buildUser.withRawPassword({ + firstName: 'Silveri', + lastName: 'Locks', + email: 'almost-blocked@example.net', + username: 'silveri.locks', + rawPassword: DEFAULT_PASSWORD, + cgu: false, + }); + + databaseBuilder.factory.buildUserLogin({ + userId: almostBlockedUser.id, + failureCount: config.login.blockingLimitFailureCount - 1, }); } export function buildBlockedUsers(databaseBuilder) { - _buildUserBeforeBeingBlocked(databaseBuilder); + _buildBlockedUser(databaseBuilder); + _buildAlmostBlockedUser(databaseBuilder); } diff --git a/api/db/seeds/data/team-acces/build-temporary-blocked-user.js b/api/db/seeds/data/team-acces/build-temporary-blocked-user.js index c9e3ddd1f97..f2dbb762a87 100644 --- a/api/db/seeds/data/team-acces/build-temporary-blocked-user.js +++ b/api/db/seeds/data/team-acces/build-temporary-blocked-user.js @@ -1,6 +1,7 @@ +import { config } from '../../../../src/shared/config.js'; import { DEFAULT_PASSWORD } from '../../../constants.js'; -function _buildUserBeforeBeingTemporarilyBlocked(databaseBuilder) { +function _buildAlmostTemporarilyBlockedUser(databaseBuilder) { const temporaryBlockedUser = databaseBuilder.factory.buildUser.withRawPassword({ firstName: 'Little', lastName: 'Bear', @@ -12,10 +13,10 @@ function _buildUserBeforeBeingTemporarilyBlocked(databaseBuilder) { databaseBuilder.factory.buildUserLogin({ userId: temporaryBlockedUser.id, - failureCount: 9, + failureCount: config.login.temporaryBlockingThresholdFailureCount - 1, }); } export function buildTemporaryBlockedUsers(databaseBuilder) { - _buildUserBeforeBeingTemporarilyBlocked(databaseBuilder); + _buildAlmostTemporarilyBlockedUser(databaseBuilder); } From 244f1798cb243346f4e43daa8957265683079ce4 Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Fri, 10 Jan 2025 08:43:50 +0100 Subject: [PATCH 10/12] test(api): regroup blocked user and temporarily blocked user seeds for efficiency in the same file since it's for testing the same code. --- .../data/team-acces/build-blocked-users.js | 17 ++++++++++++++ .../build-temporary-blocked-user.js | 22 ------------------- api/db/seeds/data/team-acces/data-builder.js | 2 -- 3 files changed, 17 insertions(+), 24 deletions(-) delete mode 100644 api/db/seeds/data/team-acces/build-temporary-blocked-user.js diff --git a/api/db/seeds/data/team-acces/build-blocked-users.js b/api/db/seeds/data/team-acces/build-blocked-users.js index 5327fe5adb8..a9c5ea05e3c 100644 --- a/api/db/seeds/data/team-acces/build-blocked-users.js +++ b/api/db/seeds/data/team-acces/build-blocked-users.js @@ -34,7 +34,24 @@ function _buildAlmostBlockedUser(databaseBuilder) { }); } +function _buildAlmostTemporarilyBlockedUser(databaseBuilder) { + const temporaryBlockedUser = databaseBuilder.factory.buildUser.withRawPassword({ + firstName: 'Little', + lastName: 'Bear', + email: 'temporarily-blocked@example.net', + username: 'little.bear0101', + rawPassword: DEFAULT_PASSWORD, + cgu: false, + }); + + databaseBuilder.factory.buildUserLogin({ + userId: temporaryBlockedUser.id, + failureCount: config.login.temporaryBlockingThresholdFailureCount - 1, + }); +} + export function buildBlockedUsers(databaseBuilder) { _buildBlockedUser(databaseBuilder); _buildAlmostBlockedUser(databaseBuilder); + _buildAlmostTemporarilyBlockedUser(databaseBuilder); } diff --git a/api/db/seeds/data/team-acces/build-temporary-blocked-user.js b/api/db/seeds/data/team-acces/build-temporary-blocked-user.js deleted file mode 100644 index f2dbb762a87..00000000000 --- a/api/db/seeds/data/team-acces/build-temporary-blocked-user.js +++ /dev/null @@ -1,22 +0,0 @@ -import { config } from '../../../../src/shared/config.js'; -import { DEFAULT_PASSWORD } from '../../../constants.js'; - -function _buildAlmostTemporarilyBlockedUser(databaseBuilder) { - const temporaryBlockedUser = databaseBuilder.factory.buildUser.withRawPassword({ - firstName: 'Little', - lastName: 'Bear', - email: 'temporary-blocked@example.net', - username: 'little.bear0101', - rawPassword: DEFAULT_PASSWORD, - cgu: false, - }); - - databaseBuilder.factory.buildUserLogin({ - userId: temporaryBlockedUser.id, - failureCount: config.login.temporaryBlockingThresholdFailureCount - 1, - }); -} - -export function buildTemporaryBlockedUsers(databaseBuilder) { - _buildAlmostTemporarilyBlockedUser(databaseBuilder); -} diff --git a/api/db/seeds/data/team-acces/data-builder.js b/api/db/seeds/data/team-acces/data-builder.js index 519ebdf3424..b81b15a0716 100644 --- a/api/db/seeds/data/team-acces/data-builder.js +++ b/api/db/seeds/data/team-acces/data-builder.js @@ -7,7 +7,6 @@ import { buildPixAdminRoles } from './build-pix-admin-roles.js'; import { buildResetPasswordUsers } from './build-reset-password-users.js'; import { buildScoOrganizationLearners } from './build-sco-organization-learners.js'; import { buildScoOrganizations } from './build-sco-organizations.js'; -import { buildTemporaryBlockedUsers } from './build-temporary-blocked-user.js'; import { buildUsers } from './build-users.js'; async function teamAccesDataBuilder(databaseBuilder) { @@ -15,7 +14,6 @@ async function teamAccesDataBuilder(databaseBuilder) { buildUsers(databaseBuilder); buildBlockedUsers(databaseBuilder); buildResetPasswordUsers(databaseBuilder); - buildTemporaryBlockedUsers(databaseBuilder); buildOrganizationUsers(databaseBuilder); buildScoOrganizations(databaseBuilder); buildArchivedOrganizations(databaseBuilder); From 51040028506cacd83fda027ff4ddf406b4102394 Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:55:55 +0100 Subject: [PATCH 11/12] test(api): better account name for almost-temporarily-blocked user account in blocked user seeds --- api/db/seeds/data/team-acces/build-blocked-users.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/db/seeds/data/team-acces/build-blocked-users.js b/api/db/seeds/data/team-acces/build-blocked-users.js index a9c5ea05e3c..743ddd33f0f 100644 --- a/api/db/seeds/data/team-acces/build-blocked-users.js +++ b/api/db/seeds/data/team-acces/build-blocked-users.js @@ -38,7 +38,7 @@ function _buildAlmostTemporarilyBlockedUser(databaseBuilder) { const temporaryBlockedUser = databaseBuilder.factory.buildUser.withRawPassword({ firstName: 'Little', lastName: 'Bear', - email: 'temporarily-blocked@example.net', + email: 'almost-temporarily-blocked@example.net', username: 'little.bear0101', rawPassword: DEFAULT_PASSWORD, cgu: false, From 56773e738cf391f62faa81dea3ffeffca8ca4540 Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Thu, 9 Jan 2025 20:05:07 +0100 Subject: [PATCH 12/12] feat(mon-pix): handle blocked user account errors in OIDC login forms --- mon-pix/app/services/error-messages.js | 18 +++++++ .../login-or-register-oidc-test.js | 52 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/mon-pix/app/services/error-messages.js b/mon-pix/app/services/error-messages.js index f044dcd1877..d999808aecf 100644 --- a/mon-pix/app/services/error-messages.js +++ b/mon-pix/app/services/error-messages.js @@ -5,6 +5,24 @@ const ERROR_CODE_MAPPING = { EXPIRED_AUTHENTICATION_KEY: { i18nKey: 'pages.login-or-register-oidc.error.expired-authentication-key', }, + USER_IS_TEMPORARY_BLOCKED: { + i18nKey: 'common.api-error-messages.login-user-temporary-blocked-error', + formatOptionsFn: () => { + return { + url: '/mot-de-passe-oublie', + htmlSafe: true, + }; + }, + }, + USER_IS_BLOCKED: { + i18nKey: 'common.api-error-messages.login-user-blocked-error', + formatOptionsFn: () => { + return { + url: 'https://support.pix.org/support/tickets/new', + htmlSafe: true, + }; + }, + }, INVALID_LOCALE_FORMAT: { i18nKey: 'pages.sign-up.errors.invalid-locale-format', formatOptionsFn: (error) => { diff --git a/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js b/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js index 2fc6ad4edf3..547af5c5862 100644 --- a/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js +++ b/mon-pix/tests/unit/components/authentication/login-or-register-oidc-test.js @@ -312,6 +312,58 @@ module('Unit | Component | authentication | login-or-register-oidc', function (h }); }); + module('when user account is temporarily blocked', function () { + test('displays error', async function (assert) { + // given + const component = createGlimmerComponent('authentication/login-or-register-oidc'); + + const sessionService = stubSessionService(this.owner, { isAuthenticated: false }); + sessionService.authenticate.rejects({ errors: [{ status: '403', code: 'USER_IS_TEMPORARY_BLOCKED' }] }); + + component.args.identityProviderSlug = 'super-idp'; + component.args.authenticationKey = 'super-key'; + component.isTermsOfServiceValidated = true; + + // when + await component.register(); + + // then + assert.deepEqual( + component.registerErrorMessage, + t('common.api-error-messages.login-user-temporary-blocked-error', { + url: '/mot-de-passe-oublie', + htmlSafe: true, + }), + ); + }); + }); + + module('when user account is blocked', function () { + test('displays error', async function (assert) { + // given + const component = createGlimmerComponent('authentication/login-or-register-oidc'); + + const sessionService = stubSessionService(this.owner, { isAuthenticated: false }); + sessionService.authenticate.rejects({ errors: [{ status: '403', code: 'USER_IS_BLOCKED' }] }); + + component.args.identityProviderSlug = 'super-idp'; + component.args.authenticationKey = 'super-key'; + component.isTermsOfServiceValidated = true; + + // when + await component.register(); + + // then + assert.deepEqual( + component.registerErrorMessage, + t('common.api-error-messages.login-user-blocked-error', { + url: 'https://support.pix.org/support/tickets/new', + htmlSafe: true, + }), + ); + }); + }); + test('displays default error message', async function (assert) { // given const component = createGlimmerComponent('authentication/login-or-register-oidc');