web/satellite: project limit increase request

create modal to allow pro users to request project limit increase when
trying to create a project if they have reached the project limit.

github issue: https://github.com/storj/storj/issues/6298

Change-Id: I1799028e742c55197fa5d944c242053cf4dc3a2c
This commit is contained in:
Cameron 2023-09-29 17:27:38 -04:00
parent c8e4f0099c
commit 1aadc0974d
4 changed files with 171 additions and 47 deletions

View File

@ -488,7 +488,7 @@ export class AuthHttpApi implements UsersApi {
*
* @throws Error
*/
public async regenerateUserMFARecoveryCodes(passcode?: string, recoveryCode?:string): Promise<string[]> {
public async regenerateUserMFARecoveryCodes(passcode?: string, recoveryCode?: string): Promise<string[]> {
if (!passcode && !recoveryCode) {
throw new Error('Either passcode or recovery code should be provided');
}
@ -585,4 +585,23 @@ export class AuthHttpApi implements UsersApi {
requestID: response.headers.get('x-request-id'),
});
}
/**
* Used to request increase for user's project limit.
*
* @param limit
*/
public async requestProjectLimitIncrease(limit: string): Promise<void> {
const path = `${this.ROOT_PATH}/limit-increase`;
const response = await this.http.patch(path, limit);
if (!response.ok) {
const result = await response.json();
throw new APIError({
status: response.status,
message: result.error,
requestID: response.headers.get('x-request-id'),
});
}
}
}

View File

@ -96,8 +96,12 @@ export const useUsersStore = defineStore('users', () => {
state.user = user;
}
async function requestProjectLimitIncrease(limit: string): Promise<void> {
await api.requestProjectLimitIncrease(limit);
}
// Does nothing. It is called on login screen, and we just subscribe to this action in dashboard wrappers.
function login(): void {}
function login(): void { }
function clear() {
state.user = new User();
@ -122,5 +126,6 @@ export const useUsersStore = defineStore('users', () => {
setUser,
updateSettings,
getSettings,
requestProjectLimitIncrease,
};
});

View File

@ -76,6 +76,12 @@ export interface UsersApi {
* @throws Error
*/
regenerateUserMFARecoveryCodes(passcode?: string, recoveryCode?: string): Promise<string[]>;
/**
* Request increase for user's project limit.
*
* @throws Error
*/
requestProjectLimitIncrease(limit: string): Promise<void>;
}
/**
@ -104,7 +110,7 @@ export class User {
public _createdAt: string | null = null,
public signupPromoCode: string = '',
public freezeStatus: FreezeStatus = new FreezeStatus(),
) {}
) { }
public get createdAt(): Date | null {
if (!this._createdAt) {
@ -129,7 +135,7 @@ export class UpdatedUser {
public constructor(
public fullName: string = '',
public shortName: string = '',
) {}
) { }
public setFullName(value: string): void {
this.fullName = value.trim();
@ -151,7 +157,7 @@ export class DisableMFARequest {
public constructor(
public passcode: string = '',
public recoveryCode: string = '',
) {}
) { }
}
/**
@ -161,7 +167,7 @@ export class TokenInfo {
public constructor(
public token: string,
public expiresAt: Date,
) {}
) { }
}
/**
@ -174,7 +180,7 @@ export class UserSettings {
public onboardingEnd = false,
public passphrasePrompt = true,
public onboardingStep: string | null = null,
) {}
) { }
public get sessionDuration(): Duration | null {
if (this._sessionDuration) {
@ -199,5 +205,5 @@ export class FreezeStatus {
public constructor(
public frozen = false,
public warned = false,
) {}
) { }
}

View File

@ -12,14 +12,15 @@
:scrim="false"
@update:model-value="v => model = v"
>
<v-card rounded="xlg">
<v-card ref="innerContent" rounded="xlg">
<v-card-item class="pl-7 py-4">
<template #prepend>
<img class="d-block" src="@/../static/images/common/blueBox.svg" alt="Box">
<img v-if="isProjectLimitReached && usersStore.state.user.paidTier && showLimitIncreaseDialog" class="d-block" src="@/../static/images/modals/limit.svg" alt="Speedometer">
<img v-else class="d-block" src="@/../static/images/common/blueBox.svg" alt="Box">
</template>
<v-card-title class="font-weight-bold">
{{ isProjectLimitReached && billingEnabled ? 'Get More Projects' : 'Create New Project' }}
{{ cardTitle }}
</v-card-title>
<template #append>
@ -38,10 +39,7 @@
<v-form v-model="formValid" class="pa-7" @submit.prevent>
<v-row>
<v-col v-if="isProjectLimitReached && billingEnabled">
Upgrade to Pro Account to create more projects and gain access to higher limits.
</v-col>
<template v-else>
<template v-if="!billingEnabled || !isProjectLimitReached">
<v-col cols="12">
Projects are where you and your team can upload and manage data, and view usage statistics and billing.
</v-col>
@ -81,6 +79,42 @@
/>
</v-col>
</template>
<template v-else-if="isProjectLimitReached && usersStore.state.user.paidTier && !showLimitIncreaseDialog">
<v-col cols="12">
Request project limit increase.
</v-col>
</template>
<template v-else-if="isProjectLimitReached && usersStore.state.user.paidTier && showLimitIncreaseDialog">
<v-col cols="12">
Request a projects limit increase for your account.
</v-col>
<v-col cols="6">
<p>Projects Limit</p>
<v-text-field
class="edit-project-limit__text-field"
variant="solo-filled"
density="compact"
flat
readonly
:model-value="usersStore.state.user.projectLimit"
/>
</v-col>
<v-col cols="6">
<p>Requested Limit</p>
<v-text-field
class="edit-project-limit__text-field"
density="compact"
flat
type="number"
:rules="projectLimitRules"
:model-value="inputText"
@update:model-value="updateInputText"
/>
</v-col>
</template>
<v-col v-else>
Upgrade to Pro Account to create more projects and gain access to higher limits.
</v-col>
</v-row>
</v-form>
@ -102,7 +136,7 @@
:append-icon="isProjectLimitReached && billingEnabled ? 'mdi-arrow-right' : undefined"
@click="onPrimaryClick"
>
{{ isProjectLimitReached && billingEnabled ? 'Upgrade' : 'Create Project' }}
{{ buttonTitle }}
</v-btn>
</v-col>
</v-row>
@ -118,7 +152,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { Component, ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
VDialog,
@ -169,12 +203,15 @@ const { isLoading, withLoading } = useLoading();
const notify = useNotify();
const router = useRouter();
const innerContent = ref<Component | null>(null);
const formValid = ref<boolean>(false);
const inputText = ref<string>('');
const name = ref<string>('');
const description = ref<string>('');
const isDescriptionShown = ref<boolean>(false);
const isProjectLimitReached = ref<boolean>(false);
const isUpgradeDialogShown = ref<boolean>(false);
const showLimitIncreaseDialog = ref<boolean>(false);
const nameRules: ValidationRule<string>[] = [
RequiredRule,
@ -190,20 +227,11 @@ const descriptionRules: ValidationRule<string>[] = [
*/
const billingEnabled = computed<boolean>(() => configStore.state.config.billingFeaturesEnabled);
function startUpgradeFlow(): void {
model.value = false;
appStore.toggleUpgradeFlow(true);
}
/**
* Handles primary button click.
*/
async function onPrimaryClick(): Promise<void> {
if (isProjectLimitReached.value && billingEnabled.value) {
isUpgradeDialogShown.value = true;
return;
}
if (!isProjectLimitReached.value || !billingEnabled.value) {
if (!formValid.value) return;
await withLoading(async () => {
let project: Project;
@ -219,13 +247,79 @@ async function onPrimaryClick(): Promise<void> {
router.push(`/projects/${project.urlId}/dashboard`);
notify.success('Project created.');
});
} else if (usersStore.state.user.paidTier) {
if (showLimitIncreaseDialog.value) {
if (!formValid.value) return;
await withLoading(async () => {
try {
await usersStore.requestProjectLimitIncrease(inputText.value);
} catch (error) {
error.message = `Failed to request project limit increase. ${error.message}`;
notify.notifyError(error, AnalyticsErrorEventSource.CREATE_PROJECT_MODAL);
return;
}
model.value = false;
notify.success('Project limit increase requested');
return;
});
} else {
showLimitIncreaseDialog.value = true;
}
} else {
isUpgradeDialogShown.value = true;
}
}
watch(() => model.value, shown => {
if (!shown) return;
/*
* Returns an array of validation rules applied to the text input.
*/
const projectLimitRules = computed<ValidationRule<string>[]>(() => {
return [
RequiredRule,
v => !(isNaN(+v) || !Number.isInteger((parseFloat(v)))) || 'Invalid number',
v => (parseFloat(v) > 0) || 'Number must be positive',
];
});
/**
* Updates input refs with value from text field.
*/
function updateInputText(value: string): void {
inputText.value = value;
}
const buttonTitle = computed((): string => {
if (!isProjectLimitReached.value || !billingEnabled.value) {
return 'Create Project';
}
if (usersStore.state.user.paidTier) {
if (showLimitIncreaseDialog.value) {
return 'Submit';
}
return 'Request';
}
return 'Upgrade';
});
const cardTitle = computed((): string => {
if (!isProjectLimitReached.value || !billingEnabled.value) {
return 'Create New Project';
}
if (usersStore.state.user.paidTier && showLimitIncreaseDialog.value) {
return 'Projects Limit Request';
}
return 'Get More Projects';
});
watch(innerContent, comp => {
if (comp) {
isProjectLimitReached.value = projectsStore.state.projects.length >= usersStore.state.user.projectLimit;
isDescriptionShown.value = false;
name.value = '';
description.value = '';
inputText.value = String(usersStore.state.user.projectLimit + 1);
} else {
showLimitIncreaseDialog.value = false;
}
});
</script>