diff --git a/src/App.tsx b/src/App.tsx index 0ba4c25..f54cf98 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import {BrowserRouter as Router, Route, Routes} from "react-router-dom"; import Exchanges from './components/Exchanges'; import Subscriptions from './components/Subscriptions'; import {useAuthApi} from "./client/components"; +import {useIdleLogout} from "./hooks/useIdleLogout"; import Login from "./components/Login"; import Documents from "./components/Documents"; import Partners from "./components/Partners"; @@ -45,6 +46,8 @@ function App() { const {isLoggedIn} = useAuthApi(); + useIdleLogout(!!isLoggedIn); + useEffect(() => { // Check if WorkGroups API is available const checkWorkGroupsAvailability = async () => { diff --git a/src/components/Settings/AddMemberModal.tsx b/src/components/Settings/AddMemberModal.tsx index f261f12..0f1477b 100644 --- a/src/components/Settings/AddMemberModal.tsx +++ b/src/components/Settings/AddMemberModal.tsx @@ -5,6 +5,7 @@ import FormField from "src/components/common/forms/FormField"; import TextEditor from "src/components/common/forms/TextEditor"; import {apiClient} from "src/client"; import {ChoiceEditor} from "src/components/common/forms/ChoiceEditor"; +import {validatePassword} from "src/utils/passwordPolicy"; function isValidEmail(email: string) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -27,9 +28,7 @@ const AddMemberModal: React.FC = ({onClose}) => { if (state.name?.length < 3) { errors.push("Name must be longer than 3 characters") } - if (state.password?.length < 7) { - errors.push("Password must be longer than 8 characters") - } + errors.push(...validatePassword(state.password)) if (errors.length > 0) { setErrors(errors) return; diff --git a/src/components/Settings/ChangePasswordModal.tsx b/src/components/Settings/ChangePasswordModal.tsx index 73bcd5c..02236d1 100644 --- a/src/components/Settings/ChangePasswordModal.tsx +++ b/src/components/Settings/ChangePasswordModal.tsx @@ -4,6 +4,7 @@ import {apiClient} from "src/client"; import Modal from "src/components/common/Modal"; import FormField from "src/components/common/forms/FormField"; import TextEditor from "src/components/common/forms/TextEditor"; +import {validatePassword} from "src/utils/passwordPolicy"; type Props = { onClose: () => void @@ -25,9 +26,7 @@ const ChangePasswordModal: React.FC = ({onClose}) => { errors.push("Passwords dont match") } - if (state.newPassword?.length < 7) { - errors.push("Password must be longer than 8 characters") - } + errors.push(...validatePassword(state.newPassword)) if (errors.length > 0) { setErrors(errors) diff --git a/src/hooks/useIdleLogout.ts b/src/hooks/useIdleLogout.ts new file mode 100644 index 0000000..3065a7e --- /dev/null +++ b/src/hooks/useIdleLogout.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef } from "react"; +import authConfig from "src/authConfig"; + +const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes +const CHECK_INTERVAL_MS = 30 * 1000; // re-check every 30s +const ACTIVITY_EVENTS = ["mousemove", "mousedown", "keydown", "scroll", "touchstart"]; +// Shared so activity in any tab keeps every tab alive (see the interval below). +const LAST_ACTIVITY_KEY = "last_activity"; + +const readSharedActivity = (): number => { + try { + return Number(localStorage.getItem(LAST_ACTIVITY_KEY)) || 0; + } catch { + return 0; // storage unavailable: fall back to this tab's own timer + } +}; + +const writeSharedActivity = (ts: number) => { + try { + localStorage.setItem(LAST_ACTIVITY_KEY, String(ts)); + } catch { + // ignore: the in-tab ref still tracks activity + } +}; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +// Logs the user out after a period of no activity in any tab. Reuses the existing +// logout handler (server logout + clear token + reload), so an idle session behaves +// exactly like clicking Sign Out. +export const useIdleLogout = (enabled: boolean, timeoutMs: number = IDLE_TIMEOUT_MS) => { + const lastActivity = useRef(Date.now()); + + useEffect(() => { + if (!enabled) return; + + // Start counting from the moment we become enabled. The ref is created when + // App first mounts, which is while the login page is showing and no activity + // listeners are attached, so without this a login page left open longer than + // timeoutMs would log the user out immediately after signing in. + const enabledAt = Date.now(); + lastActivity.current = enabledAt; + writeSharedActivity(enabledAt); + + const markActivity = () => { + const ts = Date.now(); + lastActivity.current = ts; + writeSharedActivity(ts); + }; + ACTIVITY_EVENTS.forEach(e => window.addEventListener(e, markActivity, { passive: true })); + + const interval = window.setInterval(async () => { + // Activity events only fire in the focused tab, so take the most recent + // activity across tabs. Otherwise a background tab would log out a user + // who is actively working in another one. + const last = Math.max(lastActivity.current, readSharedActivity()); + if (Date.now() - last < timeoutMs) return; + + window.clearInterval(interval); + ACTIVITY_EVENTS.forEach(e => window.removeEventListener(e, markActivity)); + try { + await authConfig.logOutHandler(); // server logout + clear + reload + } catch { + // Only the server call invalidates the refresh-token cookie (it is + // HttpOnly, so JS cannot clear it). Retry once before giving up. + await sleep(2000); + try { + await authConfig.logOutHandler(); + } catch { + localStorage.removeItem("access_token"); // safety net if the call fails + window.location.reload(); + } + } + }, CHECK_INTERVAL_MS); + + return () => { + window.clearInterval(interval); + ACTIVITY_EVENTS.forEach(e => window.removeEventListener(e, markActivity)); + }; + }, [enabled, timeoutMs]); +}; diff --git a/src/utils/__tests__/passwordPolicy.test.ts b/src/utils/__tests__/passwordPolicy.test.ts new file mode 100644 index 0000000..9a7c59d --- /dev/null +++ b/src/utils/__tests__/passwordPolicy.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { validatePassword } from '../passwordPolicy'; + +const TOO_SHORT = 'Password must be at least 8 characters.'; +const NO_UPPER = 'Password must contain an uppercase letter.'; +const NO_LOWER = 'Password must contain a lowercase letter.'; +const NO_NUMBER = 'Password must contain a number.'; +const NO_SPECIAL = 'Password must contain a special character.'; + +describe('validatePassword', () => { + it('reports every rule for undefined', () => { + expect(validatePassword(undefined)).toEqual([ + TOO_SHORT, NO_UPPER, NO_LOWER, NO_NUMBER, NO_SPECIAL, + ]); + }); + + it('reports every rule for an empty string, in order', () => { + expect(validatePassword('')).toEqual([ + TOO_SHORT, NO_UPPER, NO_LOWER, NO_NUMBER, NO_SPECIAL, + ]); + }); + + it('rejects 7 characters and accepts 8 (length boundary)', () => { + expect(validatePassword('Abc1!de')).toEqual([TOO_SHORT]); // 7 chars, all classes present + expect(validatePassword('Abc1!def')).toEqual([]); // 8 chars + }); + + it('flags a missing uppercase letter', () => { + expect(validatePassword('abc1!def')).toEqual([NO_UPPER]); + }); + + it('flags a missing lowercase letter', () => { + expect(validatePassword('ABC1!DEF')).toEqual([NO_LOWER]); + }); + + it('flags a missing number', () => { + expect(validatePassword('Abcd!efg')).toEqual([NO_NUMBER]); + }); + + it('flags a missing special character', () => { + expect(validatePassword('Abcd1efg')).toEqual([NO_SPECIAL]); + }); + + it('accepts a valid password', () => { + expect(validatePassword('Str0ng!Pass')).toEqual([]); + }); +}); diff --git a/src/utils/passwordPolicy.ts b/src/utils/passwordPolicy.ts new file mode 100644 index 0000000..65bc605 --- /dev/null +++ b/src/utils/passwordPolicy.ts @@ -0,0 +1,13 @@ +// Client-side mirror of the server password policy. The server is the +// enforcement point; this just gives the user faster feedback with the same +// messages. Keep in sync with PasswordValidationExtensions on the backend. +export const validatePassword = (password: string | undefined): string[] => { + const p = password ?? ""; + const errors: string[] = []; + if (p.length < 8) errors.push("Password must be at least 8 characters."); + if (!/[A-Z]/.test(p)) errors.push("Password must contain an uppercase letter."); + if (!/[a-z]/.test(p)) errors.push("Password must contain a lowercase letter."); + if (!/[0-9]/.test(p)) errors.push("Password must contain a number."); + if (!/[^A-Za-z0-9]/.test(p)) errors.push("Password must contain a special character."); + return errors; +};