diff --git a/client/src/actions/core.js b/client/src/actions/core.js index 85182ffd..2ae879ba 100644 --- a/client/src/actions/core.js +++ b/client/src/actions/core.js @@ -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, }; diff --git a/client/src/components/common/AutoLogoutWarningModal/AutoLogoutWarningModal.jsx b/client/src/components/common/AutoLogoutWarningModal/AutoLogoutWarningModal.jsx new file mode 100644 index 00000000..a9807cd7 --- /dev/null +++ b/client/src/components/common/AutoLogoutWarningModal/AutoLogoutWarningModal.jsx @@ -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 ( + + {t('common.autoLogoutWarning_title')} + + {t('common.autoLogoutWarning_body', { seconds: remainingSeconds })} + + + {t('action.logOutNow')} + + + + ); +}); + +export default AutoLogoutWarningModal; diff --git a/client/src/components/common/AutoLogoutWarningModal/index.js b/client/src/components/common/AutoLogoutWarningModal/index.js new file mode 100644 index 00000000..3064789e --- /dev/null +++ b/client/src/components/common/AutoLogoutWarningModal/index.js @@ -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; diff --git a/client/src/components/common/Core/Core.jsx b/client/src/components/common/Core/Core.jsx index 3bc81e7b..2cb7f7af 100644 --- a/client/src/components/common/Core/Core.jsx +++ b/client/src/components/common/Core/Core.jsx @@ -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(() => { {modalNode} + > )} {messageNode} diff --git a/client/src/components/users/UserSettingsModal/PreferencesPane.jsx b/client/src/components/users/UserSettingsModal/PreferencesPane.jsx index 984c831c..99492fce 100644 --- a/client/src/components/users/UserSettingsModal/PreferencesPane.jsx +++ b/client/src/components/users/UserSettingsModal/PreferencesPane.jsx @@ -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 ( { className={styles.radio} onChange={handleChange} /> + + {t('common.autoLogout')} + + ); }); diff --git a/client/src/components/users/UserSettingsModal/PreferencesPane.module.scss b/client/src/components/users/UserSettingsModal/PreferencesPane.module.scss index 6822ea1d..25317926 100644 --- a/client/src/components/users/UserSettingsModal/PreferencesPane.module.scss +++ b/client/src/components/users/UserSettingsModal/PreferencesPane.module.scss @@ -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; diff --git a/client/src/constants/ActionTypes.js b/client/src/constants/ActionTypes.js index 7783d938..7d79589c 100644 --- a/client/src/constants/ActionTypes.js +++ b/client/src/constants/ActionTypes.js @@ -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', diff --git a/client/src/constants/EntryActionTypes.js b/client/src/constants/EntryActionTypes.js index ad5c0d1d..78348de5 100755 --- a/client/src/constants/EntryActionTypes.js +++ b/client/src/constants/EntryActionTypes.js @@ -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`, diff --git a/client/src/constants/Enums.js b/client/src/constants/Enums.js index 3de0090e..e0dc2713 100755 --- a/client/src/constants/Enums.js +++ b/client/src/constants/Enums.js @@ -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', diff --git a/client/src/entry-actions/core.js b/client/src/entry-actions/core.js index 62b7de3c..076f7799 100644 --- a/client/src/entry-actions/core.js +++ b/client/src/entry-actions/core.js @@ -33,9 +33,15 @@ const logout = (revokeAccessToken = true) => ({ }, }); +const dismissAutoLogoutWarning = () => ({ + type: EntryActionTypes.AUTO_LOGOUT_WARNING_DISMISS, + payload: {}, +}); + export default { toggleFavorites, toggleEditMode, updateHomeView, logout, + dismissAutoLogoutWarning, }; diff --git a/client/src/locales/de-DE/core.js b/client/src/locales/de-DE/core.js index 6cb4d1c4..34a57673 100644 --- a/client/src/locales/de-DE/core.js +++ b/client/src/locales/de-DE/core.js @@ -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', diff --git a/client/src/locales/en-US/core.js b/client/src/locales/en-US/core.js index 250ba4ab..1ac9a4ed 100644 --- a/client/src/locales/en-US/core.js +++ b/client/src/locales/en-US/core.js @@ -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', diff --git a/client/src/models/User.js b/client/src/models/User.js index f4d4b913..4567d460 100755 --- a/client/src/models/User.js +++ b/client/src/models/User.js @@ -89,6 +89,7 @@ export default class extends BaseModel { subscribeToCardWhenCommenting: attr(), turnOffRecentCardHighlighting: attr(), isDefaultAdmin: attr(), + autoLogoutMode: attr(), isTotpEnabled: attr(), totpEnabledAt: attr(), totpRecoveryCodesRemaining: attr(), diff --git a/client/src/reducers/core.js b/client/src/reducers/core.js index 0a4e94a5..defa88ef 100755 --- a/client/src/reducers/core.js +++ b/client/src/reducers/core.js @@ -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, diff --git a/client/src/sagas/core/index.js b/client/src/sagas/core/index.js index ba536880..9c5a14d7 100755 --- a/client/src/sagas/core/index.js +++ b/client/src/sagas/core/index.js @@ -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); diff --git a/client/src/sagas/core/services/auto-logout.js b/client/src/sagas/core/services/auto-logout.js new file mode 100644 index 00000000..bf3346a4 --- /dev/null +++ b/client/src/sagas/core/services/auto-logout.js @@ -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, +}; diff --git a/client/src/sagas/core/services/index.js b/client/src/sagas/core/services/index.js index 7479ab8d..0cc2f2ac 100644 --- a/client/src/sagas/core/services/index.js +++ b/client/src/sagas/core/services/index.js @@ -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, diff --git a/client/src/selectors/core.js b/client/src/selectors/core.js index 53b5e03d..b35edb88 100644 --- a/client/src/selectors/core.js +++ b/client/src/selectors/core.js @@ -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, diff --git a/client/src/utils/auto-logout-channel.js b/client/src/utils/auto-logout-channel.js new file mode 100644 index 00000000..14589557 --- /dev/null +++ b/client/src/utils/auto-logout-channel.js @@ -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); + }; +}; diff --git a/server/api/controllers/users/update.js b/server/api/controllers/users/update.js index 88a05900..4be94eb2 100755 --- a/server/api/controllers/users/update.js +++ b/server/api/controllers/users/update.js @@ -95,6 +95,11 @@ * enum: [byDefault, alphabetically, byCreationTime] * description: Default sort order for projects display * example: byDefault + * autoLogoutMode: + * type: string + * enum: [never, 2m, 5m, 10m, 30m, 12h] + * description: Auto-logout behavior on inactivity + * example: 30m * isDeactivated: * type: boolean * description: Whether the user account is deactivated and cannot log in (for admins) @@ -200,6 +205,10 @@ module.exports = { type: 'string', isIn: Object.values(User.ProjectOrders), }, + autoLogoutMode: { + type: 'string', + isIn: Object.values(User.AutoLogoutModes), + }, isDeactivated: { type: 'boolean', }, @@ -266,6 +275,7 @@ module.exports = { 'defaultEditorMode', 'defaultHomeView', 'defaultProjectsOrder', + 'autoLogoutMode', 'isDeactivated', ]), }; diff --git a/server/api/models/User.js b/server/api/models/User.js index 964760f9..8ff3b78d 100755 --- a/server/api/models/User.js +++ b/server/api/models/User.js @@ -142,6 +142,12 @@ * default: byDefault * description: Default sort order for projects display (personal field) * 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: * type: boolean * default: false @@ -248,6 +254,15 @@ const LANGUAGES = [ ]; // 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 = [ 'email', 'apiKeyPrefix', @@ -272,6 +287,7 @@ const PERSONAL_FIELD_NAMES = [ 'defaultEditorMode', 'defaultHomeView', 'defaultProjectsOrder', + 'autoLogoutMode', ]; const INTERNAL = { @@ -284,6 +300,7 @@ module.exports = { EditorModes, HomeViews, ProjectOrders, + AutoLogoutModes, LANGUAGES, PRIVATE_FIELD_NAMES, PERSONAL_FIELD_NAMES, @@ -413,6 +430,12 @@ module.exports = { type: 'ref', columnName: 'terms_accepted_at', }, + autoLogoutMode: { + type: 'string', + isIn: Object.values(AutoLogoutModes), + defaultsTo: AutoLogoutModes.MINUTES_30, + columnName: 'auto_logout_mode', + }, totpSecret: { type: 'string', isNotEmptyString: true, diff --git a/server/db/migrations/20260808120000_add_auto_logout_preference.js b/server/db/migrations/20260808120000_add_auto_logout_preference.js new file mode 100644 index 00000000..72073e3e --- /dev/null +++ b/server/db/migrations/20260808120000_add_auto_logout_preference.js @@ -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'); + });
{t('common.autoLogoutWarning_body', { seconds: remainingSeconds })}