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:
Jeremy Wharton 2023-08-10 16:56:21 -05:00 committed by Storj Robot
parent 792bb113bc
commit 03690daa35
9 changed files with 314 additions and 24 deletions

View File

@ -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();
/** /**

View File

@ -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();
} }
} }

View File

@ -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',

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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();

View File

@ -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();