dashboard/shell/components/Inactivity.vue

353 lines
11 KiB
Vue

<script>
import ModalWithCard from '@shell/components/ModalWithCard';
import { Banner } from '@components/Banner';
import PercentageBar from '@shell/components/PercentageBar.vue';
import throttle from 'lodash/throttle';
import Inactivity from '@shell/utils/inactivity';
import { MANAGEMENT, EXT, NORMAN } from '@shell/config/types';
import { SETTING } from '@shell/config/settings';
let globalId;
const MODAL_VISIBILITY_CHECK_DELAY_SECONDS = 10;
export default {
name: 'Inactivity',
components: {
ModalWithCard, Banner, PercentageBar
},
data() {
return {
sessionTokenName: null,
tokens: [],
isUserActive: false,
userActivityIsoDate: '',
modalVisibilityCheckRan: false,
isOpen: false,
showModalAfter: null,
expiresAt: null,
inactivityTimeoutId: null,
courtesyTimer: null,
courtesyTimerId: null,
courtesyCountdown: null,
trackInactivity: throttle(this._trackInactivity, 1000),
id: null
};
},
beforeUnmount() {
this.removeEventListeners();
this.clearAllTimeouts();
},
computed: {
timerPercentageLeft() {
return Math.floor((this.courtesyCountdown / this.courtesyTimer ) * 100);
},
colorStops() {
return {
0: '--info', 30: '--info', 70: '--info'
};
},
userSessionTtlIdleSetting() {
return this.$store.getters['management/byId'](MANAGEMENT.SETTING, SETTING.AUTH_USER_SESSION_IDLE_TTL_MINUTES);
},
userSessionTtlSetting() {
return this.$store.getters['management/byId'](MANAGEMENT.SETTING, SETTING.AUTH_USER_SESSION_TTL_MINUTES);
},
userActivityResource() {
return this.$store.getters['management/byId'](EXT.USER_ACTIVITY, this.sessionTokenName);
},
ttlIdleValue() {
return parseInt(this.userSessionTtlIdleSetting?.value || 0);
},
ttlValue() {
return parseInt(this.userSessionTtlSetting?.value || 0);
},
isFeatureEnabled() {
return this.ttlIdleValue < this.ttlValue;
},
userActivityExpiresAt() {
return this.userActivityResource?.status?.expiresAt || '';
},
watcherData() {
return {
userActivityExpiresAt: this.userActivityExpiresAt,
sessionTokenName: this.sessionTokenName,
isFeatureEnabled: this.isFeatureEnabled
};
}
},
watch: {
// every time the Idle setting changes, we need to fetch the updated userActivity
async ttlIdleValue() {
await Inactivity.getUserActivity(this.$store, this.sessionTokenName);
},
watcherData: {
async handler(neu, old) {
if (!old?.isFeatureEnabled && neu?.isFeatureEnabled) {
const tokenName = Inactivity.getSessionTokenName();
if (tokenName) {
this.sessionTokenName = tokenName;
}
await this.initializeInactivityData();
}
const currDate = Date.now();
const endDate = new Date(neu.userActivityExpiresAt || '0001-01-01 00:00:00 +0000 UTC').getTime();
if (endDate > currDate && neu?.sessionTokenName && neu?.isFeatureEnabled) {
// feature is considered as enabled
// make sure we always clean up first so that we don't get duplicate timers running
this.stopInactivity();
// resets inactivity data and timers, starting the inactivity again with the proper data
this.resetInactivityDataAndTimers(this.userActivityResource);
// add event listeners for UI interaction
this.addIdleListeners();
}
if (!neu?.isFeatureEnabled) {
this.stopInactivity();
}
},
immediate: true,
deep: true
}
},
methods: {
async initializeInactivityData() {
const canListUserAct = this.$store.getters[`management/canList`](EXT.USER_ACTIVITY);
const canListTokens = this.$store.getters[`rancher/canList`](NORMAN.TOKEN);
if (canListUserAct && canListTokens) {
const tokens = await this.$store.dispatch('rancher/findAll', { type: NORMAN.TOKEN, opt: { watch: false } });
this.tokens = tokens;
// handle the fetching/storage of session token name
if (!this.sessionTokenName) {
const sessionToken = this.tokens.find((token) => {
return token.description === 'UI session' && token.current;
});
if (sessionToken?.name) {
this.sessionTokenName = sessionToken.name;
Inactivity.setSessionTokenName(sessionToken.name);
}
}
// get the latest userActivity data so that get reactivity on all this logic
const userActivityData = await Inactivity.getUserActivity(this.$store, this.sessionTokenName, false);
const expiresAt = userActivityData?.status?.expiresAt;
const currDate = Date.now();
const endDate = new Date(expiresAt).getTime();
// If expiresAt isn't initialised yet '0001-01-01 00:00:00 +0000 UTC' || '', or just passed the 'now' date
// We need to update/initialise the UserActivity resource
if ((currDate > endDate) || !expiresAt) {
const updatedData = await Inactivity.updateUserActivity(this.userActivityResource, this.sessionTokenName, new Date().toISOString());
this.expiresAt = updatedData?.status?.expiresAt;
} else if (expiresAt) {
this.expiresAt = expiresAt;
}
}
},
_trackInactivity() {
if (this.isOpen || !this.showModalAfter) {
return;
}
const endTime = Date.now() + this.showModalAfter * 1000;
this.id = endTime;
globalId = endTime;
const checkInactivityTimer = () => {
const now = Date.now();
if (this.id !== globalId) {
return;
}
if (now >= endTime) {
this.isOpen = true;
this.startCountdown();
} else {
// When we have X seconds to go until we display the modal, check for activity on the backend flag
// it may have come from another tab in the same browser
if (now >= endTime - (MODAL_VISIBILITY_CHECK_DELAY_SECONDS * 1000) && !this.modalVisibilityCheckRan) {
this.modalVisibilityCheckRan = true;
if (this.isUserActive) {
this.resetUserActivity();
} else {
this.checkBackendInactivity(this.expiresAt);
}
}
this.inactivityTimeoutId = setTimeout(checkInactivityTimer, 1000);
}
};
checkInactivityTimer();
},
startCountdown() {
const endTime = Date.now() + (this.courtesyCountdown * 1000);
const checkCountdown = async() => {
const now = Date.now();
if (now >= endTime) {
this.clearAllTimeouts();
const isUserActive = await this.checkBackendInactivity(this.expiresAt);
if (!isUserActive) {
return this.$store.dispatch('auth/logout', { sessionIdle: true });
}
} else {
this.courtesyCountdown = Math.floor((endTime - now) / 1000);
this.courtesyTimerId = setTimeout(checkCountdown, 1000);
}
};
checkCountdown();
},
async checkBackendInactivity(currExpiresAt) {
let isUserActive = false;
const userActivityData = await Inactivity.getUserActivity(this.$store, this.sessionTokenName);
// this means that something updated the backend expiresAt, which means we must now reset the timers and adjust for new data
if (userActivityData?.status?.expiresAt && (userActivityData?.status?.expiresAt !== currExpiresAt)) {
isUserActive = true;
this.resetInactivityDataAndTimers(userActivityData);
}
return isUserActive;
},
setUserAsActive() {
this.isUserActive = true;
this.userActivityIsoDate = new Date().toISOString();
},
addIdleListeners() {
document.addEventListener('mousemove', this.setUserAsActive);
document.addEventListener('mousedown', this.setUserAsActive);
document.addEventListener('keypress', this.setUserAsActive);
document.addEventListener('touchmove', this.setUserAsActive);
document.addEventListener('visibilitychange', this.setUserAsActive);
},
removeEventListeners() {
document.removeEventListener('mousemove', this.setUserAsActive);
document.removeEventListener('mousedown', this.setUserAsActive);
document.removeEventListener('keypress', this.setUserAsActive);
document.removeEventListener('touchmove', this.setUserAsActive);
document.removeEventListener('visibilitychange', this.setUserAsActive);
},
async resetUserActivity(useCurrDate = false) {
let seenAt;
if (useCurrDate) {
seenAt = new Date().toISOString();
} else {
seenAt = this.userActivityIsoDate;
}
const userActivityData = await Inactivity.updateUserActivity(this.userActivityResource, this.sessionTokenName, seenAt);
this.resetInactivityDataAndTimers(userActivityData);
},
stopInactivity() {
this.modalVisibilityCheckRan = false;
this.isOpen = false;
this.isUserActive = false;
this.userActivityIsoDate = '';
this.removeEventListeners();
this.clearAllTimeouts();
},
resetInactivityDataAndTimers(userActivityData) {
const data = Inactivity.parseTTLData(userActivityData);
this.modalVisibilityCheckRan = false;
this.isOpen = false;
this.isUserActive = false;
this.userActivityIsoDate = '';
this.courtesyTimer = data.courtesyTimer;
this.courtesyCountdown = data.courtesyCountdown;
this.showModalAfter = data.showModalAfter;
this.sessionTokenName = data.sessionTokenName;
this.expiresAt = data.expiresAt;
const shownAfter = Math.round(((data.showModalAfter || 0) / 60) * 100) / 100;
const shownFor = Math.round(((data.courtesyTimer || 0) / 60) * 100) / 100;
console.debug(`UI inactivity modal (backend-based) will show after ${ shownAfter }(m) and be shown for ${ shownFor }(m)`); // eslint-disable-line no-console
this.clearAllTimeouts();
this.trackInactivity();
},
clearAllTimeouts() {
clearTimeout(this.inactivityTimeoutId);
clearTimeout(this.courtesyTimerId);
}
},
};
</script>
<template>
<ModalWithCard
v-if="isOpen"
ref="inactivityModal"
name="inactivityModal"
save-text="Continue"
>
<template #title>
{{ t('inactivity.title') }}
</template>
<span>{{ courtesyCountdown }}</span>
<template #content>
<Banner color="info">
{{ t('inactivity.banner') }}
</Banner>
<p>
{{ t('inactivity.content') }}
</p>
<PercentageBar
class="mt-20"
:modelValue="timerPercentageLeft"
:color-stops="colorStops"
/>
</template>
<template
#footer
>
<div class="card-actions">
<button
class="btn role-tertiary bg-primary"
@click.prevent="resetUserActivity(true)"
>
<t k="inactivity.cta" />
</button>
</div>
</template>
</ModalWithCard>
</template>
<style lang="scss" scoped>
.card-actions {
display: flex;
width: 100%;
justify-content: flex-end;
}
</style>