web/satellite/vuetify-poc: add session timeout and refresh
This change allows sessions within the Vuetify project to be refreshed. In addition, dialogs appear to inform the user when a session is about to expire and when it has expired. Resolves #6147 Change-Id: I53d5508825aa9992e4fed8ce7b957d949ff28e2d
This commit is contained in:
parent
792bb113bc
commit
03690daa35
@ -19,13 +19,18 @@ import { useRouter } from 'vue-router';
|
|||||||
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
|
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
|
||||||
import { useSessionTimeout, INACTIVITY_MODAL_DURATION } from '@/composables/useSessionTimeout';
|
import { useSessionTimeout, INACTIVITY_MODAL_DURATION } from '@/composables/useSessionTimeout';
|
||||||
import { RouteConfig } from '@/types/router';
|
import { RouteConfig } from '@/types/router';
|
||||||
|
import { useAppStore } from '@/store/modules/appStore';
|
||||||
|
import { MODALS } from '@/utils/constants/appStatePopUps';
|
||||||
|
|
||||||
import InactivityModal from '@/components/modals/InactivityModal.vue';
|
import InactivityModal from '@/components/modals/InactivityModal.vue';
|
||||||
import SessionExpiredModal from '@/components/modals/SessionExpiredModal.vue';
|
import SessionExpiredModal from '@/components/modals/SessionExpiredModal.vue';
|
||||||
|
|
||||||
const analyticsStore = useAnalyticsStore();
|
const analyticsStore = useAnalyticsStore();
|
||||||
|
const appStore = useAppStore();
|
||||||
|
|
||||||
const sessionTimeout = useSessionTimeout();
|
const sessionTimeout = useSessionTimeout({
|
||||||
|
showEditSessionTimeoutModal: () => appStore.updateActiveModal(MODALS.editSessionTimeout),
|
||||||
|
});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -19,12 +19,15 @@ import { useNotificationsStore } from '@/store/modules/notificationsStore';
|
|||||||
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
|
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
|
||||||
import { useNotify } from '@/utils/hooks';
|
import { useNotify } from '@/utils/hooks';
|
||||||
import { LocalData } from '@/utils/localData';
|
import { LocalData } from '@/utils/localData';
|
||||||
import { MODALS } from '@/utils/constants/appStatePopUps';
|
|
||||||
|
export interface UseSessionTimeoutOptions {
|
||||||
|
showEditSessionTimeoutModal: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
const RESET_ACTIVITY_EVENTS: readonly string[] = ['keypress', 'mousemove', 'mousedown', 'touchmove'];
|
const RESET_ACTIVITY_EVENTS: readonly string[] = ['keypress', 'mousemove', 'mousedown', 'touchmove'];
|
||||||
export const INACTIVITY_MODAL_DURATION = 60000;
|
export const INACTIVITY_MODAL_DURATION = 60000;
|
||||||
|
|
||||||
export function useSessionTimeout() {
|
export function useSessionTimeout(opts: UseSessionTimeoutOptions) {
|
||||||
const initialized = ref<boolean>(false);
|
const initialized = ref<boolean>(false);
|
||||||
|
|
||||||
const inactivityTimerId = ref<ReturnType<typeof setTimeout> | null>(null);
|
const inactivityTimerId = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@ -132,22 +135,19 @@ export function useSessionTimeout() {
|
|||||||
/**
|
/**
|
||||||
* Adds DOM event listeners and starts session timers.
|
* Adds DOM event listeners and starts session timers.
|
||||||
*/
|
*/
|
||||||
function setupSessionTimers(): void {
|
async function setupSessionTimers(): Promise<void> {
|
||||||
if (initialized.value || !configStore.state.config.inactivityTimerEnabled) return;
|
if (initialized.value || !configStore.state.config.inactivityTimerEnabled) return;
|
||||||
|
|
||||||
const expiresAt = LocalData.getSessionExpirationDate();
|
const expiresAt = LocalData.getSessionExpirationDate();
|
||||||
|
if (!expiresAt || expiresAt.getTime() - sessionDuration.value + sessionRefreshInterval.value < Date.now()) {
|
||||||
|
await refreshSession();
|
||||||
|
}
|
||||||
|
|
||||||
if (expiresAt) {
|
|
||||||
RESET_ACTIVITY_EVENTS.forEach((eventName: string) => {
|
RESET_ACTIVITY_EVENTS.forEach((eventName: string) => {
|
||||||
document.addEventListener(eventName, onSessionActivity, false);
|
document.addEventListener(eventName, onSessionActivity, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (expiresAt.getTime() - sessionDuration.value + sessionRefreshInterval.value < Date.now()) {
|
|
||||||
refreshSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
restartSessionTimers();
|
restartSessionTimers();
|
||||||
}
|
|
||||||
|
|
||||||
initialized.value = true;
|
initialized.value = true;
|
||||||
}
|
}
|
||||||
@ -177,7 +177,7 @@ export function useSessionTimeout() {
|
|||||||
}, INACTIVITY_MODAL_DURATION);
|
}, INACTIVITY_MODAL_DURATION);
|
||||||
}, sessionDuration.value - INACTIVITY_MODAL_DURATION);
|
}, sessionDuration.value - INACTIVITY_MODAL_DURATION);
|
||||||
|
|
||||||
if (!debugTimerShown.value) return;
|
if (!configStore.state.config.inactivityTimerViewerEnabled) return;
|
||||||
|
|
||||||
const debugTimer = () => {
|
const debugTimer = () => {
|
||||||
const expiresAt = LocalData.getSessionExpirationDate();
|
const expiresAt = LocalData.getSessionExpirationDate();
|
||||||
@ -221,7 +221,7 @@ export function useSessionTimeout() {
|
|||||||
isSessionRefreshing.value = false;
|
isSessionRefreshing.value = false;
|
||||||
|
|
||||||
if (manual && !usersStore.state.settings.sessionDuration) {
|
if (manual && !usersStore.state.settings.sessionDuration) {
|
||||||
appStore.updateActiveModal(MODALS.editSessionTimeout);
|
opts.showEditSessionTimeoutModal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,6 +62,7 @@ export enum AnalyticsEvent {
|
|||||||
|
|
||||||
export enum AnalyticsErrorEventSource {
|
export enum AnalyticsErrorEventSource {
|
||||||
ACCESS_GRANTS_PAGE = 'Access grants page',
|
ACCESS_GRANTS_PAGE = 'Access grants page',
|
||||||
|
ACCOUNT_PAGE = 'Account page',
|
||||||
ACCOUNT_SETTINGS_AREA = 'Account settings area',
|
ACCOUNT_SETTINGS_AREA = 'Account settings area',
|
||||||
BILLING_HISTORY_TAB = 'Billing history tab',
|
BILLING_HISTORY_TAB = 'Billing history tab',
|
||||||
BILLING_COUPONS_TAB = 'Billing coupons tab',
|
BILLING_COUPONS_TAB = 'Billing coupons tab',
|
||||||
|
@ -0,0 +1,135 @@
|
|||||||
|
// Copyright (C) 2023 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="model"
|
||||||
|
width="410px"
|
||||||
|
transition="fade-transition"
|
||||||
|
>
|
||||||
|
<v-card rounded="xlg">
|
||||||
|
<v-card-item class="pl-7 py-4">
|
||||||
|
<template #prepend>
|
||||||
|
<img class="d-block" src="@poc/assets/icon-session-timeout.svg" alt="Session expiring">
|
||||||
|
</template>
|
||||||
|
<v-card-title class="font-weight-bold">Session Expiring</v-card-title>
|
||||||
|
<template #append>
|
||||||
|
<v-btn
|
||||||
|
icon="$close"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="default"
|
||||||
|
@click="model = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-card-item>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-item class="pa-8">
|
||||||
|
Your session is about to expire due to inactivity in:
|
||||||
|
<br>
|
||||||
|
<span class="font-weight-bold">{{ seconds }} second{{ seconds !== 1 ? 's' : '' }}</span>
|
||||||
|
<br><br>
|
||||||
|
Do you want to stay logged in?
|
||||||
|
</v-card-item>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="pa-7">
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
color="default"
|
||||||
|
block
|
||||||
|
:disabled="isLoading"
|
||||||
|
:loading="isLogOutLoading"
|
||||||
|
@click="logOutClick"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
<v-col>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
:loading="isContinueLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="continueClick"
|
||||||
|
>
|
||||||
|
Stay logged in
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { VDialog, VCard, VCardItem, VCardTitle, VBtn, VDivider, VCardActions, VRow, VCol } from 'vuetify/components';
|
||||||
|
|
||||||
|
import { INACTIVITY_MODAL_DURATION } from '@/composables/useSessionTimeout';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean,
|
||||||
|
onContinue: () => Promise<void>;
|
||||||
|
onLogout: () => Promise<void>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean],
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const seconds = ref<number>(0);
|
||||||
|
const isLogOutLoading = ref<boolean>(false);
|
||||||
|
const isContinueLoading = ref<boolean>(false);
|
||||||
|
const intervalId = ref<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const model = computed<boolean>({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: value => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether the dialog is processing an action.
|
||||||
|
*/
|
||||||
|
const isLoading = computed<boolean>(() => isLogOutLoading.value || isContinueLoading.value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes the logout callback when the 'Log out' button has been clicked.
|
||||||
|
*/
|
||||||
|
async function logOutClick(): Promise<void> {
|
||||||
|
if (isLoading.value) return;
|
||||||
|
isLogOutLoading.value = true;
|
||||||
|
await props.onLogout();
|
||||||
|
isLogOutLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes the continue callback when the 'Stay logged in' button has been clicked.
|
||||||
|
*/
|
||||||
|
async function continueClick(): Promise<void> {
|
||||||
|
if (isLoading.value) return;
|
||||||
|
isContinueLoading.value = true;
|
||||||
|
await props.onContinue();
|
||||||
|
isContinueLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts timer that decreases number of seconds until session expiration.
|
||||||
|
*/
|
||||||
|
watch(() => props.modelValue, shown => {
|
||||||
|
if (!shown) {
|
||||||
|
if (intervalId.value) clearInterval(intervalId.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seconds.value = INACTIVITY_MODAL_DURATION / 1000;
|
||||||
|
intervalId.value = setInterval(() => {
|
||||||
|
if (--seconds.value <= 0 && intervalId.value) clearInterval(intervalId.value);
|
||||||
|
}, 1000);
|
||||||
|
}, { immediate: true });
|
||||||
|
</script>
|
@ -0,0 +1,76 @@
|
|||||||
|
// Copyright (C) 2023 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="model"
|
||||||
|
width="410px"
|
||||||
|
transition="fade-transition"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<v-card rounded="xlg">
|
||||||
|
<v-card-item class="pl-7 py-4">
|
||||||
|
<template #prepend>
|
||||||
|
<img class="d-block" src="@poc/assets/icon-session-timeout.svg" alt="Session expired">
|
||||||
|
</template>
|
||||||
|
<v-card-title class="font-weight-bold">Session Expired</v-card-title>
|
||||||
|
</v-card-item>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-item class="pa-8">
|
||||||
|
To protect your account and data, you've been automatically logged out.
|
||||||
|
You can change your session timeout preferences in your account settings.
|
||||||
|
</v-card-item>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="pa-7">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="redirectToLogin"
|
||||||
|
>
|
||||||
|
Go to login
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { VDialog, VCard, VCardItem, VCardTitle, VBtn, VDivider, VCardActions } from 'vuetify/components';
|
||||||
|
|
||||||
|
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
|
||||||
|
import { RouteConfig } from '@/types/router';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean],
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const analyticsStore = useAnalyticsStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const model = computed<boolean>({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: value => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects to login screen.
|
||||||
|
*/
|
||||||
|
function redirectToLogin(): void {
|
||||||
|
analyticsStore.pageVisit(RouteConfig.Login.path);
|
||||||
|
router.push(RouteConfig.Login.path);
|
||||||
|
model.value = false;
|
||||||
|
// TODO this reload will be unnecessary once vuetify poc has its own login and/or becomes the primary app
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
@ -0,0 +1,44 @@
|
|||||||
|
// Copyright (C) 2023 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<slot v-bind="sessionTimeout" />
|
||||||
|
|
||||||
|
<v-snackbar
|
||||||
|
:model-value="sessionTimeout.debugTimerShown.value"
|
||||||
|
:timeout="-1"
|
||||||
|
color="warning"
|
||||||
|
rounded="pill"
|
||||||
|
min-width="0"
|
||||||
|
location="top"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-clock" />
|
||||||
|
Remaining session time:
|
||||||
|
<span class="font-weight-bold">{{ sessionTimeout.debugTimerText.value }}</span>
|
||||||
|
</v-snackbar>
|
||||||
|
|
||||||
|
<set-session-timeout-dialog v-model="isSetTimeoutModalShown" />
|
||||||
|
<inactivity-dialog
|
||||||
|
v-model="sessionTimeout.inactivityModalShown.value"
|
||||||
|
:on-continue="() => sessionTimeout.refreshSession(true)"
|
||||||
|
:on-logout="sessionTimeout.handleInactive"
|
||||||
|
/>
|
||||||
|
<session-expired-dialog v-model="sessionTimeout.sessionExpiredModalShown.value" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { VSnackbar, VIcon } from 'vuetify/lib/components/index.mjs';
|
||||||
|
|
||||||
|
import { useSessionTimeout } from '@/composables/useSessionTimeout';
|
||||||
|
|
||||||
|
import InactivityDialog from '@poc/components/dialogs/InactivityDialog.vue';
|
||||||
|
import SessionExpiredDialog from '@poc/components/dialogs/SessionExpiredDialog.vue';
|
||||||
|
import SetSessionTimeoutDialog from '@poc/components/dialogs/SetSessionTimeoutDialog.vue';
|
||||||
|
|
||||||
|
const isSetTimeoutModalShown = ref<boolean>(false);
|
||||||
|
|
||||||
|
const sessionTimeout = useSessionTimeout({
|
||||||
|
showEditSessionTimeoutModal: () => isSetTimeoutModalShown.value = true,
|
||||||
|
});
|
||||||
|
</script>
|
@ -3,13 +3,16 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-app>
|
<v-app>
|
||||||
|
<session-wrapper>
|
||||||
<default-bar show-nav-drawer-button />
|
<default-bar show-nav-drawer-button />
|
||||||
<account-nav v-if="appStore.state.isNavigationDrawerShown" />
|
<account-nav v-if="appStore.state.isNavigationDrawerShown" />
|
||||||
<default-view />
|
<default-view />
|
||||||
|
</session-wrapper>
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onBeforeMount } from 'vue';
|
||||||
import { VApp } from 'vuetify/components';
|
import { VApp } from 'vuetify/components';
|
||||||
|
|
||||||
import DefaultBar from './AppBar.vue';
|
import DefaultBar from './AppBar.vue';
|
||||||
@ -17,6 +20,26 @@ import AccountNav from './AccountNav.vue';
|
|||||||
import DefaultView from './View.vue';
|
import DefaultView from './View.vue';
|
||||||
|
|
||||||
import { useAppStore } from '@poc/store/appStore';
|
import { useAppStore } from '@poc/store/appStore';
|
||||||
|
import { useUsersStore } from '@/store/modules/usersStore';
|
||||||
|
import { useNotify } from '@/utils/hooks';
|
||||||
|
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
|
||||||
|
|
||||||
|
import SessionWrapper from '@poc/components/utils/SessionWrapper.vue';
|
||||||
|
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
|
const usersStore = useUsersStore();
|
||||||
|
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hook after initial render.
|
||||||
|
* Pre-fetches user's settings.
|
||||||
|
*/
|
||||||
|
onBeforeMount(async () => {
|
||||||
|
try {
|
||||||
|
await usersStore.getSettings();
|
||||||
|
} catch (error) {
|
||||||
|
notify.notifyError(error, AnalyticsErrorEventSource.ACCOUNT_PAGE);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -3,8 +3,10 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-app>
|
<v-app>
|
||||||
|
<session-wrapper>
|
||||||
<default-bar />
|
<default-bar />
|
||||||
<default-view />
|
<default-view />
|
||||||
|
</session-wrapper>
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -19,6 +21,8 @@ import { useUsersStore } from '@/store/modules/usersStore';
|
|||||||
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
|
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
|
||||||
import { useNotify } from '@/utils/hooks';
|
import { useNotify } from '@/utils/hooks';
|
||||||
|
|
||||||
|
import SessionWrapper from '@poc/components/utils/SessionWrapper.vue';
|
||||||
|
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
|
|
||||||
|
@ -6,11 +6,11 @@
|
|||||||
<div v-if="isLoading" class="d-flex align-center justify-center w-100 h-100">
|
<div v-if="isLoading" class="d-flex align-center justify-center w-100 h-100">
|
||||||
<v-progress-circular color="primary" indeterminate size="64" />
|
<v-progress-circular color="primary" indeterminate size="64" />
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<session-wrapper v-else>
|
||||||
<default-bar show-nav-drawer-button />
|
<default-bar show-nav-drawer-button />
|
||||||
<ProjectNav v-if="appStore.state.isNavigationDrawerShown" />
|
<ProjectNav v-if="appStore.state.isNavigationDrawerShown" />
|
||||||
<default-view />
|
<default-view />
|
||||||
</template>
|
</session-wrapper>
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -35,6 +35,8 @@ import { useAccessGrantsStore } from '@/store/modules/accessGrantsStore';
|
|||||||
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
|
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
|
||||||
import { useNotify } from '@/utils/hooks';
|
import { useNotify } from '@/utils/hooks';
|
||||||
|
|
||||||
|
import SessionWrapper from '@poc/components/utils/SessionWrapper.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
|
Loading…
Reference in New Issue
Block a user