diff --git a/src/components/CommunityPortal/Login/CPLogin.jsx b/src/components/CommunityPortal/Login/CPLogin.jsx index 51e69fdbdbd..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 @@ -42,7 +45,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 +76,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 @@ -85,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); } @@ -104,6 +116,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,47 @@ describe('CPLogin component', () => { }); }); - it("check if statusText is not 'OK' and status is not 422 and does not display validation error", async () => { + 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' } }, + }); + 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 +229,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 +247,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(); + }); +});