Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/actions/__tests__/authActions.js.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
setCurrentUser, // Import setCurrentUser action
setHeaderData, // Import setHeaderData action
} from '../authActions'; // Import actions from authActions
import { SET_CURRENT_USER, SET_HEADER_DATA } from '../../constants/auth'; // Import constants
import { SET_CURRENT_USER, SET_HEADER_DATA, STOP_FORCE_LOGOUT } from '../../constants/auth'; // Import constants


const middlewares = [thunk]; // Define middlewares
Expand All @@ -35,7 +35,10 @@ describe('authActions', () => {
httpService.post.mockResolvedValue({ data: { token } }); // Mock the httpService post method
jwtDecode.mockReturnValue(decodedToken); // Mock the jwtDecode function

const expectedActions = [{ type: SET_CURRENT_USER, payload: decodedToken }]; // Define expected actions
const expectedActions = [
{ type: STOP_FORCE_LOGOUT }, // Ensure any existing timers are cleared
{ type: SET_CURRENT_USER, payload: decodedToken },
]; // Define expected actions

await store.dispatch(loginUser(credentials)); // Dispatch the loginUser action
expect(store.getActions()).toEqual(expectedActions); // Assert the actions
Expand Down Expand Up @@ -68,7 +71,10 @@ describe('authActions', () => {

it('creates SET_CURRENT_USER with null when logoutUser is called', () => {
const store = mockStore({}); // Create a mock store
const expectedActions = [{ type: SET_CURRENT_USER, payload: null }]; // Define expected actions
const expectedActions = [
{ type: STOP_FORCE_LOGOUT }, // Clear any active force-logout timer
{ type: SET_CURRENT_USER, payload: null },
]; // Define expected actions

store.dispatch(logoutUser()); // Dispatch the logoutUser action
expect(store.getActions()).toEqual(expectedActions); // Assert the actions
Expand Down
23 changes: 23 additions & 0 deletions src/actions/authActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
SET_CURRENT_USER,
SET_HEADER_DATA,
START_FORCE_LOGOUT,
STOP_FORCE_LOGOUT,
} from '../constants/auth';

const { tokenKey } = config;
Expand All @@ -22,6 +23,21 @@
payload: data,
});

/**
* Stops any active force logout timer and clears related state
*/
export const stopForceLogout = () => (dispatch, getState) => {
const { auth } = getState();
if (auth?.timerId) {
try {
clearTimeout(auth.timerId);
} catch (e) {
// Timer already cleared or invalid
}

Check warning on line 36 in src/actions/authActions.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZqt26UtAskZv8SCVrqX&open=AZqt26UtAskZv8SCVrqX&pullRequest=4439
}
dispatch({ type: STOP_FORCE_LOGOUT });
};

export const loginUser = credentials => dispatch => {
return httpService
.post(ENDPOINTS.LOGIN, credentials)
Expand All @@ -33,6 +49,8 @@
localStorage.setItem(tokenKey, res.data.token);
httpService.setjwt(res.data.token);
const decoded = jwtDecode(res.data.token);
// Ensure any existing timers from a previous session are cleared
dispatch(stopForceLogout());
dispatch(setCurrentUser(decoded));
return { success: true };
})
Expand Down Expand Up @@ -98,6 +116,8 @@
};

export const logoutUser = () => dispatch => {
// Clear any active force-logout timer before logging out
dispatch(stopForceLogout());
localStorage.removeItem(tokenKey);
httpService.setjwt(false);
dispatch(setCurrentUser(null));
Expand Down Expand Up @@ -134,6 +154,9 @@
// eslint-disable-next-line no-console
console.error('Error acknowledging permissions during force logout:', error);
} finally {
// Set flag to indicate user was force logged out due to permission changes
// This helps distinguish "force logged out" vs "first login after permission change"
sessionStorage.setItem('wasForceLoggedOut', 'true');
dispatch(logoutUser());
}
}, delayMs);
Expand Down
146 changes: 125 additions & 21 deletions src/components/Auth/PermissionWatcher.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import axios from 'axios';
import { ENDPOINTS } from '~/utils/URL';
import { startForceLogout } from '../../actions/authActions';
import { startForceLogout, stopForceLogout } from '../../actions/authActions';
import { useCountdown } from '../../hooks/useCountdown';
import PopUpBar from '../PopUpBar/PopUpBar';
import { getUserProfile } from '../../actions/userProfile';
Expand All @@ -11,27 +11,130 @@ function PermissionWatcher() {
const dispatch = useDispatch();
const { isAuthenticated, forceLogoutAt } = useSelector(state => state.auth || {});
const userProfile = useSelector(state => state.userProfile);
const isAcknowledged = userProfile?.permissions?.isAcknowledged !== false;
const isAcknowledged = userProfile?.permissions?.isAcknowledged;
const [isAckLoading, setIsAckLoading] = useState(false);
// Get seconds remaining until force logout
const secondsRemaining = useCountdown(forceLogoutAt);
const [wasForceLoggedOut, setWasForceLoggedOut] = useState(false);
const [flagReady, setFlagReady] = useState(false);
// Track the initial acknowledged state when user first logs in
const [initialAcknowledgedState, setInitialAcknowledgedState] = useState(null);
// Track if user has just logged in (to distinguish from mid-session changes)
const [isInitialLogin, setIsInitialLogin] = useState(false);

// Start the force logout countdown when conditions are met
// On mount or when authentication changes, read flag from sessionStorage
useEffect(() => {
if (isAuthenticated && !isAcknowledged && !forceLogoutAt) {
// eslint-disable-next-line no-console
console.log('Starting force logout countdown due to unacknowledged permission changes');
dispatch(startForceLogout(20000)); // 20 seconds countdown
if (isAuthenticated) {
try {
const flag = sessionStorage.getItem('wasForceLoggedOut');
setWasForceLoggedOut(flag === 'true');
sessionStorage.removeItem('wasForceLoggedOut');
} catch {
// sessionStorage might not be available (private browsing, etc.)
// Silently fail - component will work without the flag
}

// Mark as initial login (initial state will be captured when profile loads)
setIsInitialLogin(true);
setInitialAcknowledgedState(null); // Reset to wait for profile load
} else {
// User logged out, reset state
setIsInitialLogin(false);
setInitialAcknowledgedState(null);
setWasForceLoggedOut(false);
}

setFlagReady(true);
}, [isAuthenticated]);

// Track when user profile is first loaded after login and handle initial login cases
useEffect(() => {
if (!isAuthenticated || !flagReady) return;
if (userProfile === null || userProfile === undefined) return; // Wait for profile to load
if (!isInitialLogin) return; // Only handle initial login cases

// Capture the initial acknowledged state when profile is first loaded
if (initialAcknowledgedState === null) {
setInitialAcknowledgedState(isAcknowledged);
return; // Wait for next render to check conditions
}

// Edge Case 2: User permissions changed when logged out → show banner only on login
// Detected by: user just logged in with unacknowledged permissions
// AND was NOT force logged out (just normal logout with permission changes)
const loggedInWithUnacknowledgedPermissions =
!isAcknowledged && !forceLogoutAt && !wasForceLoggedOut;

if (loggedInWithUnacknowledgedPermissions) {
setIsInitialLogin(false); // Mark as no longer initial login
return;
}

// Edge Case 3: User was force logged out → permissions change → user logs back in → show banner only
// Detected by: user just logged in with unacknowledged permissions AND was force logged out
const loggedInAfterForceLogout = !isAcknowledged && !forceLogoutAt && wasForceLoggedOut;

if (loggedInAfterForceLogout) {
setIsInitialLogin(false); // Mark as no longer initial login
return;
}
}, [isAuthenticated, isAcknowledged, forceLogoutAt, dispatch]);

// If initial login and permissions are acknowledged, mark as no longer initial
if (isAcknowledged) {
setIsInitialLogin(false);
}
}, [
isAuthenticated,
flagReady,
isAcknowledged,
forceLogoutAt,
wasForceLoggedOut,
dispatch,
isInitialLogin,
initialAcknowledgedState,
userProfile,
]);

// Handle mid-session permission changes (Edge Case 1)
useEffect(() => {
if (!isAuthenticated || !flagReady) return;
if (userProfile === null || userProfile === undefined) return; // Wait for profile to load
if (isInitialLogin) return; // Skip mid-session checks during initial login

// User permissions changed when logged in → start timer
// Detected by: permissions were acknowledged (or was null/true), then became unacknowledged
// AND user was already logged in (not initial login)
const permissionsChangedMidSession =
!isAcknowledged && !forceLogoutAt && initialAcknowledgedState !== false; // Was acknowledged or null before (not explicitly false)

if (permissionsChangedMidSession) {
dispatch(startForceLogout(20000));
return;
}

// Case: permissions re-acknowledged → cancel timer
if (isAcknowledged && forceLogoutAt) {
dispatch(stopForceLogout());
// Reset initial state since permissions are now acknowledged
setInitialAcknowledgedState(true);
}
}, [
isAuthenticated,
flagReady,
isAcknowledged,
forceLogoutAt,
dispatch,
isInitialLogin,
initialAcknowledgedState,
userProfile,
]);

// Handle acknowledgment of permission changes
const handleAcknowledge = async () => {
try {
setIsAckLoading(true);

if (!userProfile || !userProfile._id) {
// eslint-disable-next-line no-console
//console.error('User profile not available');
setIsAckLoading(false);
return;
}
Expand All @@ -49,6 +152,9 @@ function PermissionWatcher() {
})
.then(() => {
setIsAckLoading(false);
// Update initial state to reflect acknowledgment
setInitialAcknowledgedState(true);
setIsInitialLogin(false);
dispatch(getUserProfile(_id));
})
.catch(error => {
Expand All @@ -63,21 +169,19 @@ function PermissionWatcher() {
}
};

// Only render the popup when a force logout is in progress
if (!forceLogoutAt) {
return null;
}
return (
!isAcknowledged && (
// Force logout timer running (mid-session permission change)
if (forceLogoutAt && !isAcknowledged) {
return (
<PopUpBar
message={`Permissions changed—logging out in ${secondsRemaining}s. Timer will be stopped; please restart after login.`}
message={`Permissions changed—logging out in ${secondsRemaining}s unless acknowledged.`}
onClickClose={handleAcknowledge}
textColor="red"
isLoading={isAckLoading}
button={false}
button
/>
)
);
);
}

return null;
}

export default PermissionWatcher;
1 change: 1 addition & 0 deletions src/constants/auth.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const SET_CURRENT_USER = 'SET_CURRENT_USER';
export const SET_HEADER_DATA = 'SET_HEADER_DATA';
export const START_FORCE_LOGOUT = 'START_FORCE_LOGOUT';
export const STOP_FORCE_LOGOUT = 'STOP_FORCE_LOGOUT';
14 changes: 13 additions & 1 deletion src/reducers/authReducer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { isEmpty } from 'lodash';
import { SET_CURRENT_USER, SET_HEADER_DATA, START_FORCE_LOGOUT } from '../constants/auth';
import {
SET_CURRENT_USER,
SET_HEADER_DATA,
START_FORCE_LOGOUT,
STOP_FORCE_LOGOUT,
} from '../constants/auth';

const initialState = {
isAuthenticated: false,
Expand Down Expand Up @@ -47,6 +52,13 @@ export const authReducer = (auth = initialState, action) => {
timerId: action.payload.timerId,
};
}
if (action.type === STOP_FORCE_LOGOUT) {
return {
...auth,
forceLogoutAt: null,
timerId: null,
};
}

return auth;
};
Expand Down
Loading
Loading