From b6f19f1bc9110afb0697c1e445e492f2e12e653b Mon Sep 17 00:00:00 2001 From: Aditya Varun Dhayapulay <93042524+adit24dhaya@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:14:58 -0800 Subject: [PATCH 1/2] Require explicit Community Portal access and show login failures Close the secondary-login gate when access data is missing, display denied-account and retry messages, and use the HTTP status instead of its optional text. Covers findings from the Phase 3 access audit. --- .../CommunityPortal/Login/CPLogin.jsx | 22 ++++++-- .../Login/__tests__/CPLogin.test.jsx | 35 +++++++++++-- .../CPProtectedRoute/CPProtectedRoute.jsx | 2 +- .../CPProtectedRoute.test.jsx | 52 +++++++++++++++++++ 4 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 src/components/common/CPDashboard/CPProtectedRoute/CPProtectedRoute.test.jsx diff --git a/src/components/CommunityPortal/Login/CPLogin.jsx b/src/components/CommunityPortal/Login/CPLogin.jsx index 51e69fdbdbd..c56420e3a88 100644 --- a/src/components/CommunityPortal/Login/CPLogin.jsx +++ b/src/components/CommunityPortal/Login/CPLogin.jsx @@ -42,7 +42,10 @@ function CPLogin(props) { const handleChange = ({ target }) => { // clears validationError only if error input is being edited - if (validationError && target.name === validationError.label) { + if ( + validationError && + (target.name === validationError.label || validationError.label === 'form') + ) { setValidationError(null); } if (target.name === 'email') { @@ -70,14 +73,20 @@ function CPLogin(props) { } const res = await dispatch(loginAction); // server side error validation - if (!res || res.statusText !== 'OK') { - if (res?.status === 422 && res?.data) { + if (res?.status !== 200 || !res?.data?.token) { + if ( + ['email', 'password'].includes(res?.data?.label) && + typeof res.data.message === 'string' + ) { return setValidationError({ label: res.data.label, message: res.data.message, }); } - return setValidationError(null); + return setValidationError({ + label: 'form', + message: 'Unable to verify your login right now. Please try again.', + }); } // initiate push to CP Dashboard if validated (ie received token) // The auth state will be updated by loginBMUser, which will trigger the useEffect @@ -104,6 +113,11 @@ function CPLogin(props) { Enter your current user credentials to access the Community Portal Dashboard

Note: You must use your Production/Main credentials for this login.

+ {validationError?.label === 'form' && ( +
+ {validationError.message} +
+ )} { it('check if entering the right email and password logs in as expected', async () => { axios.post.mockResolvedValue({ - statusText: 'OK', + status: 200, + statusText: '', data: { token: '1234' }, }); @@ -172,7 +173,29 @@ describe('CPLogin component', () => { }); }); - it("check if statusText is not 'OK' and status is not 422 and does not display validation error", async () => { + it.each([401, 403])('shows the account denial returned with status %s', async status => { + axios.post.mockRejectedValue({ + response: { status, data: { label: 'email', message: 'Account access denied' } }, + }); + renderCPLogin(makeAuthState(true)); + const { emailElement } = fillAndSubmit({ + emailValue: VALID_EMAIL, + passwordValue: getValidSecretValue(), + }); + await screen.findByText('Account access denied'); + expect(emailElement).toBeInvalid(); + expect(history.push).not.toHaveBeenCalled(); + }); + + it('does not treat an empty successful response as portal access', async () => { + axios.post.mockResolvedValue({ status: 200, data: {} }); + renderCPLogin(makeAuthState(true)); + fillAndSubmit({ emailValue: VALID_EMAIL, passwordValue: getValidSecretValue() }); + await screen.findByRole('alert'); + expect(history.push).not.toHaveBeenCalled(); + }); + + it('shows a retry message for a server failure', async () => { axios.post.mockResolvedValue({ statusText: 'ERROR', status: 500, @@ -188,10 +211,13 @@ describe('CPLogin component', () => { await waitFor(() => { expect(passwordElement).not.toBeInvalid(); + expect(screen.getByRole('alert')).toHaveTextContent( + 'Unable to verify your login right now. Please try again.', + ); }); }); - it('check failed post request does not display validation error', async () => { + it('shows a retry message after a network failure', async () => { axios.post.mockRejectedValue({ response: 'server error' }); renderCPLogin(makeAuthState(true)); @@ -203,6 +229,9 @@ describe('CPLogin component', () => { await waitFor(() => { expect(passwordElement).not.toBeInvalid(); + expect(screen.getByRole('alert')).toHaveTextContent( + 'Unable to verify your login right now. Please try again.', + ); }); }); }); diff --git a/src/components/common/CPDashboard/CPProtectedRoute/CPProtectedRoute.jsx b/src/components/common/CPDashboard/CPProtectedRoute/CPProtectedRoute.jsx index 946dc387b66..8b7a587082e 100644 --- a/src/components/common/CPDashboard/CPProtectedRoute/CPProtectedRoute.jsx +++ b/src/components/common/CPDashboard/CPProtectedRoute/CPProtectedRoute.jsx @@ -43,7 +43,7 @@ const CPProtectedRoute = ({ component: Component, render, auth, fallback, ...res if (!auth.isAuthenticated) { return ; } - if (auth.user.access && !auth.user.access.canAccessBMPortal) { + if (auth.user?.access?.canAccessBMPortal !== true) { return ( ({ auth, role: { roles: [] } })); + render( + + + + +

Secondary login required

+
+ +

Main login required

+
+

Private portal content

} + /> +
+
+
, + ); +} + +describe('Community Portal access gate', () => { + beforeEach(() => localStorage.clear()); + + it.each([ + {}, + { access: {} }, + { access: { canAccessBMPortal: false } }, + { access: { canAccessBMPortal: 'true' } }, + ])('requires explicit secondary access for %j', user => { + mount({ isAuthenticated: true, user }); + expect(screen.getByRole('heading', { name: 'Secondary login required' })).toBeInTheDocument(); + expect(screen.queryByText('Private portal content')).not.toBeInTheDocument(); + }); + + it('allows an authenticated user with explicit access', () => { + mount({ isAuthenticated: true, user: { access: { canAccessBMPortal: true } } }); + expect(screen.getByText('Private portal content')).toBeInTheDocument(); + }); + + it('requires main login when signed out', () => { + mount({ isAuthenticated: false, user: {} }); + expect(screen.getByText('Main login required')).toBeInTheDocument(); + }); +}); From a36bd289b166f2a4a2cdd50a816d7f8f5a70f9fd Mon Sep 17 00:00:00 2001 From: Aditya Varun Dhayapulay <93042524+adit24dhaya@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:26:27 -0800 Subject: [PATCH 2/2] Cancel pending Community Portal redirects when leaving login Clear the fallback redirect on unmount so it cannot navigate after the form closes or leak into the next session. A controlled-clock regression fails on the previous commit and passes with cleanup. --- .../CommunityPortal/Login/CPLogin.jsx | 7 +++++-- .../Login/__tests__/CPLogin.test.jsx | 20 ++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/components/CommunityPortal/Login/CPLogin.jsx b/src/components/CommunityPortal/Login/CPLogin.jsx index c56420e3a88..0b42e42f76d 100644 --- a/src/components/CommunityPortal/Login/CPLogin.jsx +++ b/src/components/CommunityPortal/Login/CPLogin.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { connect } from 'react-redux'; import { Redirect } from 'react-router-dom'; import { Form, FormGroup, FormText, Input, Label, Button, FormFeedback } from 'reactstrap'; @@ -13,6 +13,9 @@ function CPLogin(props) { const [enterPassword, setEnteredPassword] = useState(''); const [validationError, setValidationError] = useState(null); const [hasAccess, setHasAccess] = useState(false); + const redirectTimer = useRef(null); + + useEffect(() => () => clearTimeout(redirectTimer.current), []); // get the previous location from the state if available // If access login page from URL directly, redirect to CP Dashboard @@ -94,7 +97,7 @@ function CPLogin(props) { if (res?.data && res.data?.token) { setHasAccess(true); // Small delay to ensure auth state is updated - setTimeout(() => { + redirectTimer.current = setTimeout(() => { history.push(prevLocation.pathname); }, 100); } diff --git a/src/components/CommunityPortal/Login/__tests__/CPLogin.test.jsx b/src/components/CommunityPortal/Login/__tests__/CPLogin.test.jsx index bbd90c96ecd..58d757083d9 100644 --- a/src/components/CommunityPortal/Login/__tests__/CPLogin.test.jsx +++ b/src/components/CommunityPortal/Login/__tests__/CPLogin.test.jsx @@ -1,4 +1,4 @@ -import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { act, screen, fireEvent, waitFor } from '@testing-library/react'; import axios from 'axios'; import CPLogin from '../CPLogin'; import { renderWithStoreRouter } from './renderWithStoreRouter'; @@ -173,6 +173,24 @@ describe('CPLogin component', () => { }); }); + it('does not navigate again after the login form unmounts', async () => { + vi.useFakeTimers(); + try { + axios.post.mockResolvedValue({ status: 200, data: { token: '1234' } }); + const view = renderCPLogin(makeAuthState(true)); + await act(async () => { + fillAndSubmit({ emailValue: VALID_EMAIL, passwordValue: getValidSecretValue() }); + }); + expect(history.push).toHaveBeenCalled(); + view.unmount(); + history.push.mockClear(); + act(() => vi.advanceTimersByTime(150)); + expect(history.push).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it.each([401, 403])('shows the account denial returned with status %s', async status => { axios.post.mockRejectedValue({ response: { status, data: { label: 'email', message: 'Account access denied' } },