mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
140 lines
4.5 KiB
TypeScript
140 lines
4.5 KiB
TypeScript
/**
|
|
* The clock behind the organisation's idle timeout and maximum session
|
|
* length. It knows the last activity and the limits, and says when the
|
|
* warning starts and when the session ends; the guard component
|
|
* (components/session/session-timeout-guard.tsx) feeds it activity and
|
|
* does the signing out.
|
|
*
|
|
* Deadlines are times, not countdowns: a laptop that slept through one
|
|
* expires on the first check after waking, and activity that arrives after
|
|
* the deadline (the focus event of the wake-up itself) can't bring the
|
|
* session back.
|
|
*/
|
|
|
|
import type { SessionLimits } from './limits';
|
|
|
|
export type SessionEndKind = 'idle' | 'max';
|
|
|
|
export interface SessionTimerState {
|
|
phase: 'active' | 'warning' | 'expired';
|
|
/** Which limit the next deadline belongs to; null when no limit is set. */
|
|
kind: SessionEndKind | null;
|
|
/** When the session ends (ms); null when no limit is set. */
|
|
deadline: number | null;
|
|
}
|
|
|
|
export interface SessionTimerOptions {
|
|
limits: SessionLimits;
|
|
lastActivity: number;
|
|
onChange?: (state: SessionTimerState) => void;
|
|
onExpire: (kind: SessionEndKind) => void;
|
|
now?: () => number;
|
|
}
|
|
|
|
export interface SessionTimer {
|
|
/** Activity in this tab. Ignored while the idle warning shows: the user answers it with "Stay signed in". */
|
|
activity(at?: number): void;
|
|
/** Activity in another tab, where the user may have answered the warning already. */
|
|
remoteActivity(at: number): void;
|
|
/** "Stay signed in": restarts the idle time, never the maximum. */
|
|
stay(): void;
|
|
setLimits(limits: SessionLimits): void;
|
|
/** Look at the clock now (after a wake-up or a tab becoming visible). */
|
|
check(): void;
|
|
lastActivity(): number;
|
|
state(): SessionTimerState;
|
|
dispose(): void;
|
|
}
|
|
|
|
const MINUTE_MS = 60 * 1000;
|
|
const MAX_WAIT_MS = 60 * MINUTE_MS;
|
|
|
|
export function createSessionTimer(options: SessionTimerOptions): SessionTimer {
|
|
const now = options.now ?? Date.now;
|
|
let limits = options.limits;
|
|
let lastActivity = options.lastActivity;
|
|
let state: SessionTimerState = { phase: 'active', kind: null, deadline: null };
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
let disposed = false;
|
|
|
|
const clear = () => {
|
|
if (timer) clearTimeout(timer);
|
|
timer = null;
|
|
};
|
|
|
|
const publish = (next: SessionTimerState) => {
|
|
if (next.phase === state.phase && next.kind === state.kind && next.deadline === state.deadline) return;
|
|
state = next;
|
|
options.onChange?.(state);
|
|
};
|
|
|
|
/** The deadline that comes first, with how long its warning lasts. */
|
|
const target = (): { kind: SessionEndKind; deadline: number; warnMs: number } | null => {
|
|
const idleMs = limits.idle ? limits.idle.minutes * MINUTE_MS : 0;
|
|
const idle = limits.idle
|
|
? { kind: 'idle' as const, deadline: lastActivity + idleMs, warnMs: Math.min(limits.idle.warnSeconds * 1000, idleMs) }
|
|
: null;
|
|
const max = limits.max
|
|
? { kind: 'max' as const, deadline: limits.max.at, warnMs: limits.max.warnSeconds * 1000 }
|
|
: null;
|
|
if (idle && max) return max.deadline <= idle.deadline ? max : idle;
|
|
return idle ?? max;
|
|
};
|
|
|
|
const evaluate = () => {
|
|
if (disposed || state.phase === 'expired') return;
|
|
clear();
|
|
const next = target();
|
|
if (!next) {
|
|
publish({ phase: 'active', kind: null, deadline: null });
|
|
return;
|
|
}
|
|
const at = now();
|
|
if (at >= next.deadline) {
|
|
publish({ phase: 'expired', kind: next.kind, deadline: next.deadline });
|
|
options.onExpire(next.kind);
|
|
return;
|
|
}
|
|
const warnFrom = next.deadline - next.warnMs;
|
|
const warning = at >= warnFrom;
|
|
publish({ phase: warning ? 'warning' : 'active', kind: next.kind, deadline: next.deadline });
|
|
// Capped: setTimeout overflows past 24.8 days, and a long wait is checked again anyway.
|
|
timer = setTimeout(evaluate, Math.min((warning ? next.deadline : warnFrom) - at, MAX_WAIT_MS));
|
|
};
|
|
|
|
const record = (at: number) => {
|
|
// A deadline that already passed ends the session before the activity counts.
|
|
evaluate();
|
|
if (state.phase === 'expired') return;
|
|
if (at > lastActivity) lastActivity = at;
|
|
evaluate();
|
|
};
|
|
|
|
evaluate();
|
|
|
|
return {
|
|
activity(at = now()) {
|
|
evaluate();
|
|
if (state.phase === 'warning' && state.kind === 'idle') return;
|
|
record(at);
|
|
},
|
|
remoteActivity(at) {
|
|
record(at);
|
|
},
|
|
stay() {
|
|
record(now());
|
|
},
|
|
setLimits(next) {
|
|
limits = next;
|
|
evaluate();
|
|
},
|
|
check: evaluate,
|
|
lastActivity: () => lastActivity,
|
|
state: () => state,
|
|
dispose() {
|
|
disposed = true;
|
|
clear();
|
|
},
|
|
};
|
|
}
|