Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: support idle management in AuthClient (#547)
* feat: idleManager * feat: setup idleManager in authClient * test: full lifecycle test of idle logout * some basic documentation * feat: registerActor and registerCallback Allows the authClient to register actors to automatically invalidate them going idle, or revalidate them after logging in * fixing test typing * reverting actorMap and using onIdle for creation options * friendlier JSDOC formatting * changelog * typescript checks for tests * feat: idleManager * feat: setup idleManager in authClient * test: full lifecycle test of idle logout * some basic documentation * feat: registerActor and registerCallback Allows the authClient to register actors to automatically invalidate them going idle, or revalidate them after logging in * fixing test typing * reverting actorMap and using onIdle for creation options * friendlier JSDOC formatting * changelog * typescript checks for tests * chore: setting default idle timeout to 10 minutes
- Loading branch information
Showing
6 changed files
with
427 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
import IdleManager from './idleManager'; | ||
|
||
jest.useFakeTimers(); | ||
|
||
describe('IdleManager tests', () => { | ||
it('should call its callback after time spent inactive', () => { | ||
const cb = jest.fn(); | ||
const manager = IdleManager.create({ onIdle: cb, captureScroll: true }); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user being inactive for 10 minutes | ||
jest.advanceTimersByTime(10 * 60 * 1000); | ||
expect(cb).toHaveBeenCalled(); | ||
manager.exit(); | ||
}); | ||
it('should delay allow configuration of the timeout', () => { | ||
const cb = jest.fn(); | ||
const extraDelay = 100; | ||
IdleManager.create({ onIdle: cb, idleTimeout: 10 * 60 * 1000 + extraDelay }); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user being inactive for 10 minutes | ||
jest.advanceTimersByTime(10 * 60 * 1000); | ||
expect(cb).not.toHaveBeenCalled(); | ||
jest.advanceTimersByTime(extraDelay); | ||
expect(cb).toHaveBeenCalled(); | ||
}); | ||
it('should delay its callback on keyboard events', () => { | ||
const cb = jest.fn(); | ||
const manager = IdleManager.create({ onIdle: cb }); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user being inactive for 9 minutes | ||
jest.advanceTimersByTime(9 * 60 * 1000); | ||
expect(cb).not.toHaveBeenCalled(); | ||
document.dispatchEvent(new KeyboardEvent('keypress')); | ||
|
||
// wait 5 minutes | ||
jest.advanceTimersByTime(5 * 60 * 1000); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user being inactive for 9 minutes | ||
jest.advanceTimersByTime(9 * 60 * 1000); | ||
expect(cb).toHaveBeenCalled(); | ||
}); | ||
it('should delay its callback on mouse events', () => { | ||
const cb = jest.fn(); | ||
const manager = IdleManager.create({ onIdle: cb }); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user being inactive for 9 minutes | ||
jest.advanceTimersByTime(9 * 60 * 1000); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user moving the mouse | ||
document.dispatchEvent(new MouseEvent('mousemove')); | ||
|
||
// wait 5 minutes | ||
jest.advanceTimersByTime(5 * 60 * 1000); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user being inactive for 9 minutes | ||
jest.advanceTimersByTime(9 * 60 * 1000); | ||
expect(cb).toHaveBeenCalled(); | ||
}); | ||
|
||
it('should delay its callback on touch events', () => { | ||
const cb = jest.fn(); | ||
const manager = IdleManager.create({ onIdle: cb }); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user being inactive for 9 minutes | ||
jest.advanceTimersByTime(9 * 60 * 1000); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user touching the screen | ||
document.dispatchEvent(new TouchEvent('touchstart')); | ||
|
||
// wait 5 minutes | ||
jest.advanceTimersByTime(5 * 60 * 1000); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user being inactive for 9 minutes | ||
jest.advanceTimersByTime(9 * 60 * 1000); | ||
expect(cb).toHaveBeenCalled(); | ||
}); | ||
it('should delay its callback on scroll events', () => { | ||
const cb = jest.fn(); | ||
|
||
const scrollDebounce = 100; | ||
|
||
const manager = IdleManager.create({ onIdle: cb, captureScroll: true, scrollDebounce }); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user being inactive for 9 minutes | ||
jest.advanceTimersByTime(9 * 60 * 1000); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user scrolling | ||
document.dispatchEvent(new WheelEvent('scroll')); | ||
|
||
// wait 5 minutes | ||
jest.advanceTimersByTime(5 * 60 * 1000); | ||
expect(cb).not.toHaveBeenCalled(); | ||
// simulate user being inactive for 9 minutes, plus the debounce | ||
jest.advanceTimersByTime(9 * 60 * 1000 + scrollDebounce); | ||
expect(cb).toHaveBeenCalled(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
type IdleCB = () => unknown; | ||
export type IdleManagerOptions = { | ||
/** | ||
* Callback after the user has gone idle | ||
*/ | ||
onIdle?: IdleCB; | ||
/** | ||
* timeout in ms | ||
* @default 30 minutes [600_000] | ||
*/ | ||
idleTimeout?: number; | ||
/** | ||
* capture scroll events | ||
* @default false | ||
*/ | ||
captureScroll?: boolean; | ||
/** | ||
* scroll debounce time in ms | ||
* @default 100 | ||
*/ | ||
scrollDebounce?: number; | ||
}; | ||
|
||
const events = ['mousedown', 'mousemove', 'keypress', 'touchstart', 'wheel']; | ||
|
||
/** | ||
* Detects if the user has been idle for a duration of `idleTimeout` ms, and calls `onIdle`. | ||
* | ||
* @param {IdleManagerOptions} options | ||
*/ | ||
class IdleManager { | ||
callbacks: IdleCB[] = []; | ||
idleTimeout: IdleManagerOptions['idleTimeout'] = 30 * 60 * 1000; | ||
timeoutID?: number = undefined; | ||
|
||
/** | ||
* Creates an idle manager | ||
* @param {IdleManagerOptions} options Optional configuration | ||
* @param options.onIdle Callback once user has been idle. Use to prompt for fresh login, and use Actor.agentOf(your_actor).invalidateIdentity() to protect the user | ||
* @param options.idleTimeout timeout in ms | ||
* @param options.captureScroll capture scroll events | ||
* @param options.scrollDebounce scroll debounce time in ms | ||
*/ | ||
public static create( | ||
options: { | ||
/** | ||
* Callback after the user has gone idle | ||
*/ | ||
onIdle?: IdleCB; | ||
/** | ||
* timeout in ms | ||
* @default 10 minutes [600_000] | ||
*/ | ||
idleTimeout?: number; | ||
/** | ||
* capture scroll events | ||
* @default false | ||
*/ | ||
captureScroll?: boolean; | ||
/** | ||
* scroll debounce time in ms | ||
* @default 100 | ||
*/ | ||
scrollDebounce?: number; | ||
} = {}, | ||
): IdleManager { | ||
return new this(options); | ||
} | ||
|
||
protected constructor(options: IdleManagerOptions = {}) { | ||
const { onIdle, idleTimeout = 10 * 60 * 1000 } = options || {}; | ||
this.callbacks = onIdle ? [onIdle] : []; | ||
this.idleTimeout = idleTimeout; | ||
|
||
const resetTimer = this.resetTimer.bind(this); | ||
|
||
window.addEventListener('load', resetTimer, true); | ||
|
||
events.forEach(function (name) { | ||
document.addEventListener(name, resetTimer, true); | ||
}); | ||
|
||
// eslint-disable-next-line @typescript-eslint/ban-types | ||
const debounce = (func: Function, wait: number) => { | ||
let timeout: number | undefined; | ||
return (...args: unknown[]) => { | ||
// eslint-disable-next-line @typescript-eslint/no-this-alias | ||
const context = this; | ||
const later = function () { | ||
timeout = undefined; | ||
func.apply(context, args); | ||
}; | ||
clearTimeout(timeout); | ||
timeout = window.setTimeout(later, wait); | ||
}; | ||
}; | ||
|
||
if (options?.captureScroll) { | ||
// debounce scroll events | ||
const scroll = debounce(resetTimer, options?.scrollDebounce ?? 100); | ||
window.addEventListener('scroll', scroll, true); | ||
} | ||
|
||
resetTimer(); | ||
} | ||
|
||
/** | ||
* adds a callback to the list of callbacks | ||
* @param {IdleCB} callback | ||
*/ | ||
public registerCallback(callback: IdleCB): void { | ||
this.callbacks.push(callback); | ||
} | ||
|
||
/** | ||
* Cleans up the idle manager and its listeners | ||
*/ | ||
public exit(): void { | ||
this.callbacks.forEach(cb => cb()); | ||
clearTimeout(this.timeoutID); | ||
window.removeEventListener('load', this.resetTimer, true); | ||
|
||
const resetTimer = this.resetTimer.bind(this); | ||
events.forEach(function (name) { | ||
document.removeEventListener(name, resetTimer, true); | ||
}); | ||
} | ||
|
||
resetTimer(): void { | ||
const exit = this.exit.bind(this); | ||
window.clearTimeout(this.timeoutID); | ||
this.timeoutID = window.setTimeout(exit, this.idleTimeout); | ||
} | ||
} | ||
|
||
export default IdleManager; |
Oops, something went wrong.