-
Notifications
You must be signed in to change notification settings - Fork 1
Add idle-timeout auto-logout and enforce the password policy in forms #167
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<number>(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 | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } 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]); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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([]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }; | ||
|
Comment on lines
+4
to
+13
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win 🔎 Supported by static analysis🤖 get_repo_knowledge executed:
Length of output: 2651 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
git ls-files | grep -E '(^|/)(passwordPolicy|.*[Pp]assword.*|.*[Mm]ember.*|.*[Cc]hange.*)' | head -200
printf '%s\n' '--- password validator references ---'
rg -n --glob '!node_modules' --glob '!dist' 'validatePassword|Password must be at least|createMember|changePassword' src test tests 2>/dev/null | head -300
printf '%s\n' '--- package test scripts ---'
if [ -f package.json ]; then sed -n '1,180p' package.json; fiRepository: simplify9/Bitween-UI Length of output: 4889 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- password policy ---'
cat -n src/utils/passwordPolicy.ts
printf '%s\n' '--- add member flow ---'
cat -n src/components/Settings/AddMemberModal.tsx
printf '%s\n' '--- change password flow ---'
cat -n src/components/Settings/ChangePasswordModal.tsx
printf '%s\n' '--- tracked test files ---'
git ls-files | grep -E '(^|/)([^/]*(test|spec)[^/]*)\.(ts|tsx|js|jsx)$' || true
printf '%s\n' '--- validator-related test references ---'
rg -n --glob '*.{test,spec}.{ts,tsx,js,jsx}' 'validatePassword|createMember|changePassword|AddMemberModal|ChangePasswordModal' . || trueRepository: simplify9/Bitween-UI Length of output: 7712 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- change in password policy ---'
git diff -- src/utils/passwordPolicy.ts
printf '%s\n' '--- all tracked test-like paths ---'
git ls-files | grep -E '(^|/)(__tests__|tests?)/|(\.|/)(test|spec)\.' || true
printf '%s\n' '--- test configuration ---'
git ls-files | grep -E '(^|/)(vitest|vite|tsconfig|setupTests|test)[^/]*\.(ts|tsx|js|json|cjs|mjs)$' | head -100Repository: simplify9/Bitween-UI Length of output: 525 Add focused tests for 🤖 Prompt for AI AgentsSource: Path instructions |
||
Uh oh!
There was an error while loading. Please reload this page.