web/satellite: enable session timeout setting

This change adds a new setting on the account settings page to change
session timeout duration. The old settings page is replaced with a new
one used on the all projects dashboard page. Also, onboarding API
endpoints and store action have been changed to be generic to include
session timeout setting.

Issue: https://github.com/storj/storj/issues/5560

Change-Id: I9026e61c6f86e4be5f9357e5d20e280eab2c29ea
This commit is contained in:
Wilfred Asomani 2023-03-30 14:02:45 +00:00
parent 857a292e52
commit 6f8dff5832
15 changed files with 574 additions and 67 deletions

View File

@ -4,9 +4,17 @@
import { ErrorBadRequest } from '@/api/errors/ErrorBadRequest';
import { ErrorMFARequired } from '@/api/errors/ErrorMFARequired';
import { ErrorTooManyRequests } from '@/api/errors/ErrorTooManyRequests';
import { TokenInfo, UpdatedUser, User, UsersApi, UserSettings } from '@/types/users';
import {
SetUserSettingsData,
TokenInfo,
UpdatedUser,
User,
UsersApi,
UserSettings,
} from '@/types/users';
import { HttpClient } from '@/utils/httpClient';
import { ErrorTokenExpired } from '@/api/errors/ErrorTokenExpired';
import { Duration } from '@/utils/time';
/**
* AuthHttpApi is a console Auth API.
@ -260,16 +268,27 @@ export class AuthHttpApi implements UsersApi {
}
/**
* Changes user's onboarding status.
* Changes user's settings.
*
* @param data
* @returns UserSettings
* @throws Error
*/
public async setOnboardingStatus(status: Partial<UserSettings>): Promise<void> {
const path = `${this.ROOT_PATH}/account/onboarding`;
const response = await this.http.patch(path, JSON.stringify(status));
if (!response.ok) {
throw new Error('can not set onboarding status');
public async updateSettings(data: SetUserSettingsData): Promise<UserSettings> {
const path = `${this.ROOT_PATH}/account/settings`;
const response = await this.http.patch(path, JSON.stringify(data));
if (response.ok) {
const responseData = await response.json();
return new UserSettings(
responseData.sessionDuration,
responseData.onboardingStart,
responseData.onboardingEnd,
responseData.onboardingStep,
);
}
throw new Error('can not get user settings');
}
// TODO: remove secret after Vanguard release

View File

@ -23,8 +23,6 @@
</div>
</div>
<div class="settings__section__content__divider" />
<div class="settings__section__content__row">
<span class="settings__section__content__row__title">Email</span>
<span class="settings__section__content__row__subtitle">{{ user.email }}</span>
@ -51,10 +49,11 @@
</div>
</div>
<div class="settings__section__content__divider" />
<div class="settings__section__content__row">
<span class="settings__section__content__row__title">Two-Factor Authentication</span>
<div class="settings__section__content__row__title">
<p>Two-Factor</p>
<p>Authentication</p>
</div>
<span v-if="!user.isMFAEnabled" class="settings__section__content__row__subtitle">Improve account security by enabling 2FA.</span>
<span v-else class="settings__section__content__row__subtitle">2FA is enabled.</span>
<div class="settings__section__content__row__actions">
@ -78,6 +77,22 @@
/>
</div>
</div>
<div class="settings__section__content__row">
<span class="settings__section__content__row__title">Session Timeout</span>
<span v-if="userDuration" class="settings__section__content__row__subtitle">{{ userDuration.shortString }} of inactivity will log you out.</span>
<span v-else class="settings__section__content__row__subtitle">Duration of inactivity that will log you out.</span>
<div class="settings__section__content__row__actions">
<VButton
class="button"
is-white
font-size="14px"
width="100px"
:on-press="toggleEditSessionTimeoutModal"
label="Set Timeout"
/>
</div>
</div>
</div>
</div>
</div>
@ -88,19 +103,15 @@ import { computed, onMounted } from 'vue';
import { USER_ACTIONS } from '@/store/modules/users';
import { User } from '@/types/users';
import { APP_STATE_ACTIONS } from '@/utils/constants/actionNames';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import { MODALS } from '@/utils/constants/appStatePopUps';
import { useNotify, useStore } from '@/utils/hooks';
import { useLoading } from '@/composables/useLoading';
import { APP_STATE_MUTATIONS } from '@/store/mutationConstants';
import { Duration } from '@/utils/time';
import VButton from '@/components/common/VButton.vue';
import ChangePasswordIcon from '@/../static/images/account/profile/changePassword.svg';
import EmailIcon from '@/../static/images/account/profile/email.svg';
import EditIcon from '@/../static/images/common/edit.svg';
const store = useStore();
const notify = useNotify();
const { isLoading, withLoading } = useLoading();
@ -112,6 +123,13 @@ const user = computed((): User => {
return store.getters.user;
});
/**
* Returns duration from store.
*/
const userDuration = computed((): Duration | null => {
return store.state.usersModule.settings.sessionDuration;
});
/**
* Toggles enable MFA modal visibility.
*/
@ -140,6 +158,13 @@ function toggleChangePasswordModal(): void {
store.commit(APP_STATE_MUTATIONS.UPDATE_ACTIVE_MODAL, MODALS.changePassword);
}
/**
* Opens edit session timeout modal.
*/
function toggleEditSessionTimeoutModal(): void {
store.commit(APP_STATE_MUTATIONS.UPDATE_ACTIVE_MODAL, MODALS.editSessionTimeout);
}
/**
* Opens edit account info modal.
*/
@ -201,19 +226,14 @@ onMounted(() => {
&__content {
margin-top: 20px;
background: var(--c-white);
box-shadow: 0 0 20px rgb(0 0 0 / 4%);
border-radius: 8px;
padding: 10px 20px;
&__divider {
margin: 10px;
border: 0.5px solid var(--c-grey-2);
}
&__row {
padding: 10px;
min-height: 55px;
background: var(--c-white);
box-shadow: 0 0 20px rgb(0 0 0 / 4%);
border-radius: 8px;
padding: 10px 30px;
margin-bottom: 20px;
height: 88px;
box-sizing: border-box;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@ -225,6 +245,7 @@ onMounted(() => {
align-items: flex-start;
justify-content: center;
gap: 10px;
height: unset;
}
&__title {

View File

@ -18,14 +18,7 @@
<slot name="icon" />
</div>
<span class="label" :class="{uppercase: isUppercase}">
<CopyIcon v-if="icon.toLowerCase() === 'copy'" />
<LockIcon v-if="icon.toLowerCase() === 'lock'" />
<CreditCardIcon v-if="icon.toLowerCase() === 'credit-card'" />
<DocumentIcon v-if="icon.toLowerCase() === 'document'" />
<TrashIcon v-if="icon.toLowerCase() === 'trash'" />
<FolderIcon v-if="icon.toLowerCase() === 'folder'" />
<resources-icon v-if="icon.toLowerCase() === 'resources'" />
<add-circle-icon v-if="icon.toLowerCase() === 'addcircle'" />
<component :is="iconComponent" v-if="iconComponent" />
<span v-if="icon !== 'none'">&nbsp;&nbsp;</span>
{{ label }}
</span>
@ -46,15 +39,7 @@
<slot name="icon" />
</div>
<span class="label" :class="{uppercase: isUppercase}">
<CopyIcon v-if="icon.toLowerCase() === 'copy'" />
<DownloadIcon v-if="icon.toLowerCase() === 'download'" />
<LockIcon v-if="icon.toLowerCase() === 'lock'" />
<CreditCardIcon v-if="icon.toLowerCase() === 'credit-card'" />
<DocumentIcon v-if="icon.toLowerCase() === 'document'" />
<TrashIcon v-if="icon.toLowerCase() === 'trash'" />
<FolderIcon v-if="icon.toLowerCase() === 'folder'" />
<resources-icon v-if="icon.toLowerCase() === 'resources'" />
<add-circle-icon v-if="icon.toLowerCase() === 'addcircle'" />
<component :is="iconComponent" v-if="iconComponent" />
<span v-if="icon !== 'none'">&nbsp;&nbsp;</span>
{{ label }}
</span>
@ -66,7 +51,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, VueConstructor } from 'vue';
import AddCircleIcon from '@/../static/images/common/addCircle.svg';
import CopyIcon from '@/../static/images/common/copyButtonIcon.svg';
@ -120,6 +105,20 @@ const props = withDefaults(defineProps<{
onPress: () => {},
});
const icons = new Map<string, VueConstructor>([
['copy', CopyIcon],
['download', DownloadIcon],
['lock', LockIcon],
['credit-card', CreditCardIcon],
['document', DocumentIcon],
['trash', TrashIcon],
['folder', FolderIcon],
['resources', ResourcesIcon],
['addcircle', AddCircleIcon],
]);
const iconComponent = computed((): VueConstructor | undefined => icons.get(props.icon.toLowerCase()));
const containerClassName = computed((): string => {
if (props.isDisabled) return 'disabled';

View File

@ -0,0 +1,205 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<VModal :on-close="withLoading(onClose)">
<template #content>
<div class="timeout-modal">
<div class="timeout-modal__header">
<Icon class="timeout-modal__header__icon" />
<h1 class="timeout-modal__header__title">
Session Timeout
</h1>
</div>
<div class="timeout-modal__divider" />
<p class="timeout-modal__info">Select your session timeout duration.</p>
<div class="timeout-modal__divider" />
<p class="timeout-modal__label">Session timeout duration</p>
<timeout-selector :selected="sessionDuration" @select="durationChange" />
<div class="timeout-modal__divider" />
<div class="timeout-modal__buttons">
<VButton
label="Cancel"
width="100%"
height="40px"
border-radius="10px"
font-size="13px"
is-white
class="timeout-modal__buttons__button"
:on-press="withLoading(onClose)"
:is-disabled="isLoading"
/>
<VButton
label="Save"
width="100%"
height="40px"
border-radius="10px"
font-size="13px"
class="timeout-modal__buttons__button save"
:on-press="withLoading(save)"
:is-disabled="isLoading || !hasChanged"
/>
</div>
</div>
</template>
</VModal>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { APP_STATE_MUTATIONS } from '@/store/mutationConstants';
import { useNotify, useStore } from '@/utils/hooks';
import { Duration } from '@/utils/time';
import { USER_ACTIONS } from '@/store/modules/users';
import { SetUserSettingsData } from '@/types/users';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import VButton from '@/components/common/VButton.vue';
import VModal from '@/components/common/VModal.vue';
import TimeoutSelector from '@/components/modals/editSessionTimeout/TimeoutSelector.vue';
import Icon from '@/../static/images/session/inactivityTimer.svg';
const store = useStore();
const notify = useNotify();
const isLoading = ref(false);
const sessionDuration = ref<Duration | null>(null);
/**
* Lifecycle hook after initial render.
* Make the current selected duration the already configured one.
*/
onMounted(() => {
sessionDuration.value = userDuration.value;
});
/**
* Returns duration from store.
*/
const userDuration = computed((): Duration | null => {
return store.state.usersModule.settings.sessionDuration;
});
/**
* Whether the user has changed this setting.
*/
const hasChanged = computed((): boolean => {
if (!sessionDuration.value) {
return false;
}
return !userDuration.value?.isEqualTo(sessionDuration.value as Duration);
});
/**
* durationChange is called when the user selects a different duration.
* @param duration the user's selection.
* */
function durationChange(duration: Duration) {
sessionDuration.value = duration;
}
/**
* save submits the changed duration.
* */
async function save() {
isLoading.value = true;
try {
await store.dispatch(USER_ACTIONS.UPDATE_SETTINGS, {
sessionDuration: sessionDuration.value?.nanoseconds ?? 0,
} as SetUserSettingsData);
notify.success(`Session timeout changed successfully. Your session timeout is ${sessionDuration.value?.shortString}.`);
onClose();
} catch (error) {
await notify.error(error.message, AnalyticsErrorEventSource.EDIT_TIMEOUT_MODAL);
} finally {
isLoading.value = false;
}
}
/**
* onClose is called to close this modal.
* */
async function onClose() {
store.commit(APP_STATE_MUTATIONS.REMOVE_ACTIVE_MODAL);
}
/**
* Returns a function that disables modal interaction during execution.
*/
function withLoading(fn: () => Promise<void>): () => Promise<void> {
return async () => {
if (isLoading.value) return;
isLoading.value = true;
await fn();
isLoading.value = false;
};
}
</script>
<style scoped lang="scss">
.timeout-modal {
padding: 32px;
box-sizing: border-box;
font-family: 'font_regular', sans-serif;
text-align: left;
&__header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 20px;
@media screen and (max-width: 500px) {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
&__title {
font-family: 'font_bold', sans-serif;
font-size: 28px;
line-height: 36px;
}
}
&__divider {
margin: 20px 0;
border: 1px solid var(--c-grey-2);
}
&__info {
font-family: 'font_regular', sans-serif;
font-size: 16px;
line-height: 24px;
}
&__label {
font-family: 'font_regular', sans-serif;
font-size: 14px;
line-height: 24px;
margin-bottom: 10px;
}
&__buttons {
display: flex;
gap: 16px;
@media screen and (max-width: 500px) {
flex-direction: column-reverse;
}
&__button {
padding: 16px;
box-sizing: border-box;
}
}
}
</style>

View File

@ -0,0 +1,167 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="selector">
<div v-click-outside="closeSelector" tabindex="0" class="selector__content" @keyup.enter="toggleSelector" @click="toggleSelector">
<span v-if="selected" class="selector__content__label">{{ selected?.shortString }}</span>
<span v-else class="selector__content__label">Select duration</span>
<arrow-down-icon class="selector__content__arrow" :class="{ open: isOpen }" />
</div>
<div v-if="isOpen" class="selector__dropdown">
<div
v-for="(option, index) in options"
:key="index" tabindex="0"
class="selector__dropdown__item"
:class="{ selected: isSelected(option) }"
@click.stop="() => select(option)"
@keyup.enter="() => select(option)"
>
<span class="selector__dropdown__item__label">{{ option.shortString }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useStore } from '@/utils/hooks';
import { APP_STATE_DROPDOWNS } from '@/utils/constants/appStatePopUps';
import { APP_STATE_ACTIONS } from '@/utils/constants/actionNames';
import { Duration } from '@/utils/time';
import ArrowDownIcon from '@/../static/images/common/dropIcon.svg';
const store = useStore();
const props = defineProps<{
selected: Duration | null;
}>();
const emit = defineEmits<{
// select is emitted when the selection changes.
(e: 'select', duration: Duration): void
}>();
const options = [
Duration.MINUTES_15,
Duration.MINUTES_30,
Duration.HOUR_1,
Duration.DAY_1,
Duration.WEEK_1,
Duration.DAY_30,
];
/**
* whether the selector drop down is open
* */
const isOpen = computed((): boolean => {
return store.state.appStateModule.viewsState.activeDropdown === APP_STATE_DROPDOWNS.TIMEOUT_SELECTOR;
});
/**
* whether an option is currently selected.
* @param option
* */
function isSelected(option: Duration): boolean {
if (!props.selected) {
return false;
}
return props.selected.isEqualTo(option);
}
/**
* select sends the new selection to a parent component.
* @param option the new selection
* */
function select(option: Duration) {
emit('select', option);
closeSelector();
}
/**
* closeSelector closes the selector dropdown.
* */
function closeSelector() {
store.dispatch(APP_STATE_ACTIONS.CLOSE_POPUPS);
}
/**
* toggleSelector closes or opens the selector dropdown
* */
function toggleSelector() {
if (isOpen.value) {
store.dispatch(APP_STATE_ACTIONS.CLOSE_POPUPS);
} else {
store.commit(APP_STATE_ACTIONS.TOGGLE_ACTIVE_DROPDOWN, APP_STATE_DROPDOWNS.TIMEOUT_SELECTOR);
}
}
</script>
<style scoped lang="scss">
.selector {
border: 1px solid var(--c-grey-3);
border-radius: 6px;
max-width: 170px;
position: relative;
box-sizing: border-box;
&__content {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
margin: 10px 14px;
&__label {
font-family: 'font_regular', sans-serif;
font-size: 14px;
line-height: 20px;
color: var(--c-grey-6);
}
&__arrow {
transition-duration: 0.5s;
&.open {
transform: rotate(180deg) scaleX(-1);
}
}
}
&__dropdown {
position: absolute;
top: 50px;
background: var(--c-white);
z-index: 999;
box-sizing: border-box;
box-shadow: 0 -2px 16px rgb(0 0 0 / 10%);
border-radius: 8px;
border: 1px solid var(--c-grey-2);
width: 100%;
&__item {
padding: 10px;
&.selected {
background: var(--c-grey-1);
}
&:first-of-type {
border-top-right-radius: 8px;
border-top-left-radius: 8px;
}
&:last-of-type {
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
}
&:hover {
background: var(--c-grey-2);
}
}
}
}
</style>

View File

@ -87,7 +87,7 @@ async function onUploadInBrowserClick(): Promise<void> {
async function endOnboarding(): Promise<void> {
try {
await store.dispatch(USER_ACTIONS.SET_ONBOARDING_STATUS, {
await store.dispatch(USER_ACTIONS.UPDATE_SETTINGS, {
onboardingEnd: true,
} as Partial<UserSettings>);
} catch (error) {
@ -102,7 +102,7 @@ async function endOnboarding(): Promise<void> {
onMounted(async (): Promise<void> => {
try {
if (!store.state.usersModule.settings.onboardingStart) {
await store.dispatch(USER_ACTIONS.SET_ONBOARDING_STATUS, {
await store.dispatch(USER_ACTIONS.UPDATE_SETTINGS, {
onboardingStart: true,
} as Partial<UserSettings>);
}

View File

@ -114,7 +114,7 @@ onBeforeMount(async () => {
if (!store.state.usersModule.settings.onboardingStart) {
try {
await store.dispatch(USER_ACTIONS.SET_ONBOARDING_STATUS, {
await store.dispatch(USER_ACTIONS.UPDATE_SETTINGS, {
onboardingStart: true,
} as Partial<UserSettings>);
} catch (error) {

View File

@ -66,7 +66,7 @@ async function onFinishClick(): Promise<void> {
async function endOnboarding(): Promise<void> {
try {
await store.dispatch(USER_ACTIONS.SET_ONBOARDING_STATUS, {
await store.dispatch(USER_ACTIONS.UPDATE_SETTINGS, {
onboardingEnd: true,
} as Partial<UserSettings>);
} catch (error) {

View File

@ -204,7 +204,7 @@ export const router = new Router({
{
path: RouteConfig.Settings.path,
name: RouteConfig.Settings.name,
component: SettingsArea,
component: NewSettingsArea,
},
{
path: RouteConfig.Billing.path,

View File

@ -3,6 +3,7 @@
import {
DisableMFARequest,
SetUserSettingsData,
UpdatedUser,
User,
UsersApi,
@ -10,6 +11,7 @@ import {
} from '@/types/users';
import { MetaUtils } from '@/utils/meta';
import { StoreModule } from '@/types/store';
import { Duration } from '@/utils/time';
export const USER_ACTIONS = {
LOGIN: 'loginUser',
@ -22,7 +24,7 @@ export const USER_ACTIONS = {
CLEAR: 'clearUser',
GET_FROZEN_STATUS: 'getFrozenStatus',
GET_SETTINGS: 'getSettings',
SET_ONBOARDING_STATUS: 'setOnboardingStatus',
UPDATE_SETTINGS: 'updateSettings',
};
export const USER_MUTATIONS = {
@ -51,7 +53,7 @@ const {
GENERATE_USER_MFA_RECOVERY_CODES,
GET_FROZEN_STATUS,
GET_SETTINGS,
SET_ONBOARDING_STATUS,
UPDATE_SETTINGS,
} = USER_ACTIONS;
const {
@ -142,12 +144,8 @@ export function makeUsersModule(api: UsersApi): StoreModule<UsersState, UsersCon
return settings;
},
[SET_ONBOARDING_STATUS]: async function ({ commit, state }: UsersContext, status: Partial<UserSettings>): Promise<void> {
await api.setOnboardingStatus(status);
const settings = state.settings;
for (const statusKey in status) {
settings[statusKey] = status[statusKey];
}
[UPDATE_SETTINGS]: async function ({ commit, state }: UsersContext, update: SetUserSettingsData): Promise<void> {
const settings = await api.updateSettings(update);
commit(SET_SETTINGS, settings);
},

View File

@ -1,6 +1,8 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import { Duration } from '@/utils/time';
/**
* Exposes all user-related functionality.
*/
@ -36,12 +38,13 @@ export interface UsersApi {
getUserSettings(): Promise<UserSettings>;
/**
* Changes user's onboarding status.
* Changes user's settings.
*
* @param data
* @returns UserSettings
* @throws Error
*/
setOnboardingStatus(status: Partial<UserSettings>): Promise<void>;
updateSettings(data: SetUserSettingsData): Promise<UserSettings>;
/**
* Enable user's MFA.
@ -157,16 +160,23 @@ export class TokenInfo {
*/
export class UserSettings {
public constructor(
private _sessionDuration: string | null = null,
private _sessionDuration: number | null = null,
public onboardingStart = false,
public onboardingEnd = false,
public onboardingStep: string | null = null,
) {}
public get sessionDuration(): Date | null {
public get sessionDuration(): Duration | null {
if (this._sessionDuration) {
return new Date(this._sessionDuration);
return new Duration(this._sessionDuration);
}
return null;
}
}
export interface SetUserSettingsData {
onboardingStart?: boolean
onboardingEnd?: boolean;
onboardingStep?: string | null;
sessionDuration?: number;
}

View File

@ -123,4 +123,5 @@ export enum AnalyticsErrorEventSource {
ALL_PROJECT_DASHBOARD = 'All projects dashboard error',
ONBOARDING_OVERVIEW_STEP = 'Onboarding Overview step error',
PRICING_PLAN_STEP = 'Onboarding Pricing Plan step error',
EDIT_TIMEOUT_MODAL = 'Edit session timeout error',
}

View File

@ -29,6 +29,7 @@ import ObjectDetailsModal from '@/components/modals/ObjectDetailsModal.vue';
import EnterPassphraseModal from '@/components/modals/EnterPassphraseModal.vue';
import PricingPlanModal from '@/components/modals/PricingPlanModal.vue';
import NewCreateProjectModal from '@/components/modals/NewCreateProjectModal.vue';
import EditSessionTimeoutModal from '@/components/modals/EditSessionTimeoutModal.vue';
export const APP_STATE_DROPDOWNS = {
ACCOUNT: 'isAccountDropdownShown',
@ -44,6 +45,7 @@ export const APP_STATE_DROPDOWNS = {
CHART_DATE_PICKER: 'isChartsDatePickerShown',
PERMISSIONS: 'isPermissionsDropdownShown',
PAYMENT_SELECTION: 'isPaymentSelectionShown',
TIMEOUT_SELECTOR: 'timeoutSelector',
};
enum Modals {
@ -72,6 +74,7 @@ enum Modals {
ENTER_PASSPHRASE = 'enterPassphrase',
PRICING_PLAN = 'pricingPlan',
NEW_CREATE_PROJECT = 'newCreateProject',
EDIT_SESSION_TIMEOUT = 'editSessionTimeout',
}
// modals could be of VueConstructor type or Object (for composition api components).
@ -101,4 +104,5 @@ export const MODALS: Record<Modals, unknown> = {
[Modals.ENTER_PASSPHRASE]: EnterPassphraseModal,
[Modals.PRICING_PLAN]: PricingPlanModal,
[Modals.NEW_CREATE_PROJECT]: NewCreateProjectModal,
[Modals.EDIT_SESSION_TIMEOUT]: EditSessionTimeoutModal,
};

View File

@ -13,3 +13,80 @@ export class Time {
return Math.floor(time.getTime() / 1000);
}
}
/**
* This class simplifies working with duration (nanoseconds) sent from the backend.
* */
export class Duration {
static MINUTES_15 = new Duration(9e+11);
static MINUTES_30 = new Duration(1.8e+12);
static HOUR_1 = new Duration(3.6e+12);
static DAY_1 = new Duration(8.64e+13);
static WEEK_1 = new Duration(6.048e+14);
static DAY_30 = new Duration(2.592e+15);
public constructor(
public nanoseconds: number,
) {}
/**
* parsed returns an object of the time components in this.nanoseconds
* */
get parsed(): { days: number, hours: number; seconds: number; minutes: number } {
const seconds = Math.floor((this.nanoseconds / 1000000) / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
return {
days,
hours: hours % 24,
minutes: minutes % 60,
seconds: seconds % 60,
};
}
get days(): number {
return this.parsed.days;
}
get hours(): number {
return this.parsed.hours;
}
get minutes(): number {
return this.parsed.minutes;
}
get seconds(): number {
return this.parsed.seconds;
}
/**
* shortString represents this duration in the appropriate unit.
* */
get shortString(): string {
let numberPart = this.seconds;
let unitPart = 'second';
if (this.days > 0) {
numberPart = this.days;
unitPart = 'day';
} else if (this.hours > 0) {
numberPart = this.hours;
unitPart = 'hour';
} if (this.minutes > 0) {
numberPart = this.minutes;
unitPart = 'minute';
}
if (numberPart > 1) {
unitPart = `${unitPart}s`;
}
return `${numberPart} ${unitPart}`;
}
public isEqualTo(other: Duration): boolean {
return this.nanoseconds === other.nanoseconds;
}
}

View File

@ -1,7 +1,13 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import { UpdatedUser, User, UsersApi, UserSettings } from '@/types/users';
import {
SetUserSettingsData,
UpdatedUser,
User,
UsersApi,
UserSettings,
} from '@/types/users';
/**
* Mock for UsersApi
@ -25,8 +31,8 @@ export class UsersApiMock implements UsersApi {
return Promise.resolve(new UserSettings());
}
public setOnboardingStatus(status: Partial<UserSettings>): Promise<void> {
return Promise.resolve();
public updateSettings(status: SetUserSettingsData): Promise<UserSettings> {
return Promise.resolve(new UserSettings());
}
public update(_user: UpdatedUser): Promise<void> {