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:
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -33,9 +33,15 @@ const logout = (revokeAccessToken = true) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const dismissAutoLogoutWarning = () => ({
|
||||
type: EntryActionTypes.AUTO_LOGOUT_WARNING_DISMISS,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
export default {
|
||||
toggleFavorites,
|
||||
toggleEditMode,
|
||||
updateHomeView,
|
||||
logout,
|
||||
dismissAutoLogoutWarning,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -89,6 +89,7 @@ export default class extends BaseModel {
|
||||
subscribeToCardWhenCommenting: attr(),
|
||||
turnOffRecentCardHighlighting: attr(),
|
||||
isDefaultAdmin: attr(),
|
||||
autoLogoutMode: attr(),
|
||||
isTotpEnabled: attr(),
|
||||
totpEnabledAt: attr(),
|
||||
totpRecoveryCodesRemaining: attr(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user