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' } },