1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-21 16:18:28 +01:00

[AC-1607] Add offboarding survey to subscription pages (#7809)

* Add offboarding survey to subscription pages

* Cleaning up unused code

* Removing unused eslint suppression

* Product updates

* Jared's feedback
This commit is contained in:
Alex Morask 2024-02-09 12:08:46 -05:00 committed by GitHub
parent 64381cbae0
commit b239e3736f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 358 additions and 43 deletions

View File

@ -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<any>;
reinstatePromise: Promise<any>;
presentUserWithOffboardingSurvey$: Observable<boolean>;
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$<boolean>(
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<boolean>(
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

View File

@ -232,9 +232,28 @@
<button
bitButton
buttonType="danger"
[bitAction]="cancel"
[bitAction]="cancelWithWarning"
type="button"
*ngIf="subscription && !subscription.cancelled && !subscriptionMarkedForCancel"
*ngIf="
subscription &&
!subscription.cancelled &&
!subscriptionMarkedForCancel &&
!(presentUserWithOffboardingSurvey$ | async)
"
>
{{ "cancelSubscription" | i18n }}
</button>
<button
bitButton
buttonType="danger"
(click)="cancelWithOffboardingSurvey()"
type="button"
*ngIf="
subscription &&
!subscription.cancelled &&
!subscriptionMarkedForCancel &&
(presentUserWithOffboardingSurvey$ | async)
"
>
{{ "cancelSubscription" | i18n }}
</button>

View File

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

View File

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

View File

@ -0,0 +1,37 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle>
{{ "cancelSubscription" | i18n }}
</span>
<div bitDialogContent>
<p>{{ "sorryToSeeYouGo" | i18n }}</p>
<bit-form-field>
<bit-label>
{{ "selectCancellationReason" | i18n }}
</bit-label>
<select bitInput formControlName="reason">
<option *ngFor="let reason of reasons" [ngValue]="reason.value">
{{ reason.text }}
</option>
</select>
</bit-form-field>
<bit-form-field>
<bit-label>
{{ "anyOtherFeedback" | i18n }}
</bit-label>
<textarea rows="4" bitInput formControlName="feedback"></textarea>
<bit-hint>{{
"charactersCurrentAndMaximum" | i18n: formGroup.value.feedback.length : MaxFeedbackLength
}}</bit-hint>
</bit-form-field>
</div>
<ng-container bitDialogFooter>
<button bitButton bitFormButton buttonType="primary" type="submit">
{{ "cancelSubscription" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.Closed">
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -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<OffboardingSurveyDialogParams>,
) =>
dialogService.open<OffboardingSurveyDialogResultType, OffboardingSurveyDialogParams>(
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<OffboardingSurveyDialogResultType>,
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);
};
}

View File

@ -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."
}
}

View File

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

View File

@ -0,0 +1,9 @@
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
export abstract class BillingApiServiceAbstraction {
cancelOrganizationSubscription: (
organizationId: string,
request: SubscriptionCancellationRequest,
) => Promise<void>;
cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise<void>;
}

View File

@ -0,0 +1,4 @@
export type SubscriptionCancellationRequest = {
reason: string;
feedback?: string;
};

View File

@ -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<void> {
return this.apiService.send(
"POST",
"/organizations/" + organizationId + "/cancel",
request,
true,
false,
);
}
cancelPremiumUserSubscription(request: SubscriptionCancellationRequest): Promise<void> {
return this.apiService.send("POST", "/accounts/cancel-premium", request, true, false);
}
}

View File

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