diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 51cebc595da..fd4bb62a5ee 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -1,8 +1,11 @@ import { Component, OnInit } from "@angular/core"; import { Router } from "@angular/router"; +import { lastValueFrom, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction as ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -11,6 +14,11 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { DialogService } from "@bitwarden/components"; +import { + OffboardingSurveyDialogResultType, + openOffboardingSurvey, +} from "../shared/offboarding-survey.component"; + @Component({ templateUrl: "user-subscription.component.html", }) @@ -26,6 +34,7 @@ export class UserSubscriptionComponent implements OnInit { cancelPromise: Promise; reinstatePromise: Promise; + presentUserWithOffboardingSurvey$: Observable; constructor( private stateService: StateService, @@ -37,12 +46,16 @@ export class UserSubscriptionComponent implements OnInit { private fileDownloadService: FileDownloadService, private dialogService: DialogService, private environmentService: EnvironmentService, + private configService: ConfigService, ) { this.selfHosted = platformUtilsService.isSelfHost(); this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl(); } async ngOnInit() { + this.presentUserWithOffboardingSurvey$ = this.configService.getFeatureFlag$( + FeatureFlag.AC1607_PresentUserOffboardingSurvey, + ); await this.load(); this.firstLoaded = true; } @@ -93,36 +106,17 @@ export class UserSubscriptionComponent implements OnInit { } } - async cancel() { - if (this.loading) { - return; - } + cancel = async () => { + const presentUserWithOffboardingSurvey = await this.configService.getFeatureFlag( + FeatureFlag.AC1607_PresentUserOffboardingSurvey, + ); - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "cancelSubscription" }, - content: { key: "cancelConfirmation" }, - type: "warning", - }); - - if (!confirmed) { - return; + if (presentUserWithOffboardingSurvey) { + await this.cancelWithOffboardingSurvey(); + } else { + await this.cancelWithWarning(); } - - try { - this.cancelPromise = this.apiService.postCancelPremium(); - await this.cancelPromise; - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t("canceledSubscription"), - ); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.load(); - } catch (e) { - this.logService.error(e); - } - } + }; downloadLicense() { if (this.loading) { @@ -166,6 +160,55 @@ export class UserSubscriptionComponent implements OnInit { } } + private cancelWithOffboardingSurvey = async () => { + const reference = openOffboardingSurvey(this.dialogService, { + data: { + type: "User", + }, + }); + + this.cancelPromise = lastValueFrom(reference.closed); + + const result = await this.cancelPromise; + + if (result === OffboardingSurveyDialogResultType.Closed) { + return; + } + + await this.load(); + }; + + private async cancelWithWarning() { + if (this.loading) { + return; + } + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "cancelSubscription" }, + content: { key: "cancelConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + try { + this.cancelPromise = this.apiService.postCancelPremium(); + await this.cancelPromise; + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("canceledSubscription"), + ); + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.load(); + } catch (e) { + this.logService.error(e); + } + } + get subscriptionMarkedForCancel() { return ( this.subscription != null && !this.subscription.cancelled && this.subscription.cancelAtEndDate diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index d4978787944..290433ccf18 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -232,9 +232,28 @@ + diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 09f0f9ca40f..c4c6bde9b7f 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { concatMap, firstValueFrom, Subject, takeUntil } from "rxjs"; +import { concatMap, firstValueFrom, lastValueFrom, Observable, Subject, takeUntil } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; @@ -11,11 +11,18 @@ import { PlanType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { BillingSubscriptionItemResponse } from "@bitwarden/common/billing/models/response/subscription.response"; import { ProductType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction as ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; +import { + OffboardingSurveyDialogResultType, + openOffboardingSurvey, +} from "../shared/offboarding-survey.component"; + import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; import { SecretsManagerSubscriptionOptions } from "./sm-adjust-subscription.component"; @@ -33,11 +40,10 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy showAdjustStorage = false; hasBillingSyncToken: boolean; showAdjustSecretsManager = false; - showSecretsManagerSubscribe = false; - firstLoaded = false; loading: boolean; + presentUserWithOffboardingSurvey$: Observable; protected readonly teamsStarter = ProductType.TeamsStarter; @@ -52,6 +58,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy private organizationApiService: OrganizationApiServiceAbstraction, private route: ActivatedRoute, private dialogService: DialogService, + private configService: ConfigService, ) {} async ngOnInit() { @@ -71,6 +78,10 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy takeUntil(this.destroy$), ) .subscribe(); + + this.presentUserWithOffboardingSurvey$ = this.configService.getFeatureFlag$( + FeatureFlag.AC1607_PresentUserOffboardingSurvey, + ); } ngOnDestroy() { @@ -168,10 +179,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy : 0; } - get storageProgressWidth() { - return this.storagePercentage < 5 ? 5 : 0; - } - get billingInterval() { const monthly = !this.sub.plan.isAnnual; return monthly ? "month" : "year"; @@ -211,10 +218,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return this.sub.plan.PasswordManager.hasAdditionalSeatsOption; } - get isAdmin() { - return this.userOrg.isAdmin; - } - get isSponsoredSubscription(): boolean { return this.sub.subscription?.items.some((i) => i.sponsoredSubscriptionItem); } @@ -270,7 +273,24 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy ); } - cancel = async () => { + cancelWithOffboardingSurvey = async () => { + const reference = openOffboardingSurvey(this.dialogService, { + data: { + type: "Organization", + id: this.organizationId, + }, + }); + + const result = await lastValueFrom(reference.closed); + + if (result === OffboardingSurveyDialogResultType.Closed) { + return; + } + + await this.load(); + }; + + cancelWithWarning = async () => { if (this.loading) { return; } diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index 1b8f083a876..303df189ab9 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -6,6 +6,7 @@ import { AddCreditComponent } from "./add-credit.component"; import { AdjustPaymentComponent } from "./adjust-payment.component"; import { AdjustStorageComponent } from "./adjust-storage.component"; import { BillingHistoryComponent } from "./billing-history.component"; +import { OffboardingSurveyComponent } from "./offboarding-survey.component"; import { PaymentMethodComponent } from "./payment-method.component"; import { PaymentComponent } from "./payment.component"; import { SecretsManagerSubscribeComponent } from "./sm-subscribe.component"; @@ -22,16 +23,17 @@ import { UpdateLicenseComponent } from "./update-license.component"; PaymentMethodComponent, SecretsManagerSubscribeComponent, UpdateLicenseComponent, + OffboardingSurveyComponent, ], exports: [ SharedModule, PaymentComponent, TaxInfoComponent, - AdjustStorageComponent, BillingHistoryComponent, SecretsManagerSubscribeComponent, UpdateLicenseComponent, + OffboardingSurveyComponent, ], }) export class BillingSharedModule {} diff --git a/apps/web/src/app/billing/shared/offboarding-survey.component.html b/apps/web/src/app/billing/shared/offboarding-survey.component.html new file mode 100644 index 00000000000..db28f5d9b77 --- /dev/null +++ b/apps/web/src/app/billing/shared/offboarding-survey.component.html @@ -0,0 +1,37 @@ +
+ + + {{ "cancelSubscription" | i18n }} + +
+

{{ "sorryToSeeYouGo" | i18n }}

+ + + {{ "selectCancellationReason" | i18n }} + + + + + + {{ "anyOtherFeedback" | i18n }} + + + {{ + "charactersCurrentAndMaximum" | i18n: formGroup.value.feedback.length : MaxFeedbackLength + }} + +
+ + + + +
+
diff --git a/apps/web/src/app/billing/shared/offboarding-survey.component.ts b/apps/web/src/app/billing/shared/offboarding-survey.component.ts new file mode 100644 index 00000000000..73a460f8c8f --- /dev/null +++ b/apps/web/src/app/billing/shared/offboarding-survey.component.ts @@ -0,0 +1,117 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; + +import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; + +type UserOffboardingParams = { + type: "User"; +}; + +type OrganizationOffboardingParams = { + type: "Organization"; + id: string; +}; + +export type OffboardingSurveyDialogParams = UserOffboardingParams | OrganizationOffboardingParams; + +export enum OffboardingSurveyDialogResultType { + Closed = "closed", + Submitted = "submitted", +} + +type Reason = { + value: string; + text: string; +}; + +export const openOffboardingSurvey = ( + dialogService: DialogService, + dialogConfig: DialogConfig, +) => + dialogService.open( + OffboardingSurveyComponent, + dialogConfig, + ); + +@Component({ + selector: "app-cancel-subscription-form", + templateUrl: "offboarding-survey.component.html", +}) +export class OffboardingSurveyComponent { + protected ResultType = OffboardingSurveyDialogResultType; + protected readonly MaxFeedbackLength = 400; + + protected readonly reasons: Reason[] = [ + { + value: null, + text: this.i18nService.t("selectPlaceholder"), + }, + { + value: "missing_features", + text: this.i18nService.t("missingFeatures"), + }, + { + value: "switched_service", + text: this.i18nService.t("movingToAnotherTool"), + }, + { + value: "too_complex", + text: this.i18nService.t("tooDifficultToUse"), + }, + { + value: "unused", + text: this.i18nService.t("notUsingEnough"), + }, + { + value: "too_expensive", + text: this.i18nService.t("tooExpensive"), + }, + { + value: "other", + text: this.i18nService.t("other"), + }, + ]; + + protected formGroup = this.formBuilder.group({ + reason: [this.reasons[0].value, [Validators.required]], + feedback: ["", [Validators.maxLength(this.MaxFeedbackLength)]], + }); + + constructor( + @Inject(DIALOG_DATA) private dialogParams: OffboardingSurveyDialogParams, + private dialogRef: DialogRef, + private formBuilder: FormBuilder, + private billingApiService: BillingApiService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + ) {} + + submit = async () => { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + return; + } + + const request = { + reason: this.formGroup.value.reason, + feedback: this.formGroup.value.feedback, + }; + + this.dialogParams.type === "Organization" + ? await this.billingApiService.cancelOrganizationSubscription(this.dialogParams.id, request) + : await this.billingApiService.cancelPremiumUserSubscription(request); + + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("canceledSubscription"), + ); + + this.dialogRef.close(this.ResultType.Submitted); + }; +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 45eebe4d637..7cc35b67c39 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7525,5 +7525,37 @@ }, "confirmCollectionEnhancementsDialogContent": { "message": "Turning on this feature will deprecate the manager role and replace it with a Can manage permission. This will take a few moments. Do not make any organization changes until it is complete. Are you sure you want to proceed?" + }, + "sorryToSeeYouGo": { + "message": "Sorry to see you go! Help improve Bitwarden by sharing why you're canceling.", + "description": "A message shown to users as part of an offboarding survey asking them to provide more information on their subscription cancelation." + }, + "selectCancellationReason": { + "message": "Select a reason for canceling", + "description": "Used as a form field label for a select input on the offboarding survey." + }, + "anyOtherFeedback": { + "message": "Is there any other feedback you'd like to share?", + "description": "Used as a form field label for a textarea input on the offboarding survey." + }, + "missingFeatures": { + "message": "Missing features", + "description": "An option for the offboarding survey shown when a user cancels their subscription." + }, + "movingToAnotherTool": { + "message": "Moving to another tool", + "description": "An option for the offboarding survey shown when a user cancels their subscription." + }, + "tooDifficultToUse": { + "message": "Too difficult to use", + "description": "An option for the offboarding survey shown when a user cancels their subscription." + }, + "notUsingEnough": { + "message": "Not using enough", + "description": "An option for the offboarding survey shown when a user cancels their subscription." + }, + "tooExpensive": { + "message": "Too expensive", + "description": "An option for the offboarding survey shown when a user cancels their subscription." } } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c4108c796cd..c621d9ffa9c 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -82,8 +82,10 @@ import { UserVerificationService } from "@bitwarden/common/auth/services/user-ve import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-api.service"; import { WebAuthnLoginPrfCryptoService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-crypto.service"; import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; import { BillingBannerServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-banner.service.abstraction"; import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-billing.service"; +import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { BillingBannerService } from "@bitwarden/common/billing/services/billing-banner.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -916,6 +918,11 @@ import { ModalService } from "./modal.service"; useClass: VaultSettingsService, deps: [StateProvider], }, + { + provide: BillingApiServiceAbstraction, + useClass: BillingApiService, + deps: [ApiServiceAbstraction], + }, ], }) export class JslibServicesModule {} diff --git a/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts new file mode 100644 index 00000000000..24a61e7dc66 --- /dev/null +++ b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts @@ -0,0 +1,9 @@ +import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; + +export abstract class BillingApiServiceAbstraction { + cancelOrganizationSubscription: ( + organizationId: string, + request: SubscriptionCancellationRequest, + ) => Promise; + cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise; +} diff --git a/libs/common/src/billing/models/request/subscription-cancellation.request.ts b/libs/common/src/billing/models/request/subscription-cancellation.request.ts new file mode 100644 index 00000000000..33975d1b24b --- /dev/null +++ b/libs/common/src/billing/models/request/subscription-cancellation.request.ts @@ -0,0 +1,4 @@ +export type SubscriptionCancellationRequest = { + reason: string; + feedback?: string; +}; diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts new file mode 100644 index 00000000000..e3044a2293e --- /dev/null +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -0,0 +1,24 @@ +import { ApiService } from "../../abstractions/api.service"; +import { BillingApiServiceAbstraction } from "../../billing/abstractions/billilng-api.service.abstraction"; +import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; + +export class BillingApiService implements BillingApiServiceAbstraction { + constructor(private apiService: ApiService) {} + + cancelOrganizationSubscription( + organizationId: string, + request: SubscriptionCancellationRequest, + ): Promise { + return this.apiService.send( + "POST", + "/organizations/" + organizationId + "/cancel", + request, + true, + false, + ); + } + + cancelPremiumUserSubscription(request: SubscriptionCancellationRequest): Promise { + return this.apiService.send("POST", "/accounts/cancel-premium", request, true, false); + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index de78c8810a5..bf5287801d0 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -7,6 +7,7 @@ export enum FeatureFlag { GeneratorToolsModernization = "generator-tools-modernization", KeyRotationImprovements = "key-rotation-improvements", FlexibleCollectionsMigration = "flexible-collections-migration", + AC1607_PresentUserOffboardingSurvey = "AC-1607_present-user-offboarding-survey", } // Replace this with a type safe lookup of the feature flag values in PM-2282