diff --git a/ui/jest.config.ts b/ui/jest.config.ts index fb6a0889..d6c08bc9 100644 --- a/ui/jest.config.ts +++ b/ui/jest.config.ts @@ -19,7 +19,7 @@ const config: Config = { customExportConditions: [] }, setupFilesAfterEnv: [ - '/tests/setupJest.tsx' + '/tests/setupJest.ts' ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleDirectories: ['node_modules', ''], diff --git a/ui/package.json b/ui/package.json index 1a100b52..3d92dd95 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,6 +13,7 @@ "test:watch": "jest --watch" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", @@ -25,6 +26,7 @@ "next": "14.1.0", "react": "^18", "react-dom": "^18", + "react-idle-timer": "^5.7.2", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "uniqid": "^5.4.0" diff --git a/ui/src/components/layout/navbar/Navbar.tsx b/ui/src/components/layout/navbar/Navbar.tsx index a775626b..a9a5921c 100644 --- a/ui/src/components/layout/navbar/Navbar.tsx +++ b/ui/src/components/layout/navbar/Navbar.tsx @@ -5,47 +5,51 @@ import { getCurrentUser } from '@/access/AuthenticationService'; import MobileNavbar from './MobileNavbar'; import { NavLinks } from './NavLinks'; import Role from '@/access/Role'; +import TimeoutModal from '@/components/modal/TimeoutModal'; const Navbar = async () => { const currentUser = await getCurrentUser(); return ( - + + ); } diff --git a/ui/src/components/modal/TimeoutModal.tsx b/ui/src/components/modal/TimeoutModal.tsx new file mode 100644 index 00000000..c3479aa2 --- /dev/null +++ b/ui/src/components/modal/TimeoutModal.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + AlertDialog, + AlertDialogHeader, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel +} from '@/components/ui/alert-dialog'; +import { useIdleTimer } from 'react-idle-timer'; +import { logout } from '@/access/AuthenticationService'; +import User from '@/access/User'; +import Role from '@/access/Role'; + +const timeout = 1000 * 60 * 30; // Total timeout - 30 minutes in milliseconds +const promptBeforeIdle = 1000 * 60 * 5; // Time prior to timeout until modal opens - 5 minutes in milliseconds + +const TimeoutModal = ({ + currentUser +}: { + currentUser: User +}) => { + const [open, setOpen] = useState(false); + const [remainingTime, setRemainingTime] = useState(timeout); + + const { activate, getRemainingTime } = useIdleTimer({ + onIdle: () => logout(), + onPrompt: () => setOpen(true), + timeout, + promptBeforeIdle, + throttle: 500, + disabled: !currentUser.roles.includes(Role.UH) + }); + + useEffect(() => { + const interval = setInterval(() => { + setRemainingTime(getRemainingTime()); + }, 500); + + return () => { + clearInterval(interval); + }; + }); + + /** + * Closes the modal and resets the timer. + */ + const close = () => { + activate(); + setOpen(false); + }; + + /** + * Convert miliseconds into mm:ss string format. + * + * @param ms - the number of milliseconds + * + * @returns the mm:ss formatted string + */ + const formatTime = (ms: number) => { + const date = new Date(ms); + const minutes = date.getMinutes(); + const seconds = date.getSeconds(); + + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + return ( + + + + Inactivity Warning + + Warning! This session will expire soon. + Time remaining: + {formatTime(remainingTime)}. + + + + close()}>Stay logged in + logout()}>Log off now + + + + ); +} + +export default TimeoutModal; diff --git a/ui/src/components/ui/alert-dialog.tsx b/ui/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..23dc59af --- /dev/null +++ b/ui/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +'use client' + +import * as React from 'react' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' + +import { cn } from '@/components/ui/utils' +import { buttonVariants } from '@/components/ui/button' + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = 'AlertDialogHeader' + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = 'AlertDialogFooter' + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx index 60a2e796..587f9ce3 100644 --- a/ui/src/components/ui/button.tsx +++ b/ui/src/components/ui/button.tsx @@ -16,8 +16,7 @@ const buttonVariants = cva( destructive: `bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90`, outline: `border border-green-blue bg-white hover:bg-green-blue hover:text-white text-uh-teal`, - secondary: `bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 - dark:hover:bg-slate-800/80`, + secondary: `border border-transparent bg-[#f8f9fa] text-[#212529] hover:bg-uh-blue4 hover:text-white`, ghost: `hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50`, link: `text-slate-900 underline-offset-4 hover:underline dark:text-slate-50`, }, diff --git a/ui/tailwind.config.ts b/ui/tailwind.config.ts index 778cc5dd..c46c6b2e 100644 --- a/ui/tailwind.config.ts +++ b/ui/tailwind.config.ts @@ -23,6 +23,7 @@ const config = { extend: { colors: { 'green-blue': '#004e59', + 'uh-blue4': '#4c90a8', 'uh-black': '#212121', 'uh-teal': '#0d7078', 'seafoam': '#e3f2ef', diff --git a/ui/tests/components/modal/TimeoutModal.test.tsx b/ui/tests/components/modal/TimeoutModal.test.tsx new file mode 100644 index 00000000..053bff4f --- /dev/null +++ b/ui/tests/components/modal/TimeoutModal.test.tsx @@ -0,0 +1,110 @@ +import Role from '@/access/Role'; +import User from '@/access/User'; +import TimeoutModal from '@/components/modal/TimeoutModal'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import * as AuthenticationService from '@/access/AuthenticationService'; + +const testUser: User = JSON.parse(process.env.TEST_USER_A as string); + +jest.mock('@/access/AuthenticationService'); + +describe('TimeoutModal', () => { + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + testUser.roles = []; + }); + + it('should not open the timeout modal when the user is not logged-in', () => { + render(); + + act(() => jest.advanceTimersByTime(1000 * 60 * 25)); + fireEvent.focus(document); + + expect(screen.queryByRole('alertdialog', { name: 'Inactivity Warning' })).not.toBeInTheDocument(); + }); + + it('should not open the timeout modal before 25 minutes of idle', () => { + testUser.roles.push(Role.UH); + render(); + + act(() => jest.advanceTimersByTime(1000 * 60 * 24)); + fireEvent.focus(document); + + expect(screen.queryByRole('alertdialog', { name: 'Inactivity Warning' })).not.toBeInTheDocument(); + }); + + it('should open the timeout modal after 25 minutes of idle', () => { + testUser.roles.push(Role.UH); + render(); + + act(() => jest.advanceTimersByTime(1000 * 60 * 25)); + fireEvent.focus(document); + + expect(screen.getByRole('alertdialog', { name: 'Inactivity Warning' })).toBeInTheDocument(); + }); + + it('should display the countdown timer', () => { + testUser.roles.push(Role.UH); + render(); + + act(() => jest.advanceTimersByTime(1000 * 60 * 25)); + fireEvent.focus(document); + + expect(screen.getByRole('alertdialog', { name: 'Inactivity Warning' })).toBeInTheDocument(); + + for (let i = 5; i >= 0; i--) { + expect(screen.getByText(i + ':00.')).toBeInTheDocument(); + act(() => jest.advanceTimersByTime(1000 * 60)); + fireEvent.focus(document); + } + }); + + it('should reset the idle timer when "Stay logged in" is pressed', () => { + testUser.roles.push(Role.UH); + render(); + + act(() => jest.advanceTimersByTime(1000 * 60 * 25)); + fireEvent.focus(document); + + expect(screen.getByRole('alertdialog', { name: 'Inactivity Warning' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Stay logged in'})); + expect(screen.queryByRole('alertdialog', { name: 'Inactivity Warning' })).not.toBeInTheDocument(); + + act(() => jest.advanceTimersByTime(1000 * 60 * 24)); + fireEvent.focus(document); + + expect(screen.queryByRole('alertdialog', { name: 'Inactivity Warning' })).not.toBeInTheDocument(); + }); + + it('should logout when "Log off now" is pressed', () => { + const logoutSpy = jest.spyOn(AuthenticationService, 'logout'); + + testUser.roles.push(Role.UH); + render(); + + act(() => jest.advanceTimersByTime(1000 * 60 * 25)); + fireEvent.focus(document); + + expect(screen.getByRole('alertdialog', { name: 'Inactivity Warning' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Log off now'})); + expect(logoutSpy).toHaveBeenCalled(); + }); + + it('should logout after 30 minutes of idle', () => { + const logoutSpy = jest.spyOn(AuthenticationService, 'logout'); + + testUser.roles.push(Role.UH); + render(); + + act(() => jest.advanceTimersByTime(1000 * 60 * 30 + 1)); + fireEvent.focus(document); + + expect(logoutSpy).toHaveBeenCalled(); + }); + +}); diff --git a/ui/tests/setupJest.tsx b/ui/tests/setupJest.ts similarity index 100% rename from ui/tests/setupJest.tsx rename to ui/tests/setupJest.ts