Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CNFT1-3685: Idle timeout logout modal #2184

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
86 changes: 86 additions & 0 deletions apps/modernization-ui/src/authorization/IdleTimer.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React from 'react';
import { act, fireEvent, render } from '@testing-library/react';
import IdleTimer from './IdleTimer';

interface FixtureProps {
onIdle: () => void;
}

const timeout = 5000;
const warningTimeout = 2000;

const Fixture: React.FC<FixtureProps> = ({ onIdle }) => {
return <IdleTimer timeout={timeout} warningTimeout={warningTimeout} onIdle={onIdle} />;
};

describe('IdleTimer Component', () => {
let onIdle: jest.Mock;

beforeEach(() => {
onIdle = jest.fn();
jest.useFakeTimers();
jest.clearAllTimers();
});

it('should render without crashing', () => {
render(<Fixture onIdle={onIdle} />);
});

it('should start idle timer on mount', () => {
const { queryByRole } = render(<Fixture onIdle={onIdle} />);
act(() => {
jest.advanceTimersByTime(timeout + 100);
});
expect(queryByRole('dialog')).toBeInTheDocument();
});

it('should reset idle timer on activity', () => {
const { queryByRole } = render(<Fixture onIdle={onIdle} />);
act(() => {
jest.advanceTimersByTime(timeout - 1000);
});
fireEvent.mouseMove(document.body);
act(() => {
jest.advanceTimersByTime(1100);
});
expect(queryByRole('dialog')).not.toBeInTheDocument();
});

it('should call onIdle after warning timeout', () => {
const { queryByRole } = render(<Fixture onIdle={onIdle} />);
act(() => {
jest.advanceTimersByTime(timeout + 100);
});
expect(queryByRole('dialog')).toBeInTheDocument();
jest.advanceTimersByTime(2000);
expect(onIdle).toHaveBeenCalledTimes(1);
});

it('should reset timers on continue', () => {
const { queryByRole, getByText } = render(<Fixture onIdle={onIdle} />);
act(() => {
jest.advanceTimersByTime(timeout + 100);
});
expect(queryByRole('dialog')).toBeInTheDocument();
fireEvent.click(getByText('Continue'));
// act(() => {
// fireEvent.click(getByText('Continue'));
// });
expect(queryByRole('dialog')).not.toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(timeout + 100);
});
expect(queryByRole('dialog')).toBeInTheDocument();
});

it('should call onIdle on logout', () => {
const { queryByRole, getByText } = render(<Fixture onIdle={onIdle} />);
// jest.advanceTimersByTime(timeout + 100);
act(() => {
jest.advanceTimersByTime(timeout + 100);
});
expect(queryByRole('dialog')).toBeInTheDocument();
fireEvent.click(getByText('Logout'));
expect(onIdle).toHaveBeenCalledTimes(1);
});
});
125 changes: 78 additions & 47 deletions apps/modernization-ui/src/authorization/IdleTimer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Button } from 'components/button';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import debounce from 'lodash.debounce';
import { Confirmation } from 'design-system/modal';

interface IdleTimerProps {
timeout: number; // Timeout in milliseconds
Expand All @@ -9,75 +10,105 @@ interface IdleTimerProps {

const IdleTimer: React.FC<IdleTimerProps> = ({ timeout, warningTimeout, onIdle }) => {
const [idle, setIdle] = useState(false);
const [showWarningModal, setShowWarningModal] = useState(false);
const [idleTimer, setIdleTimer] = useState<number | undefined>();
const [warningTimer, setWarningTimer] = useState<number | undefined>();
const warningMins = Math.ceil(warningTimeout / 60000);

// this starts the warning timer and shows the warning modal
const startWarningTimer = useCallback(() => {
setIdle(true);
clearTimeout(warningTimer);
setWarningTimer(
window.setTimeout(() => {
onIdle();
}, warningTimeout)
);
}, [onIdle, warningTimeout, warningTimer]);

// this resets the idle timer, when there is mouse activity or the warning modal is dismissed
const resetIdleTimer = useCallback(() => {
clearTimeout(idleTimer);
clearTimeout(warningTimer);
setIdle(false);
setIdleTimer(
window.setTimeout(() => {
startWarningTimer();
}, timeout)
);
setWarningTimer(undefined);
}, [idleTimer, warningTimer, startWarningTimer]);
const debouncedResetIdleTimer = useCallback(debounce(resetIdleTimer, 100), [resetIdleTimer]);

const handleActivity = useCallback(() => {
if (!idle) {
debouncedResetIdleTimer();
}
}, [idle, debouncedResetIdleTimer]);
const handleActivityRef = useRef(handleActivity);

useEffect(() => {
let idleTimer: number;
let warningTimer: number;
// set up ref to the handleActivity function
// we do this so we can use the same function in the event listeners
handleActivityRef.current = handleActivity;
}, [handleActivity]);

const resetIdleTimer = () => {
useEffect(() => {
// first call to start the idle timer
debouncedResetIdleTimer();
return () => {
clearTimeout(idleTimer);
idleTimer = window.setTimeout(() => {
setIdle(true);
removeEventListeners();
startWarningTimer();
}, timeout);
clearTimeout(warningTimer);
debouncedResetIdleTimer.cancel();
};
}, []);

const startWarningTimer = () => {
setShowWarningModal(true);
clearTimeout(warningTimer);
warningTimer = window.setTimeout(() => {
onIdle();
setShowWarningModal(false);
}, warningTimeout);
useEffect(() => {
const handleEvent = () => {
handleActivityRef.current();
};

const removeEventListeners = () => {
const addEventListeners = () => {
const events = ['mousemove', 'keydown', 'mousedown', 'touchstart'];
events.forEach((event) => {
document.removeEventListener(event, handleActivity);
document.addEventListener(event, handleEvent);
});
};

const addEventListeners = () => {
const removeEventListeners = () => {
const events = ['mousemove', 'keydown', 'mousedown', 'touchstart'];
events.forEach((event) => {
document.addEventListener(event, handleActivity);
document.removeEventListener(event, handleEvent);
});
};

const handleActivity = () => {
resetIdleTimer();
};

if (!idle) {
addEventListeners();
resetIdleTimer();
}
addEventListeners();

return () => {
clearTimeout(idleTimer);
clearTimeout(warningTimer);
removeEventListeners();
};
}, [timeout, onIdle, idle]);
}, [timeout, onIdle]);

const handleContinue = () => {
setIdle(false);
debouncedResetIdleTimer();
};

const handleLogout = () => {
onIdle();
};

return (
<>
{showWarningModal && (
<div className="warning-modal">
<p>Warning: Only 2 minutes remaining of idle time!</p>
<Button
onClick={() => {
setShowWarningModal(false);
setIdle(false);
}}>
I'm still here
</Button>
</div>
)}
</>
idle && (
<Confirmation
title="Timeout"
confirmText="Continue"
cancelText="Logout"
forceAction={true}
onConfirm={handleContinue}
onCancel={handleLogout}>
Your session will timeout in {warningMins} minutes. Would you like to continue your session in NBS?
</Confirmation>
)
);
};

Expand Down
Loading