feat: Add configurable auto logout on inactivity

Users can pick an inactivity timeout in their preferences. A warning
appears 30 seconds before, and activity or a logout is synchronised
across open tabs.
This commit is contained in:
Daniel Hiller
2026-08-08 00:24:16 +02:00
parent a9d0c228df
commit 3ef87a8603
22 changed files with 603 additions and 2 deletions
+14
View File
@@ -98,10 +98,24 @@ logout.revokeAccessToken = () => ({
payload: {},
});
const showAutoLogoutWarning = (expiresAt) => ({
type: ActionTypes.AUTO_LOGOUT_WARNING_SHOW,
payload: {
expiresAt,
},
});
const dismissAutoLogoutWarning = () => ({
type: ActionTypes.AUTO_LOGOUT_WARNING_DISMISS,
payload: {},
});
export default {
initializeCore,
toggleFavorites,
toggleEditMode,
updateHomeView,
logout,
showAutoLogoutWarning,
dismissAutoLogoutWarning,
};
@@ -0,0 +1,71 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Modal } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
const AutoLogoutWarningModal = React.memo(() => {
const warning = useSelector(selectors.selectAutoLogoutWarning);
const dispatch = useDispatch();
const [t] = useTranslation();
const computeRemaining = useCallback(
() => (warning ? Math.max(0, Math.ceil((warning.expiresAt - Date.now()) / 1000)) : 0),
[warning],
);
const [remainingSeconds, setRemainingSeconds] = useState(computeRemaining);
useEffect(() => {
if (!warning) {
return undefined;
}
setRemainingSeconds(computeRemaining());
const interval = setInterval(() => {
setRemainingSeconds(computeRemaining());
}, 500);
return () => clearInterval(interval);
}, [warning, computeRemaining]);
const handleStayClick = useCallback(() => {
dispatch(entryActions.dismissAutoLogoutWarning());
}, [dispatch]);
const handleLogoutClick = useCallback(() => {
dispatch(entryActions.logout());
}, [dispatch]);
if (!warning) {
return null;
}
return (
<Modal open size="tiny" centered closeOnDimmerClick={false} closeOnEscape={false}>
<Modal.Header>{t('common.autoLogoutWarning_title')}</Modal.Header>
<Modal.Content>
<p>{t('common.autoLogoutWarning_body', { seconds: remainingSeconds })}</p>
</Modal.Content>
<Modal.Actions>
<Button onClick={handleLogoutClick}>{t('action.logOutNow')}</Button>
<Button
positive
content={t('action.stayLoggedIn')}
icon="checkmark"
onClick={handleStayClick}
/>
</Modal.Actions>
</Modal>
);
});
export default AutoLogoutWarningModal;
@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import AutoLogoutWarningModal from './AutoLogoutWarningModal';
export default AutoLogoutWarningModal;
@@ -17,6 +17,7 @@ import Fixed from '../Fixed';
import Static from '../Static';
import AdministrationModal from '../AdministrationModal';
import AboutModal from '../AboutModal';
import AutoLogoutWarningModal from '../AutoLogoutWarningModal';
import UserSettingsModal from '../../users/UserSettingsModal';
import ProjectBackground from '../../projects/ProjectBackground';
import AddProjectModal from '../../projects/AddProjectModal';
@@ -122,6 +123,7 @@ const Core = React.memo(() => {
<Fixed />
<Static />
{modalNode}
<AutoLogoutWarningModal />
</>
)}
{messageNode}
@@ -3,16 +3,35 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Radio, Tab } from 'semantic-ui-react';
import { Dropdown, Radio, Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { AutoLogoutModes } from '../../../constants/Enums';
import styles from './PreferencesPane.module.scss';
const AUTO_LOGOUT_OPTION_ORDER = [
AutoLogoutModes.MINUTES_2,
AutoLogoutModes.MINUTES_5,
AutoLogoutModes.MINUTES_10,
AutoLogoutModes.MINUTES_30,
AutoLogoutModes.HOURS_12,
AutoLogoutModes.NEVER,
];
const AUTO_LOGOUT_TRANSLATION_KEYS = {
[AutoLogoutModes.NEVER]: 'common.autoLogout_never',
[AutoLogoutModes.MINUTES_2]: 'common.autoLogout_2m',
[AutoLogoutModes.MINUTES_5]: 'common.autoLogout_5m',
[AutoLogoutModes.MINUTES_10]: 'common.autoLogout_10m',
[AutoLogoutModes.MINUTES_30]: 'common.autoLogout_30m',
[AutoLogoutModes.HOURS_12]: 'common.autoLogout_12h',
};
const PreferencesPane = React.memo(() => {
const user = useSelector(selectors.selectCurrentUser);
@@ -30,6 +49,29 @@ const PreferencesPane = React.memo(() => {
[dispatch],
);
const handleAutoLogoutChange = useCallback(
(_, { value }) => {
dispatch(
entryActions.updateCurrentUser({
autoLogoutMode: value,
}),
);
},
[dispatch],
);
const autoLogoutOptions = useMemo(
() =>
AUTO_LOGOUT_OPTION_ORDER.map((mode) => ({
key: mode,
value: mode,
text: t(AUTO_LOGOUT_TRANSLATION_KEYS[mode]),
})),
[t],
);
const autoLogoutValue = user.autoLogoutMode || AutoLogoutModes.MINUTES_30;
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Radio
@@ -56,6 +98,17 @@ const PreferencesPane = React.memo(() => {
className={styles.radio}
onChange={handleChange}
/>
<div className={styles.field}>
<span className={styles.label}>{t('common.autoLogout')}</span>
<Dropdown
fluid
selection
className={styles.dropdown}
options={autoLogoutOptions}
value={autoLogoutValue}
onChange={handleAutoLogoutChange}
/>
</div>
</Tab.Pane>
);
});
@@ -13,6 +13,22 @@
}
}
.field {
margin-top: 16px;
}
.label {
color: #444444;
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.dropdown {
width: 100%;
}
.wrapper {
border: none;
box-shadow: none;
+2
View File
@@ -55,6 +55,8 @@ export default {
/* Modals */
AUTO_LOGOUT_WARNING_SHOW: 'AUTO_LOGOUT_WARNING_SHOW',
AUTO_LOGOUT_WARNING_DISMISS: 'AUTO_LOGOUT_WARNING_DISMISS',
MODAL_OPEN: 'MODAL_OPEN',
MODAL_CLOSE: 'MODAL_CLOSE',
+1
View File
@@ -36,6 +36,7 @@ export default {
/* Modals */
AUTO_LOGOUT_WARNING_DISMISS: `${PREFIX}/AUTO_LOGOUT_WARNING_DISMISS`,
MODAL_OPEN: `${PREFIX}/MODAL_OPEN`,
MODAL_CLOSE: `${PREFIX}/MODAL_CLOSE`,
+9
View File
@@ -13,6 +13,15 @@ export const EditorModes = {
MARKUP: 'markup',
};
export const AutoLogoutModes = {
NEVER: 'never',
MINUTES_2: '2m',
MINUTES_5: '5m',
MINUTES_10: '10m',
MINUTES_30: '30m',
HOURS_12: '12h',
};
export const HomeViews = {
GRID_PROJECTS: 'gridProjects',
GROUPED_PROJECTS: 'groupedProjects',
+6
View File
@@ -33,9 +33,15 @@ const logout = (revokeAccessToken = true) => ({
},
});
const dismissAutoLogoutWarning = () => ({
type: EntryActionTypes.AUTO_LOGOUT_WARNING_DISMISS,
payload: {},
});
export default {
toggleFavorites,
toggleEditMode,
updateHomeView,
logout,
dismissAutoLogoutWarning,
};
+11
View File
@@ -106,6 +106,15 @@ export default {
attachment: 'Anhang',
attachments: 'Anhänge',
authentication: 'Authentifizierung',
autoLogout: 'Automatisches Ausloggen',
autoLogout_10m: 'Nach 10 Minuten',
autoLogout_12h: 'Nach 12 Stunden',
autoLogout_2m: 'Nach 2 Minuten',
autoLogout_30m: 'Nach 30 Minuten',
autoLogout_5m: 'Nach 5 Minuten',
autoLogout_never: 'Nie',
autoLogoutWarning_body: 'Sie werden in {{seconds}}s aufgrund von Inaktivität ausgeloggt.',
autoLogoutWarning_title: 'Sie werden bald ausgeloggt',
background: 'Hintergrund',
baseCustomFields_title: 'Feldgruppe',
baseGroup: 'Feldgruppe',
@@ -540,6 +549,7 @@ export default {
leaveBoard: 'Arbeitsbereich verlassen',
leaveProject: 'Projekt verlassen',
logOut_title: 'Ausloggen',
logOutNow: 'Jetzt abmelden',
makeCover_title: 'Als Vorschau festlegen',
makeProjectPrivate: 'Projekt privat machen',
makeProjectPrivate_title: 'Projekt privat machen',
@@ -574,6 +584,7 @@ export default {
showMore: 'Mehr anzeigen',
sortList_title: 'Liste sortieren',
start: 'Start',
stayLoggedIn: 'Angemeldet bleiben',
stop: 'Stopp',
subscribe: 'Abonnieren',
unsubscribe: 'De-abonnieren',
+11
View File
@@ -88,6 +88,15 @@ export default {
attachment: 'Attachment',
attachments: 'Attachments',
authentication: 'Authentication',
autoLogout: 'Auto logout',
autoLogout_10m: 'After 10 minutes',
autoLogout_12h: 'After 12 hours',
autoLogout_2m: 'After 2 minutes',
autoLogout_30m: 'After 30 minutes',
autoLogout_5m: 'After 5 minutes',
autoLogout_never: 'Never',
autoLogoutWarning_body: 'You will be logged out in {{seconds}}s due to inactivity.',
autoLogoutWarning_title: 'You will be logged out soon',
background: 'Background',
baseCustomFields_title: 'Base Custom Fields',
baseGroup: 'Base group',
@@ -514,6 +523,7 @@ export default {
leaveBoard: 'Leave board',
leaveProject: 'Leave project',
logOut_title: 'Log Out',
logOutNow: 'Log out now',
makeCover_title: 'Make Cover',
makeProjectPrivate: 'Make project private',
makeProjectPrivate_title: 'Make Project Private',
@@ -548,6 +558,7 @@ export default {
showMore: 'Show more',
sortList_title: 'Sort List',
start: 'Start',
stayLoggedIn: 'Stay logged in',
stop: 'Stop',
subscribe: 'Subscribe',
unsubscribe: 'Unsubscribe',
+1
View File
@@ -89,6 +89,7 @@ export default class extends BaseModel {
subscribeToCardWhenCommenting: attr(),
turnOffRecentCardHighlighting: attr(),
isDefaultAdmin: attr(),
autoLogoutMode: attr(),
isTotpEnabled: attr(),
totpEnabledAt: attr(),
totpRecoveryCodesRemaining: attr(),
+13
View File
@@ -26,6 +26,7 @@ const initialState = {
projectsSearch: '',
projectsOrder: ProjectOrders.BY_DEFAULT,
isHiddenProjectsVisible: false, // TODO: refactor?
autoLogoutWarning: null,
};
// eslint-disable-next-line default-param-last
@@ -117,6 +118,18 @@ export default (state = initialState, { type, payload }) => {
...state,
isLogouting: true,
};
case ActionTypes.AUTO_LOGOUT_WARNING_SHOW:
return {
...state,
autoLogoutWarning: {
expiresAt: payload.expiresAt,
},
};
case ActionTypes.AUTO_LOGOUT_WARNING_DISMISS:
return {
...state,
autoLogoutWarning: null,
};
case ActionTypes.MODAL_OPEN:
return {
...state,
+1
View File
@@ -17,6 +17,7 @@ export default function* coreSaga() {
yield apply(socket, socket.connect);
yield fork(services.initializeCore);
yield fork(services.autoLogout);
yield take(ActionTypes.LOGOUT);
@@ -0,0 +1,240 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import { buffers, eventChannel } from 'redux-saga';
import { call, cancelled, delay, put, race, select, take } from 'redux-saga/effects';
import { logout as logoutService } from './core';
import selectors from '../../../selectors';
import actions from '../../../actions';
import {
postActivity as broadcastActivity,
postLogout as broadcastLogout,
postStayLoggedIn as broadcastStayLoggedIn,
subscribe as subscribeToChannel,
} from '../../../utils/auto-logout-channel';
import { AutoLogoutModes } from '../../../constants/Enums';
import ActionTypes from '../../../constants/ActionTypes';
import EntryActionTypes from '../../../constants/EntryActionTypes';
const WARNING_LEAD_MS = 30 * 1000;
const ACTIVITY_THROTTLE_MS = 1000;
const BROADCAST_THROTTLE_MS = 2000;
const MODE_DURATIONS_MS = {
[AutoLogoutModes.MINUTES_2]: 2 * 60 * 1000,
[AutoLogoutModes.MINUTES_5]: 5 * 60 * 1000,
[AutoLogoutModes.MINUTES_10]: 10 * 60 * 1000,
[AutoLogoutModes.MINUTES_30]: 30 * 60 * 1000,
[AutoLogoutModes.HOURS_12]: 12 * 60 * 60 * 1000,
};
const ACTIVITY_EVENTS = ['mousedown', 'keydown', 'wheel', 'touchstart', 'mousemove'];
const EventTypes = {
LOCAL_ACTIVITY: 'localActivity',
REMOTE_ACTIVITY: 'remoteActivity',
REMOTE_LOGOUT: 'remoteLogout',
REMOTE_STAY_LOGGED_IN: 'remoteStayLoggedIn',
};
const MODE_CHANGE_TRIGGER_TYPES = [
ActionTypes.CORE_INITIALIZE,
ActionTypes.USER_UPDATE__SUCCESS,
ActionTypes.USER_UPDATE_HANDLE,
];
const selectCurrentUserAutoLogoutMode = (state) => {
const user = selectors.selectCurrentUser(state);
return user ? user.autoLogoutMode : null;
};
const createEventChannel = () =>
eventChannel((emit) => {
let lastActivityEmit = 0;
const handleActivity = () => {
const now = Date.now();
if (now - lastActivityEmit < ACTIVITY_THROTTLE_MS) {
return;
}
lastActivityEmit = now;
emit({ type: EventTypes.LOCAL_ACTIVITY });
};
ACTIVITY_EVENTS.forEach((event) => {
window.addEventListener(event, handleActivity, { passive: true });
});
const unsubscribe = subscribeToChannel((message) => {
if (!message || !message.type) {
return;
}
if (message.type === 'activity') {
emit({ type: EventTypes.REMOTE_ACTIVITY });
} else if (message.type === 'logout') {
emit({ type: EventTypes.REMOTE_LOGOUT });
} else if (message.type === 'stay-logged-in') {
emit({ type: EventTypes.REMOTE_STAY_LOGGED_IN });
}
});
return () => {
ACTIVITY_EVENTS.forEach((event) => {
window.removeEventListener(event, handleActivity);
});
unsubscribe();
};
}, buffers.sliding(8));
let lastBroadcastAt = 0;
function* maybeBroadcastActivity() {
const now = Date.now();
if (now - lastBroadcastAt < BROADCAST_THROTTLE_MS) {
return;
}
lastBroadcastAt = now;
yield call(broadcastActivity);
}
function* triggerLogout({ broadcast = true } = {}) {
if (broadcast) {
yield call(broadcastLogout);
}
yield put(actions.dismissAutoLogoutWarning());
yield call(logoutService, true);
}
function* waitForRelevantEvent(channel, predicate) {
while (true) {
const event = yield take(channel);
if (predicate(event)) {
return event;
}
}
}
function* runIdleMode(channel) {
// For NEVER mode: only react to remote logout broadcasts so a logout in another tab
// also signs out this tab.
yield call(waitForRelevantEvent, channel, (e) => e.type === EventTypes.REMOTE_LOGOUT);
yield call(triggerLogout, { broadcast: false });
}
function* runTimerMode(channel, mode) {
// Fall back to the safest known timer if the persisted mode is no longer recognized
// (e.g. partially-applied migration, hand-edited DB) — better than silently disabling.
const totalMs = MODE_DURATIONS_MS[mode] || MODE_DURATIONS_MS[AutoLogoutModes.MINUTES_30];
while (true) {
const phaseDuration = totalMs - WARNING_LEAD_MS;
const idleResult = yield race({
event: take(channel),
timeout: delay(phaseDuration > 0 ? phaseDuration : 0),
});
if (idleResult.event) {
const { type } = idleResult.event;
if (type === EventTypes.REMOTE_LOGOUT) {
yield call(triggerLogout, { broadcast: false });
return;
}
if (type === EventTypes.LOCAL_ACTIVITY) {
yield call(maybeBroadcastActivity);
}
// Any local/remote activity (or other events) just restarts the loop
// eslint-disable-next-line no-continue
continue;
}
// Timeout reached → show warning
const expiresAt = Date.now() + WARNING_LEAD_MS;
yield put(actions.showAutoLogoutWarning(expiresAt));
const warningResult = yield race({
dismiss: take(EntryActionTypes.AUTO_LOGOUT_WARNING_DISMISS),
channel: call(waitForRelevantEvent, channel, (e) =>
[
EventTypes.REMOTE_LOGOUT,
EventTypes.REMOTE_STAY_LOGGED_IN,
EventTypes.REMOTE_ACTIVITY,
].includes(e.type),
),
timeout: delay(WARNING_LEAD_MS),
});
if (warningResult.channel && warningResult.channel.type === EventTypes.REMOTE_LOGOUT) {
yield call(triggerLogout, { broadcast: false });
return;
}
yield put(actions.dismissAutoLogoutWarning());
if (warningResult.timeout) {
yield call(triggerLogout);
return;
}
if (warningResult.dismiss) {
yield call(broadcastStayLoggedIn);
}
// Loop continues — timer restarts (also resets when remote activity / stay-logged-in arrives)
}
}
function* runMode(channel, mode) {
if (!mode || mode === AutoLogoutModes.NEVER) {
yield call(runIdleMode, channel);
return;
}
yield call(runTimerMode, channel, mode);
}
function* waitForModeChange(currentMode) {
while (true) {
yield take(MODE_CHANGE_TRIGGER_TYPES);
const nextMode = yield select(selectCurrentUserAutoLogoutMode);
if (nextMode !== currentMode) {
return nextMode;
}
}
}
function* autoLogout() {
if (typeof window === 'undefined') {
return;
}
const channel = yield call(createEventChannel);
try {
while (true) {
const mode = yield select(selectCurrentUserAutoLogoutMode);
const result = yield race({
run: call(runMode, channel, mode),
modeChanged: call(waitForModeChange, mode),
});
if (!('modeChanged' in result)) {
// runMode completed without a mode change → logout already happened
return;
}
// Mode changed mid-run: clear any visible warning before re-entering with new mode
yield put(actions.dismissAutoLogoutWarning());
}
} finally {
channel.close();
if (yield cancelled()) {
yield put(actions.dismissAutoLogoutWarning());
}
}
}
export default {
autoLogout,
};
+2
View File
@@ -7,6 +7,7 @@ import router from './router';
import socket from './socket';
import bootstrap from './bootstrap';
import core from './core';
import autoLogout from './auto-logout';
import modals from './modals';
import config from './config';
import webhooks from './webhooks';
@@ -36,6 +37,7 @@ export default {
...socket,
...bootstrap,
...core,
...autoLogout,
...modals,
...config,
...webhooks,
+3
View File
@@ -15,6 +15,8 @@ export const selectClipboard = ({ core: { clipboard } }) => clipboard;
export const selectConfig = ({ core: { config } }) => config;
export const selectAutoLogoutWarning = ({ core: { autoLogoutWarning } }) => autoLogoutWarning;
export const selectRecentCardId = ({ core: { recentCardId } }) => recentCardId;
export const selectPrevCardId = ({ core: { prevCardIds } }) => prevCardIds.at(-1);
@@ -35,6 +37,7 @@ export default {
selectIsEditModeEnabled,
selectClipboard,
selectConfig,
selectAutoLogoutWarning,
selectRecentCardId,
selectPrevCardId,
selectHomeView,
+90
View File
@@ -0,0 +1,90 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const CHANNEL_NAME = 'planka-auto-logout';
const STORAGE_KEY = 'planka:auto-logout-message';
const TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
const supportsBroadcastChannel = typeof window !== 'undefined' && 'BroadcastChannel' in window;
let channel = null;
const listeners = new Set();
const dispatch = (message) => {
listeners.forEach((listener) => {
try {
listener(message);
} catch {
/* empty */
}
});
};
const handleStorageEvent = (event) => {
if (event.key !== STORAGE_KEY || !event.newValue) {
return;
}
try {
const message = JSON.parse(event.newValue);
if (message && message.tabId !== TAB_ID) {
dispatch(message);
}
} catch {
/* empty */
}
};
const ensureChannel = () => {
if (channel || typeof window === 'undefined') {
return;
}
if (supportsBroadcastChannel) {
channel = new BroadcastChannel(CHANNEL_NAME);
channel.addEventListener('message', (event) => {
if (event.data && event.data.tabId !== TAB_ID) {
dispatch(event.data);
}
});
} else {
window.addEventListener('storage', handleStorageEvent);
channel = { fallback: true };
}
};
const post = (message) => {
ensureChannel();
if (!channel) {
return;
}
const payload = { ...message, tabId: TAB_ID };
if (channel.fallback) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...payload, _ts: Date.now() }));
window.localStorage.removeItem(STORAGE_KEY);
} catch {
/* empty */
}
} else {
channel.postMessage(payload);
}
};
export const postActivity = () => post({ type: 'activity' });
export const postLogout = () => post({ type: 'logout' });
export const postStayLoggedIn = () => post({ type: 'stay-logged-in' });
export const subscribe = (handler) => {
ensureChannel();
listeners.add(handler);
return () => {
listeners.delete(handler);
};
};