diff --git a/web/satellite/src/App.vue b/web/satellite/src/App.vue index 07271023032e..a47878a5c20e 100644 --- a/web/satellite/src/App.vue +++ b/web/satellite/src/App.vue @@ -8,7 +8,7 @@ @@ -54,7 +54,6 @@ const theme = useTheme(); const route = useRoute(); const isLoading = ref(true); -const isTrialExpirationDialogShown = ref(false); /** * Indicates whether an error page should be shown in place of the router view. @@ -155,7 +154,7 @@ usersStore.$onAction(({ name, after }) => { const expirationInfo = user.value.getExpirationInfo(configStore.state.config.daysBeforeTrialEndNotification); if (user.value.freezeStatus.trialExpiredFrozen || expirationInfo.isCloseToExpiredTrial) { - isTrialExpirationDialogShown.value = true; + appStore.toggleExpirationDialog(true); } }); }); diff --git a/web/satellite/src/components/ApplicationItem.vue b/web/satellite/src/components/ApplicationItem.vue index 79c2eb862eab..2893b668a6dc 100644 --- a/web/satellite/src/components/ApplicationItem.vue +++ b/web/satellite/src/components/ApplicationItem.vue @@ -46,6 +46,7 @@ import { mdiArrowRight, mdiOpenInNew } from '@mdi/js'; import { Application } from '@/types/applications'; import { AccessType, Exposed } from '@/types/createAccessGrant'; +import { useTrialCheck } from '@/composables/useTrialCheck'; import CreateAccessDialog from '@/components/dialogs/CreateAccessDialog.vue'; @@ -53,6 +54,8 @@ const props = defineProps<{ app: Application }>(); +const { withTrialCheck } = useTrialCheck(); + const accessDialog = ref(); const dialog = ref(false); @@ -61,7 +64,9 @@ const dialog = ref(false); * Starts create S3 credentials flow. */ function onSetup(): void { - accessDialog.value?.setTypes([AccessType.S3]); - dialog.value = true; + withTrialCheck(() => { + accessDialog.value?.setTypes([AccessType.S3]); + dialog.value = true; + }); } diff --git a/web/satellite/src/components/BucketsDataTable.vue b/web/satellite/src/components/BucketsDataTable.vue index cb3621375abf..a92770a0a1ad 100644 --- a/web/satellite/src/components/BucketsDataTable.vue +++ b/web/satellite/src/components/BucketsDataTable.vue @@ -172,6 +172,7 @@ import { RouteConfig } from '@/types/router'; import { EdgeCredentials } from '@/types/accessGrants'; import { useAnalyticsStore } from '@/store/modules/analyticsStore'; import { ROUTES } from '@/router'; +import { useTrialCheck } from '@/composables/useTrialCheck'; import IconTrash from '@/components/icons/IconTrash.vue'; import IconShare from '@/components/icons/IconShare.vue'; @@ -187,8 +188,10 @@ const analyticsStore = useAnalyticsStore(); const bucketsStore = useBucketsStore(); const projectsStore = useProjectsStore(); const configStore = useConfigStore(); + const notify = useNotify(); const router = useRouter(); +const { withTrialCheck } = useTrialCheck(); const FIRST_PAGE = 1; const areBucketsFetching = ref(true); @@ -412,33 +415,35 @@ function onUpdateSort(value: SortItem[]): void { /** * Navigates to bucket page. */ -async function openBucket(bucketName: string): Promise { - if (!bucketName) { - return; - } - bucketsStore.setFileComponentBucketName(bucketName); - if (!promptForPassphrase.value) { - if (!edgeCredentials.value.accessKeyId) { - try { - await bucketsStore.setS3Client(projectsStore.state.selectedProject.id); - } catch (error) { - notify.notifyError(error, AnalyticsErrorEventSource.BUCKET_TABLE); - return; - } +function openBucket(bucketName: string): void { + withTrialCheck(async () => { + if (!bucketName) { + return; } + bucketsStore.setFileComponentBucketName(bucketName); + if (!promptForPassphrase.value) { + if (!edgeCredentials.value.accessKeyId) { + try { + await bucketsStore.setS3Client(projectsStore.state.selectedProject.id); + } catch (error) { + notify.notifyError(error, AnalyticsErrorEventSource.BUCKET_TABLE); + return; + } + } - analyticsStore.pageVisit(RouteConfig.Buckets.with(RouteConfig.UploadFile).path); - await router.push({ - name: ROUTES.Bucket.name, - params: { - browserPath: bucketsStore.state.fileComponentBucketName, - id: projectsStore.state.selectedProject.urlId, - }, - }); - return; - } - passphraseDialogCallback = () => openBucket(selectedBucketName.value); - isBucketPassphraseDialogOpen.value = true; + analyticsStore.pageVisit(RouteConfig.Buckets.with(RouteConfig.UploadFile).path); + await router.push({ + name: ROUTES.Bucket.name, + params: { + browserPath: bucketsStore.state.fileComponentBucketName, + id: projectsStore.state.selectedProject.urlId, + }, + }); + return; + } + passphraseDialogCallback = () => openBucket(selectedBucketName.value); + isBucketPassphraseDialogOpen.value = true; + }); } /** @@ -461,14 +466,16 @@ function showDeleteBucketDialog(bucketName: string): void { * Displays the Share Bucket dialog. */ function showShareBucketDialog(bucketName: string): void { - shareBucketName.value = bucketName; - if (promptForPassphrase.value) { - bucketsStore.setFileComponentBucketName(bucketName); - isBucketPassphraseDialogOpen.value = true; - passphraseDialogCallback = () => isShareBucketDialogShown.value = true; - return; - } - isShareBucketDialogShown.value = true; + withTrialCheck(() => { + shareBucketName.value = bucketName; + if (promptForPassphrase.value) { + bucketsStore.setFileComponentBucketName(bucketName); + isBucketPassphraseDialogOpen.value = true; + passphraseDialogCallback = () => isShareBucketDialogShown.value = true; + return; + } + isShareBucketDialogShown.value = true; + }); } /** diff --git a/web/satellite/src/components/billing/BillingHistoryTab.vue b/web/satellite/src/components/billing/BillingHistoryTab.vue index 8d7e6f01bef9..051c9f6dd428 100644 --- a/web/satellite/src/components/billing/BillingHistoryTab.vue +++ b/web/satellite/src/components/billing/BillingHistoryTab.vue @@ -7,6 +7,7 @@ :loading="isLoading" :headers="headers" :items="historyItems" + :items-length="historyItems.length" :must-sort="false" no-data-text="No results found" hover diff --git a/web/satellite/src/components/dialogs/DetailedUsageReportDialog.vue b/web/satellite/src/components/dialogs/DetailedUsageReportDialog.vue index 93ab45238835..afd96050bb52 100644 --- a/web/satellite/src/components/dialogs/DetailedUsageReportDialog.vue +++ b/web/satellite/src/components/dialogs/DetailedUsageReportDialog.vue @@ -101,7 +101,7 @@ const projectsStore = useProjectsStore(); const notify = useNotify(); const props = withDefaults(defineProps<{ - projectID: string + projectID?: string }>(), { projectID: '', }); diff --git a/web/satellite/src/components/onboarding/OnboardingStepperComponent.vue b/web/satellite/src/components/onboarding/OnboardingStepperComponent.vue index 90d14d14d332..e7b9d4825d01 100644 --- a/web/satellite/src/components/onboarding/OnboardingStepperComponent.vue +++ b/web/satellite/src/components/onboarding/OnboardingStepperComponent.vue @@ -21,7 +21,7 @@ :disabled="currentStep !== OnboardingStep.EncryptionPassphrase" :prepend-icon="isPassphraseDone ? mdiCheck : ''" block - @click="isManagePassphraseDialogOpen = true" + @click="onManagePassphrase" > Set a Passphrase @@ -45,7 +45,7 @@ :disabled="currentStep !== OnboardingStep.CreateBucket" :prepend-icon="isBucketDone ? mdiCheck : ''" block - @click="isBucketDialogOpen = true" + @click="onCreateBucket" > Create a Bucket @@ -139,6 +139,7 @@ import { useNotify } from '@/utils/hooks'; import { ROUTES } from '@/router'; import { OnboardingInfo } from '@/types/common'; import { AccessType, Exposed } from '@/types/createAccessGrant'; +import { useTrialCheck } from '@/composables/useTrialCheck'; import CreateBucketDialog from '@/components/dialogs/CreateBucketDialog.vue'; import NewAccessDialog from '@/components/dialogs/CreateAccessDialog.vue'; @@ -153,6 +154,7 @@ const userStore = useUsersStore(); const notify = useNotify(); const router = useRouter(); +const { withTrialCheck } = useTrialCheck(); let passphraseDialogCallback: () => void = () => {}; @@ -243,28 +245,48 @@ const edgeCredentials = computed((): EdgeCredentials => { return bucketsStore.state.edgeCredentials; }); +/** + * Starts set passphrase flow if user's free trial is not expired. + */ +function onManagePassphrase(): void { + withTrialCheck(() => { + isManagePassphraseDialogOpen.value = true; + }); +} + +/** + * Starts create bucket flow if user's free trial is not expired. + */ +function onCreateBucket(): void { + withTrialCheck(() => { + isBucketDialogOpen.value = true; + }); +} + /** * Opens the file browser for the bucket being tracked in onboarding if any * or select the only bucket the user has created * or redirect to the buckets list. */ -async function uploadFilesClicked() { - if (trackedBucketName.value) { - await openTrackedBucket(); - } else { - await bucketsStore.getBuckets(1, projectsStore.state.selectedProject.id); - const buckets = bucketsStore.state.page.buckets; - if (buckets.length === 1) { - trackedBucketName.value = buckets[0].name; +function uploadFilesClicked(): void { + withTrialCheck(async () => { + if (trackedBucketName.value) { await openTrackedBucket(); } else { - await progressStep(); - await router.push({ - name: ROUTES.Buckets.name, - params: { id: selectedProject.value.urlId }, - }); + await bucketsStore.getBuckets(1, projectsStore.state.selectedProject.id); + const buckets = bucketsStore.state.page.buckets; + if (buckets.length === 1) { + trackedBucketName.value = buckets[0].name; + await openTrackedBucket(); + } else { + await progressStep(); + await router.push({ + name: ROUTES.Buckets.name, + params: { id: selectedProject.value.urlId }, + }); + } } - } + }); } /** @@ -299,22 +321,24 @@ async function openTrackedBucket(): Promise { isBucketPassphraseDialogOpen.value = true; } -function onBucketCreated(bucketName: string) { +function onBucketCreated(bucketName: string): void { trackedBucketName.value = bucketName; progressStep(); } -async function openAccessDialog() { - if (currentStep.value === OnboardingStep.UploadFiles) { - // progress to access step so the onboarding will - // end correctly after the access is created. - await progressStep(); - } - accessDialog.value?.setTypes([AccessType.S3]); - isAccessDialogOpen.value = true; +function openAccessDialog(): void { + withTrialCheck(async () => { + if (currentStep.value === OnboardingStep.UploadFiles) { + // progress to access step so the onboarding will + // end correctly after the access is created. + await progressStep(); + } + accessDialog.value?.setTypes([AccessType.S3]); + isAccessDialogOpen.value = true; + }); } -function onAccessCreated() { +function onAccessCreated(): void { // arbitrary delay so the disappear animation // of the access dialog is visible setTimeout(() => progressStep(true), 500); @@ -325,7 +349,7 @@ function onAccessCreated() { * and saves the progress in the user settings, conditionally * ending the onboarding. */ -async function progressStep(onboardingEnd = false) { +async function progressStep(onboardingEnd = false): Promise { let onboardingStep = currentStep.value; switch (userSettings.value.onboardingStep) { case OnboardingStep.EncryptionPassphrase: @@ -357,7 +381,7 @@ async function progressStep(onboardingEnd = false) { /** * Dismisses the onboarding stepper and abandons the onboarding process. */ -async function endOnboarding() { +async function endOnboarding(): Promise { try { await userStore.updateSettings({ onboardingEnd: true }); analyticsStore.eventTriggered(AnalyticsEvent.ONBOARDING_ABANDONED); diff --git a/web/satellite/src/composables/useTrialCheck.ts b/web/satellite/src/composables/useTrialCheck.ts new file mode 100644 index 000000000000..aaf6a0569f84 --- /dev/null +++ b/web/satellite/src/composables/useTrialCheck.ts @@ -0,0 +1,43 @@ +// Copyright (C) 2024 Storj Labs, Inc. +// See LICENSE for copying information. + +import { computed } from 'vue'; + +import { useUsersStore } from '@/store/modules/usersStore'; +import { useAppStore } from '@/store/modules/appStore'; +import { useConfigStore } from '@/store/modules/configStore'; +import { User } from '@/types/users'; + +export function useTrialCheck() { + const userStore = useUsersStore(); + const appStore = useAppStore(); + const configStore = useConfigStore(); + + const user = computed(() => userStore.state.user); + + const isTrialExpirationBanner = computed(() => { + if (user.value.paidTier) return false; + + const expirationInfo = user.value.getExpirationInfo(configStore.state.config.daysBeforeTrialEndNotification); + + return user.value.freezeStatus.trialExpiredFrozen || expirationInfo.isCloseToExpiredTrial; + }); + + const isExpired = computed(() => user.value.freezeStatus.trialExpiredFrozen); + + function withTrialCheck(callback: () => void | Promise): void { + const user = userStore.state.user; + if (!user.paidTier && user.freezeStatus.trialExpiredFrozen) { + appStore.toggleExpirationDialog(true); + return; + } + + callback(); + } + + return { + isTrialExpirationBanner, + isExpired, + withTrialCheck, + }; +} diff --git a/web/satellite/src/layouts/default/ProjectNav.vue b/web/satellite/src/layouts/default/ProjectNav.vue index 540b6f5cef17..d2b64e143380 100644 --- a/web/satellite/src/layouts/default/ProjectNav.vue +++ b/web/satellite/src/layouts/default/ProjectNav.vue @@ -106,7 +106,7 @@ - + @@ -267,6 +267,7 @@ import { AnalyticsEvent } from '@/utils/constants/analyticsEventNames'; import { useBucketsStore } from '@/store/modules/bucketsStore'; import { ROUTES } from '@/router'; import { useConfigStore } from '@/store/modules/configStore'; +import { useTrialCheck } from '@/composables/useTrialCheck'; import IconProject from '@/components/icons/IconProject.vue'; import IconSettings from '@/components/icons/IconSettings.vue'; @@ -298,6 +299,7 @@ const bucketsStore = useBucketsStore(); const route = useRoute(); const router = useRouter(); +const { withTrialCheck } = useTrialCheck(); const { mdAndDown } = useDisplay(); const model = computed({ @@ -329,8 +331,6 @@ const teamURL = computed(() => `${projectURLBase.value}/${ROUTES.Team.pa const appsURL = computed(() => `${projectURLBase.value}/${ROUTES.Applications.path}`); -const isAppsPage = computed(() => configStore.state.config.applicationsPageEnabled); - /** * Returns user's own projects. */ @@ -396,6 +396,15 @@ function trackViewSupportEvent(link: string): void { window.open(link); } +/** + * Starts create project flow if user's free trial is not expired. + */ +function onCreateProject() { + withTrialCheck(() => { + isCreateProjectDialogShown.value = true; + }); +} + /** * This comparator is used to sort projects by isSelected. */ diff --git a/web/satellite/src/router/index.ts b/web/satellite/src/router/index.ts index 06c585ed6bb1..6dc7740f3ff9 100644 --- a/web/satellite/src/router/index.ts +++ b/web/satellite/src/router/index.ts @@ -147,6 +147,7 @@ const routes: RouteRecordRaw[] = [ children: [ { path: '', + name: RouteName.Project, redirect: (to: RouteLocation) => { const projRoute = new NavigationLink(to.params.id as string, RouteName.Project); return { path: ROUTES.Projects.with(projRoute).with(ROUTES.Dashboard).path }; diff --git a/web/satellite/src/store/modules/appStore.ts b/web/satellite/src/store/modules/appStore.ts index 4e7f96f5d98a..877b553308b8 100644 --- a/web/satellite/src/store/modules/appStore.ts +++ b/web/satellite/src/store/modules/appStore.ts @@ -14,6 +14,7 @@ class AppState { public isBrowserCardViewEnabled = LocalData.getBrowserCardViewEnabled(); public isNavigationDrawerShown = true; public isUpgradeFlowDialogShown = false; + public isExpirationDialogShown = false; public isAccountSetupDialogShown = false; public isProjectPassphraseDialogShown = false; public pathBeforeAccountPage: string | null = null; @@ -73,6 +74,10 @@ export const useAppStore = defineStore('app', () => { state.isUpgradeFlowDialogShown = isShown ?? !state.isUpgradeFlowDialogShown; } + function toggleExpirationDialog(isShown?: boolean): void { + state.isExpirationDialogShown = isShown ?? !state.isExpirationDialogShown; + } + function toggleAccountSetup(isShown?: boolean): void { state.isAccountSetupDialogShown = isShown ?? !state.isAccountSetupDialogShown; } @@ -116,6 +121,7 @@ export const useAppStore = defineStore('app', () => { hasProjectTableViewConfigured, toggleHasJustLoggedIn, toggleProjectPassphraseDialog, + toggleExpirationDialog, setUploadingModal, setErrorPage, removeErrorPage, diff --git a/web/satellite/src/views/Access.vue b/web/satellite/src/views/Access.vue index f94dd0ff59a1..072dea8e0bfd 100644 --- a/web/satellite/src/views/Access.vue +++ b/web/satellite/src/views/Access.vue @@ -3,12 +3,14 @@