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: {},
|
payload: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showAutoLogoutWarning = (expiresAt) => ({
|
||||||
|
type: ActionTypes.AUTO_LOGOUT_WARNING_SHOW,
|
||||||
|
payload: {
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissAutoLogoutWarning = () => ({
|
||||||
|
type: ActionTypes.AUTO_LOGOUT_WARNING_DISMISS,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
initializeCore,
|
initializeCore,
|
||||||
toggleFavorites,
|
toggleFavorites,
|
||||||
toggleEditMode,
|
toggleEditMode,
|
||||||
updateHomeView,
|
updateHomeView,
|
||||||
logout,
|
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 Static from '../Static';
|
||||||
import AdministrationModal from '../AdministrationModal';
|
import AdministrationModal from '../AdministrationModal';
|
||||||
import AboutModal from '../AboutModal';
|
import AboutModal from '../AboutModal';
|
||||||
|
import AutoLogoutWarningModal from '../AutoLogoutWarningModal';
|
||||||
import UserSettingsModal from '../../users/UserSettingsModal';
|
import UserSettingsModal from '../../users/UserSettingsModal';
|
||||||
import ProjectBackground from '../../projects/ProjectBackground';
|
import ProjectBackground from '../../projects/ProjectBackground';
|
||||||
import AddProjectModal from '../../projects/AddProjectModal';
|
import AddProjectModal from '../../projects/AddProjectModal';
|
||||||
@@ -122,6 +123,7 @@ const Core = React.memo(() => {
|
|||||||
<Fixed />
|
<Fixed />
|
||||||
<Static />
|
<Static />
|
||||||
{modalNode}
|
{modalNode}
|
||||||
|
<AutoLogoutWarningModal />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{messageNode}
|
{messageNode}
|
||||||
|
|||||||
@@ -3,16 +3,35 @@
|
|||||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
* 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 { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 selectors from '../../../selectors';
|
||||||
import entryActions from '../../../entry-actions';
|
import entryActions from '../../../entry-actions';
|
||||||
|
import { AutoLogoutModes } from '../../../constants/Enums';
|
||||||
|
|
||||||
import styles from './PreferencesPane.module.scss';
|
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 PreferencesPane = React.memo(() => {
|
||||||
const user = useSelector(selectors.selectCurrentUser);
|
const user = useSelector(selectors.selectCurrentUser);
|
||||||
|
|
||||||
@@ -30,6 +49,29 @@ const PreferencesPane = React.memo(() => {
|
|||||||
[dispatch],
|
[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 (
|
return (
|
||||||
<Tab.Pane attached={false} className={styles.wrapper}>
|
<Tab.Pane attached={false} className={styles.wrapper}>
|
||||||
<Radio
|
<Radio
|
||||||
@@ -56,6 +98,17 @@ const PreferencesPane = React.memo(() => {
|
|||||||
className={styles.radio}
|
className={styles.radio}
|
||||||
onChange={handleChange}
|
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>
|
</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 {
|
.wrapper {
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export default {
|
|||||||
|
|
||||||
/* Modals */
|
/* Modals */
|
||||||
|
|
||||||
|
AUTO_LOGOUT_WARNING_SHOW: 'AUTO_LOGOUT_WARNING_SHOW',
|
||||||
|
AUTO_LOGOUT_WARNING_DISMISS: 'AUTO_LOGOUT_WARNING_DISMISS',
|
||||||
MODAL_OPEN: 'MODAL_OPEN',
|
MODAL_OPEN: 'MODAL_OPEN',
|
||||||
MODAL_CLOSE: 'MODAL_CLOSE',
|
MODAL_CLOSE: 'MODAL_CLOSE',
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export default {
|
|||||||
|
|
||||||
/* Modals */
|
/* Modals */
|
||||||
|
|
||||||
|
AUTO_LOGOUT_WARNING_DISMISS: `${PREFIX}/AUTO_LOGOUT_WARNING_DISMISS`,
|
||||||
MODAL_OPEN: `${PREFIX}/MODAL_OPEN`,
|
MODAL_OPEN: `${PREFIX}/MODAL_OPEN`,
|
||||||
MODAL_CLOSE: `${PREFIX}/MODAL_CLOSE`,
|
MODAL_CLOSE: `${PREFIX}/MODAL_CLOSE`,
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export const EditorModes = {
|
|||||||
MARKUP: 'markup',
|
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 = {
|
export const HomeViews = {
|
||||||
GRID_PROJECTS: 'gridProjects',
|
GRID_PROJECTS: 'gridProjects',
|
||||||
GROUPED_PROJECTS: 'groupedProjects',
|
GROUPED_PROJECTS: 'groupedProjects',
|
||||||
|
|||||||
@@ -33,9 +33,15 @@ const logout = (revokeAccessToken = true) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dismissAutoLogoutWarning = () => ({
|
||||||
|
type: EntryActionTypes.AUTO_LOGOUT_WARNING_DISMISS,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
toggleFavorites,
|
toggleFavorites,
|
||||||
toggleEditMode,
|
toggleEditMode,
|
||||||
updateHomeView,
|
updateHomeView,
|
||||||
logout,
|
logout,
|
||||||
|
dismissAutoLogoutWarning,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ export default {
|
|||||||
attachment: 'Anhang',
|
attachment: 'Anhang',
|
||||||
attachments: 'Anhänge',
|
attachments: 'Anhänge',
|
||||||
authentication: 'Authentifizierung',
|
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',
|
background: 'Hintergrund',
|
||||||
baseCustomFields_title: 'Feldgruppe',
|
baseCustomFields_title: 'Feldgruppe',
|
||||||
baseGroup: 'Feldgruppe',
|
baseGroup: 'Feldgruppe',
|
||||||
@@ -540,6 +549,7 @@ export default {
|
|||||||
leaveBoard: 'Arbeitsbereich verlassen',
|
leaveBoard: 'Arbeitsbereich verlassen',
|
||||||
leaveProject: 'Projekt verlassen',
|
leaveProject: 'Projekt verlassen',
|
||||||
logOut_title: 'Ausloggen',
|
logOut_title: 'Ausloggen',
|
||||||
|
logOutNow: 'Jetzt abmelden',
|
||||||
makeCover_title: 'Als Vorschau festlegen',
|
makeCover_title: 'Als Vorschau festlegen',
|
||||||
makeProjectPrivate: 'Projekt privat machen',
|
makeProjectPrivate: 'Projekt privat machen',
|
||||||
makeProjectPrivate_title: 'Projekt privat machen',
|
makeProjectPrivate_title: 'Projekt privat machen',
|
||||||
@@ -574,6 +584,7 @@ export default {
|
|||||||
showMore: 'Mehr anzeigen',
|
showMore: 'Mehr anzeigen',
|
||||||
sortList_title: 'Liste sortieren',
|
sortList_title: 'Liste sortieren',
|
||||||
start: 'Start',
|
start: 'Start',
|
||||||
|
stayLoggedIn: 'Angemeldet bleiben',
|
||||||
stop: 'Stopp',
|
stop: 'Stopp',
|
||||||
subscribe: 'Abonnieren',
|
subscribe: 'Abonnieren',
|
||||||
unsubscribe: 'De-abonnieren',
|
unsubscribe: 'De-abonnieren',
|
||||||
|
|||||||
@@ -88,6 +88,15 @@ export default {
|
|||||||
attachment: 'Attachment',
|
attachment: 'Attachment',
|
||||||
attachments: 'Attachments',
|
attachments: 'Attachments',
|
||||||
authentication: 'Authentication',
|
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',
|
background: 'Background',
|
||||||
baseCustomFields_title: 'Base Custom Fields',
|
baseCustomFields_title: 'Base Custom Fields',
|
||||||
baseGroup: 'Base group',
|
baseGroup: 'Base group',
|
||||||
@@ -514,6 +523,7 @@ export default {
|
|||||||
leaveBoard: 'Leave board',
|
leaveBoard: 'Leave board',
|
||||||
leaveProject: 'Leave project',
|
leaveProject: 'Leave project',
|
||||||
logOut_title: 'Log Out',
|
logOut_title: 'Log Out',
|
||||||
|
logOutNow: 'Log out now',
|
||||||
makeCover_title: 'Make Cover',
|
makeCover_title: 'Make Cover',
|
||||||
makeProjectPrivate: 'Make project private',
|
makeProjectPrivate: 'Make project private',
|
||||||
makeProjectPrivate_title: 'Make Project Private',
|
makeProjectPrivate_title: 'Make Project Private',
|
||||||
@@ -548,6 +558,7 @@ export default {
|
|||||||
showMore: 'Show more',
|
showMore: 'Show more',
|
||||||
sortList_title: 'Sort List',
|
sortList_title: 'Sort List',
|
||||||
start: 'Start',
|
start: 'Start',
|
||||||
|
stayLoggedIn: 'Stay logged in',
|
||||||
stop: 'Stop',
|
stop: 'Stop',
|
||||||
subscribe: 'Subscribe',
|
subscribe: 'Subscribe',
|
||||||
unsubscribe: 'Unsubscribe',
|
unsubscribe: 'Unsubscribe',
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export default class extends BaseModel {
|
|||||||
subscribeToCardWhenCommenting: attr(),
|
subscribeToCardWhenCommenting: attr(),
|
||||||
turnOffRecentCardHighlighting: attr(),
|
turnOffRecentCardHighlighting: attr(),
|
||||||
isDefaultAdmin: attr(),
|
isDefaultAdmin: attr(),
|
||||||
|
autoLogoutMode: attr(),
|
||||||
isTotpEnabled: attr(),
|
isTotpEnabled: attr(),
|
||||||
totpEnabledAt: attr(),
|
totpEnabledAt: attr(),
|
||||||
totpRecoveryCodesRemaining: attr(),
|
totpRecoveryCodesRemaining: attr(),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const initialState = {
|
|||||||
projectsSearch: '',
|
projectsSearch: '',
|
||||||
projectsOrder: ProjectOrders.BY_DEFAULT,
|
projectsOrder: ProjectOrders.BY_DEFAULT,
|
||||||
isHiddenProjectsVisible: false, // TODO: refactor?
|
isHiddenProjectsVisible: false, // TODO: refactor?
|
||||||
|
autoLogoutWarning: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line default-param-last
|
// eslint-disable-next-line default-param-last
|
||||||
@@ -117,6 +118,18 @@ export default (state = initialState, { type, payload }) => {
|
|||||||
...state,
|
...state,
|
||||||
isLogouting: true,
|
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:
|
case ActionTypes.MODAL_OPEN:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default function* coreSaga() {
|
|||||||
|
|
||||||
yield apply(socket, socket.connect);
|
yield apply(socket, socket.connect);
|
||||||
yield fork(services.initializeCore);
|
yield fork(services.initializeCore);
|
||||||
|
yield fork(services.autoLogout);
|
||||||
|
|
||||||
yield take(ActionTypes.LOGOUT);
|
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 socket from './socket';
|
||||||
import bootstrap from './bootstrap';
|
import bootstrap from './bootstrap';
|
||||||
import core from './core';
|
import core from './core';
|
||||||
|
import autoLogout from './auto-logout';
|
||||||
import modals from './modals';
|
import modals from './modals';
|
||||||
import config from './config';
|
import config from './config';
|
||||||
import webhooks from './webhooks';
|
import webhooks from './webhooks';
|
||||||
@@ -36,6 +37,7 @@ export default {
|
|||||||
...socket,
|
...socket,
|
||||||
...bootstrap,
|
...bootstrap,
|
||||||
...core,
|
...core,
|
||||||
|
...autoLogout,
|
||||||
...modals,
|
...modals,
|
||||||
...config,
|
...config,
|
||||||
...webhooks,
|
...webhooks,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const selectClipboard = ({ core: { clipboard } }) => clipboard;
|
|||||||
|
|
||||||
export const selectConfig = ({ core: { config } }) => config;
|
export const selectConfig = ({ core: { config } }) => config;
|
||||||
|
|
||||||
|
export const selectAutoLogoutWarning = ({ core: { autoLogoutWarning } }) => autoLogoutWarning;
|
||||||
|
|
||||||
export const selectRecentCardId = ({ core: { recentCardId } }) => recentCardId;
|
export const selectRecentCardId = ({ core: { recentCardId } }) => recentCardId;
|
||||||
|
|
||||||
export const selectPrevCardId = ({ core: { prevCardIds } }) => prevCardIds.at(-1);
|
export const selectPrevCardId = ({ core: { prevCardIds } }) => prevCardIds.at(-1);
|
||||||
@@ -35,6 +37,7 @@ export default {
|
|||||||
selectIsEditModeEnabled,
|
selectIsEditModeEnabled,
|
||||||
selectClipboard,
|
selectClipboard,
|
||||||
selectConfig,
|
selectConfig,
|
||||||
|
selectAutoLogoutWarning,
|
||||||
selectRecentCardId,
|
selectRecentCardId,
|
||||||
selectPrevCardId,
|
selectPrevCardId,
|
||||||
selectHomeView,
|
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);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -95,6 +95,11 @@
|
|||||||
* enum: [byDefault, alphabetically, byCreationTime]
|
* enum: [byDefault, alphabetically, byCreationTime]
|
||||||
* description: Default sort order for projects display
|
* description: Default sort order for projects display
|
||||||
* example: byDefault
|
* example: byDefault
|
||||||
|
* autoLogoutMode:
|
||||||
|
* type: string
|
||||||
|
* enum: [never, 2m, 5m, 10m, 30m, 12h]
|
||||||
|
* description: Auto-logout behavior on inactivity
|
||||||
|
* example: 30m
|
||||||
* isDeactivated:
|
* isDeactivated:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* description: Whether the user account is deactivated and cannot log in (for admins)
|
* description: Whether the user account is deactivated and cannot log in (for admins)
|
||||||
@@ -200,6 +205,10 @@ module.exports = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
isIn: Object.values(User.ProjectOrders),
|
isIn: Object.values(User.ProjectOrders),
|
||||||
},
|
},
|
||||||
|
autoLogoutMode: {
|
||||||
|
type: 'string',
|
||||||
|
isIn: Object.values(User.AutoLogoutModes),
|
||||||
|
},
|
||||||
isDeactivated: {
|
isDeactivated: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
},
|
},
|
||||||
@@ -266,6 +275,7 @@ module.exports = {
|
|||||||
'defaultEditorMode',
|
'defaultEditorMode',
|
||||||
'defaultHomeView',
|
'defaultHomeView',
|
||||||
'defaultProjectsOrder',
|
'defaultProjectsOrder',
|
||||||
|
'autoLogoutMode',
|
||||||
'isDeactivated',
|
'isDeactivated',
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -142,6 +142,12 @@
|
|||||||
* default: byDefault
|
* default: byDefault
|
||||||
* description: Default sort order for projects display (personal field)
|
* description: Default sort order for projects display (personal field)
|
||||||
* example: byDefault
|
* example: byDefault
|
||||||
|
* autoLogoutMode:
|
||||||
|
* type: string
|
||||||
|
* enum: [never, 2m, 5m, 10m, 30m, 12h]
|
||||||
|
* default: 30m
|
||||||
|
* description: Auto-logout behavior on inactivity (personal field)
|
||||||
|
* example: 30m
|
||||||
* isTotpEnabled:
|
* isTotpEnabled:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* default: false
|
* default: false
|
||||||
@@ -248,6 +254,15 @@ const LANGUAGES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// TODO: find better way to handle apiKeyHash and apiKeyCreatedAt
|
// TODO: find better way to handle apiKeyHash and apiKeyCreatedAt
|
||||||
|
const AutoLogoutModes = {
|
||||||
|
NEVER: 'never',
|
||||||
|
MINUTES_2: '2m',
|
||||||
|
MINUTES_5: '5m',
|
||||||
|
MINUTES_10: '10m',
|
||||||
|
MINUTES_30: '30m',
|
||||||
|
HOURS_12: '12h',
|
||||||
|
};
|
||||||
|
|
||||||
const PRIVATE_FIELD_NAMES = [
|
const PRIVATE_FIELD_NAMES = [
|
||||||
'email',
|
'email',
|
||||||
'apiKeyPrefix',
|
'apiKeyPrefix',
|
||||||
@@ -272,6 +287,7 @@ const PERSONAL_FIELD_NAMES = [
|
|||||||
'defaultEditorMode',
|
'defaultEditorMode',
|
||||||
'defaultHomeView',
|
'defaultHomeView',
|
||||||
'defaultProjectsOrder',
|
'defaultProjectsOrder',
|
||||||
|
'autoLogoutMode',
|
||||||
];
|
];
|
||||||
|
|
||||||
const INTERNAL = {
|
const INTERNAL = {
|
||||||
@@ -284,6 +300,7 @@ module.exports = {
|
|||||||
EditorModes,
|
EditorModes,
|
||||||
HomeViews,
|
HomeViews,
|
||||||
ProjectOrders,
|
ProjectOrders,
|
||||||
|
AutoLogoutModes,
|
||||||
LANGUAGES,
|
LANGUAGES,
|
||||||
PRIVATE_FIELD_NAMES,
|
PRIVATE_FIELD_NAMES,
|
||||||
PERSONAL_FIELD_NAMES,
|
PERSONAL_FIELD_NAMES,
|
||||||
@@ -413,6 +430,12 @@ module.exports = {
|
|||||||
type: 'ref',
|
type: 'ref',
|
||||||
columnName: 'terms_accepted_at',
|
columnName: 'terms_accepted_at',
|
||||||
},
|
},
|
||||||
|
autoLogoutMode: {
|
||||||
|
type: 'string',
|
||||||
|
isIn: Object.values(AutoLogoutModes),
|
||||||
|
defaultsTo: AutoLogoutModes.MINUTES_30,
|
||||||
|
columnName: 'auto_logout_mode',
|
||||||
|
},
|
||||||
totpSecret: {
|
totpSecret: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isNotEmptyString: true,
|
isNotEmptyString: true,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports.up = (knex) =>
|
||||||
|
knex.schema.alterTable('user_account', (table) => {
|
||||||
|
table.string('auto_logout_mode').notNullable().defaultTo('30m');
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports.down = (knex) =>
|
||||||
|
knex.schema.alterTable('user_account', (table) => {
|
||||||
|
table.dropColumn('auto_logout_mode');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user