From b632b318a9a9e4105c576c68d6970df18fd132b7 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 10 Aug 2026 18:00:09 +0300 Subject: [PATCH 1/3] Add idle-timeout auto-logout after 30 minutes of inactivity --- src/App.tsx | 3 +++ src/hooks/useIdleLogout.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/hooks/useIdleLogout.ts 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/hooks/useIdleLogout.ts b/src/hooks/useIdleLogout.ts new file mode 100644 index 0000000..de27c1b --- /dev/null +++ b/src/hooks/useIdleLogout.ts @@ -0,0 +1,37 @@ +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"]; + +// Logs the user out after a period of no activity. 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; + + const markActivity = () => { lastActivity.current = Date.now(); }; + ACTIVITY_EVENTS.forEach(e => window.addEventListener(e, markActivity, { passive: true })); + + const interval = window.setInterval(async () => { + if (Date.now() - lastActivity.current < timeoutMs) return; + window.clearInterval(interval); + ACTIVITY_EVENTS.forEach(e => window.removeEventListener(e, markActivity)); + try { + await authConfig.logOutHandler(); // server logout + clear + reload + } 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]); +}; From 20dc4220b620733502aa5c38a2f7d02c7983b82d Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 11 Aug 2026 16:58:41 +0300 Subject: [PATCH 2/3] Enforce the password policy in the member and change-password forms --- src/components/Settings/AddMemberModal.tsx | 5 ++--- src/components/Settings/ChangePasswordModal.tsx | 5 ++--- src/utils/passwordPolicy.ts | 13 +++++++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 src/utils/passwordPolicy.ts 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/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; +}; From 17236f741646d5dc66ce04a09538579453672d97 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 3 Sep 2026 11:24:19 +0300 Subject: [PATCH 3/3] Share idle activity across tabs and reset it on login Activity events only fire in the focused tab, so a background tab could log out a user working in another one. Track the last activity in localStorage and take the most recent across tabs. Also reset the timestamp when idle logout becomes enabled, otherwise a login page left open longer than the timeout logged the user straight back out, and retry the server logout once before falling back, since only that call can invalidate the HttpOnly refresh cookie. Adds tests for validatePassword. --- src/hooks/useIdleLogout.ts | 56 +++++++++++++++++++--- src/utils/__tests__/passwordPolicy.test.ts | 47 ++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 src/utils/__tests__/passwordPolicy.test.ts diff --git a/src/hooks/useIdleLogout.ts b/src/hooks/useIdleLogout.ts index de27c1b..3065a7e 100644 --- a/src/hooks/useIdleLogout.ts +++ b/src/hooks/useIdleLogout.ts @@ -4,9 +4,29 @@ 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"; -// Logs the user out after a period of no activity. Reuses the existing logout -// handler (server logout + clear token + reload), so an idle session behaves +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()); @@ -14,18 +34,42 @@ export const useIdleLogout = (enabled: boolean, timeoutMs: number = IDLE_TIMEOUT useEffect(() => { if (!enabled) return; - const markActivity = () => { lastActivity.current = Date.now(); }; + // 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 () => { - if (Date.now() - lastActivity.current < timeoutMs) return; + // 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 { - localStorage.removeItem("access_token"); // safety net if the call fails - window.location.reload(); + // 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); 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([]); + }); +});