mirror of
https://github.com/bitwarden/browser
synced 2025-12-13 06:43:35 +00:00
[AC-1863] Send initiationPath on organization or user signup (#7747)
* Sent initiation path for organization and user signups * Rename organizationQueryParameter > organizationTypeQueryParameter * Jared's feedback * Split PM & SM initiation path
This commit is contained in:
@@ -70,7 +70,6 @@ export class RegisterFormComponent extends BaseRegisterComponent {
|
||||
async ngOnInit() {
|
||||
await super.ngOnInit();
|
||||
this.referenceData = this.referenceDataValue;
|
||||
|
||||
if (this.queryParamEmail) {
|
||||
this.formGroup.get("email")?.setValue(this.queryParamEmail);
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { ProductType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { BillingSharedModule, PaymentComponent, TaxInfoComponent } from "../../../billing/shared";
|
||||
|
||||
export interface OrganizationInfo {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface OrganizationCreatedEvent {
|
||||
organizationId: string;
|
||||
planDescription: string;
|
||||
}
|
||||
|
||||
enum SubscriptionCadence {
|
||||
Monthly,
|
||||
Annual,
|
||||
}
|
||||
|
||||
export enum SubscriptionType {
|
||||
Teams,
|
||||
Enterprise,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-secrets-manager-trial-billing-step",
|
||||
templateUrl: "secrets-manager-trial-billing-step.component.html",
|
||||
imports: [BillingSharedModule],
|
||||
standalone: true,
|
||||
})
|
||||
export class SecretsManagerTrialBillingStepComponent implements OnInit {
|
||||
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
|
||||
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
|
||||
@Input() organizationInfo: OrganizationInfo;
|
||||
@Input() subscriptionType: SubscriptionType;
|
||||
@Output() steppedBack = new EventEmitter();
|
||||
@Output() organizationCreated = new EventEmitter<OrganizationCreatedEvent>();
|
||||
|
||||
loading = true;
|
||||
|
||||
annualCadence = SubscriptionCadence.Annual;
|
||||
monthlyCadence = SubscriptionCadence.Monthly;
|
||||
|
||||
formGroup = this.formBuilder.group({
|
||||
cadence: [SubscriptionCadence.Annual, Validators.required],
|
||||
});
|
||||
formPromise: Promise<string>;
|
||||
|
||||
applicablePlans: PlanResponse[];
|
||||
annualPlan: PlanResponse;
|
||||
monthlyPlan: PlanResponse;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
private formBuilder: FormBuilder,
|
||||
private messagingService: MessagingService,
|
||||
private organizationBillingService: OrganizationBillingService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const plans = await this.apiService.getPlans();
|
||||
this.applicablePlans = plans.data.filter(this.isApplicable);
|
||||
this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual);
|
||||
this.monthlyPlan = this.findPlanFor(SubscriptionCadence.Monthly);
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
this.formPromise = this.createOrganization();
|
||||
|
||||
const organizationId = await this.formPromise;
|
||||
const planDescription = this.getPlanDescription();
|
||||
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
this.i18nService.t("organizationCreated"),
|
||||
this.i18nService.t("organizationReadyToGo"),
|
||||
);
|
||||
|
||||
this.organizationCreated.emit({
|
||||
organizationId,
|
||||
planDescription,
|
||||
});
|
||||
|
||||
this.messagingService.send("organizationCreated", organizationId);
|
||||
}
|
||||
|
||||
protected changedCountry() {
|
||||
this.paymentComponent.hideBank = this.taxInfoComponent.taxInfo.country !== "US";
|
||||
if (
|
||||
this.paymentComponent.hideBank &&
|
||||
this.paymentComponent.method === PaymentMethodType.BankAccount
|
||||
) {
|
||||
this.paymentComponent.method = PaymentMethodType.Card;
|
||||
this.paymentComponent.changeMethod();
|
||||
}
|
||||
}
|
||||
|
||||
protected stepBack() {
|
||||
this.steppedBack.emit();
|
||||
}
|
||||
|
||||
private async createOrganization(): Promise<string> {
|
||||
const plan = this.findPlanFor(this.formGroup.value.cadence);
|
||||
const paymentMethod = await this.paymentComponent.createPaymentToken();
|
||||
|
||||
const response = await this.organizationBillingService.purchaseSubscription({
|
||||
organization: {
|
||||
name: this.organizationInfo.name,
|
||||
billingEmail: this.organizationInfo.email,
|
||||
},
|
||||
plan: {
|
||||
type: plan.type,
|
||||
passwordManagerSeats: 1,
|
||||
subscribeToSecretsManager: true,
|
||||
isFromSecretsManagerTrial: true,
|
||||
secretsManagerSeats: 1,
|
||||
},
|
||||
payment: {
|
||||
paymentMethod,
|
||||
billing: {
|
||||
postalCode: this.taxInfoComponent.taxInfo.postalCode,
|
||||
country: this.taxInfoComponent.taxInfo.country,
|
||||
taxId: this.taxInfoComponent.taxInfo.taxId,
|
||||
addressLine1: this.taxInfoComponent.taxInfo.line1,
|
||||
addressLine2: this.taxInfoComponent.taxInfo.line2,
|
||||
city: this.taxInfoComponent.taxInfo.city,
|
||||
state: this.taxInfoComponent.taxInfo.state,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return response.id;
|
||||
}
|
||||
|
||||
private findPlanFor(cadence: SubscriptionCadence) {
|
||||
switch (this.subscriptionType) {
|
||||
case SubscriptionType.Teams:
|
||||
return cadence === SubscriptionCadence.Annual
|
||||
? this.applicablePlans.find((plan) => plan.type === PlanType.TeamsAnnually)
|
||||
: this.applicablePlans.find((plan) => plan.type === PlanType.TeamsMonthly);
|
||||
case SubscriptionType.Enterprise:
|
||||
return cadence === SubscriptionCadence.Annual
|
||||
? this.applicablePlans.find((plan) => plan.type === PlanType.EnterpriseAnnually)
|
||||
: this.applicablePlans.find((plan) => plan.type === PlanType.EnterpriseMonthly);
|
||||
}
|
||||
}
|
||||
|
||||
private getPlanDescription(): string {
|
||||
const plan = this.findPlanFor(this.formGroup.value.cadence);
|
||||
const price =
|
||||
plan.SecretsManager.basePrice === 0
|
||||
? plan.SecretsManager.seatPrice
|
||||
: plan.SecretsManager.basePrice;
|
||||
|
||||
switch (this.formGroup.value.cadence) {
|
||||
case SubscriptionCadence.Annual:
|
||||
return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`;
|
||||
case SubscriptionCadence.Monthly:
|
||||
return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`;
|
||||
}
|
||||
}
|
||||
|
||||
private isApplicable(plan: PlanResponse): boolean {
|
||||
const hasSecretsManager = !!plan.SecretsManager;
|
||||
const isTeamsOrEnterprise =
|
||||
plan.product === ProductType.Teams || plan.product === ProductType.Enterprise;
|
||||
const notDisabledOrLegacy = !plan.disabled && !plan.legacyYear;
|
||||
return hasSecretsManager && isTeamsOrEnterprise && notDisabledOrLegacy;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,11 @@
|
||||
[subLabel]="subLabels.createAccount"
|
||||
[addSubLabelSpacing]="true"
|
||||
>
|
||||
<app-register-form [isInTrialFlow]="true" (createdAccount)="accountCreated($event)">
|
||||
<app-register-form
|
||||
[referenceDataValue]="referenceEventRequest"
|
||||
[isInTrialFlow]="true"
|
||||
(createdAccount)="accountCreated($event)"
|
||||
>
|
||||
</app-register-form>
|
||||
</app-vertical-step>
|
||||
<app-vertical-step
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Component, ViewChild } from "@angular/core";
|
||||
import { Component, OnInit, ViewChild } from "@angular/core";
|
||||
import { UntypedFormBuilder, Validators } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
|
||||
@@ -12,7 +13,7 @@ import { VerticalStepperComponent } from "../../trial-initiation/vertical-steppe
|
||||
selector: "app-secrets-manager-trial-free-stepper",
|
||||
templateUrl: "secrets-manager-trial-free-stepper.component.html",
|
||||
})
|
||||
export class SecretsManagerTrialFreeStepperComponent {
|
||||
export class SecretsManagerTrialFreeStepperComponent implements OnInit {
|
||||
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
|
||||
|
||||
formGroup = this.formBuilder.group({
|
||||
@@ -39,6 +40,8 @@ export class SecretsManagerTrialFreeStepperComponent {
|
||||
|
||||
organizationId: string;
|
||||
|
||||
referenceEventRequest: ReferenceEventRequest;
|
||||
|
||||
constructor(
|
||||
protected formBuilder: UntypedFormBuilder,
|
||||
protected i18nService: I18nService,
|
||||
@@ -46,6 +49,11 @@ export class SecretsManagerTrialFreeStepperComponent {
|
||||
private router: Router,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.referenceEventRequest = new ReferenceEventRequest();
|
||||
this.referenceEventRequest.initiationPath = "Secrets Manager trial from marketing website";
|
||||
}
|
||||
|
||||
accountCreated(email: string): void {
|
||||
this.formGroup.get("email")?.setValue(email);
|
||||
this.subLabels.createAccount = email;
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
[subLabel]="createAccountLabel"
|
||||
[addSubLabelSpacing]="true"
|
||||
>
|
||||
<app-register-form [isInTrialFlow]="true" (createdAccount)="accountCreated($event)">
|
||||
<app-register-form
|
||||
[referenceDataValue]="referenceEventRequest"
|
||||
[isInTrialFlow]="true"
|
||||
(createdAccount)="accountCreated($event)"
|
||||
>
|
||||
</app-register-form>
|
||||
</app-vertical-step>
|
||||
<app-vertical-step
|
||||
@@ -24,21 +28,22 @@
|
||||
</button>
|
||||
</app-vertical-step>
|
||||
<app-vertical-step label="{{ 'billing' | i18n | titlecase }}" [subLabel]="billingSubLabel">
|
||||
<app-secrets-manager-trial-billing-step
|
||||
<app-trial-billing-step
|
||||
*ngIf="stepper.selectedIndex === 2"
|
||||
[organizationInfo]="{
|
||||
name: formGroup.get('name').value,
|
||||
email: formGroup.get('email').value
|
||||
email: formGroup.get('email').value,
|
||||
type: productType
|
||||
}"
|
||||
[subscriptionType]="paidSubscriptionType"
|
||||
[subscriptionProduct]="SubscriptionProduct.SecretsManager"
|
||||
(steppedBack)="steppedBack()"
|
||||
(organizationCreated)="organizationCreated($event)"
|
||||
></app-secrets-manager-trial-billing-step>
|
||||
></app-trial-billing-step>
|
||||
</app-vertical-step>
|
||||
<app-vertical-step label="{{ 'confirmationDetails' | i18n | titlecase }}">
|
||||
<app-trial-confirmation-details
|
||||
[email]="formGroup.get('email').value"
|
||||
[orgLabel]="subscriptionType"
|
||||
[orgLabel]="organizationTypeQueryParameter"
|
||||
></app-trial-confirmation-details>
|
||||
<div class="tw-mb-3 tw-flex">
|
||||
<button type="button" bitButton buttonType="primary" (click)="navigateTo('vault')">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Component, Input, ViewChild } from "@angular/core";
|
||||
|
||||
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
|
||||
import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component";
|
||||
import { ProductType } from "@bitwarden/common/enums";
|
||||
|
||||
import {
|
||||
OrganizationCreatedEvent,
|
||||
SubscriptionType,
|
||||
} from "./secrets-manager-trial-billing-step.component";
|
||||
SubscriptionProduct,
|
||||
TrialOrganizationType,
|
||||
} from "../../../billing/accounts/trial-initiation/trial-billing-step.component";
|
||||
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
|
||||
import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-secrets-manager-trial-paid-stepper",
|
||||
@@ -14,7 +16,7 @@ import {
|
||||
})
|
||||
export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrialFreeStepperComponent {
|
||||
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
|
||||
@Input() subscriptionType: string;
|
||||
@Input() organizationTypeQueryParameter: string;
|
||||
|
||||
billingSubLabel = this.i18nService.t("billingTrialSubLabel");
|
||||
organizationId: string;
|
||||
@@ -31,16 +33,24 @@ export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrial
|
||||
|
||||
get createAccountLabel() {
|
||||
const organizationType =
|
||||
this.paidSubscriptionType == SubscriptionType.Enterprise ? "Enterprise" : "Teams";
|
||||
this.productType === ProductType.TeamsStarter
|
||||
? "Teams Starter"
|
||||
: ProductType[this.productType];
|
||||
return `Before creating your ${organizationType} organization, you first need to log in or create a personal account.`;
|
||||
}
|
||||
|
||||
get paidSubscriptionType() {
|
||||
switch (this.subscriptionType) {
|
||||
get productType(): TrialOrganizationType {
|
||||
switch (this.organizationTypeQueryParameter) {
|
||||
case "enterprise":
|
||||
return SubscriptionType.Enterprise;
|
||||
return ProductType.Enterprise;
|
||||
case "families":
|
||||
return ProductType.Families;
|
||||
case "teams":
|
||||
return SubscriptionType.Teams;
|
||||
return ProductType.Teams;
|
||||
case "teamsStarter":
|
||||
return ProductType.TeamsStarter;
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly SubscriptionProduct = SubscriptionProduct;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
class="tw-flex tw-h-auto tw-w-full tw-gap-5 tw-rounded-t tw-bg-secondary-100"
|
||||
>
|
||||
<h2 class="tw-pb-4 tw-pl-4 tw-pt-5 tw-text-base tw-font-bold tw-uppercase">
|
||||
{{ "startYour7DayFreeTrialOfBitwardenSecretsManagerFor" | i18n: subscriptionType }}
|
||||
{{
|
||||
"startYour7DayFreeTrialOfBitwardenSecretsManagerFor"
|
||||
| i18n: organizationTypeQueryParameter
|
||||
}}
|
||||
</h2>
|
||||
<environment-selector
|
||||
class="tw-mr-4 tw-mt-6 tw-flex-shrink-0 tw-text-end"
|
||||
@@ -32,7 +35,7 @@
|
||||
></app-secrets-manager-trial-free-stepper>
|
||||
<app-secrets-manager-trial-paid-stepper
|
||||
*ngIf="!freeOrganization"
|
||||
[subscriptionType]="subscriptionType"
|
||||
[organizationTypeQueryParameter]="organizationTypeQueryParameter"
|
||||
></app-secrets-manager-trial-paid-stepper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Subject, takeUntil } from "rxjs";
|
||||
templateUrl: "secrets-manager-trial.component.html",
|
||||
})
|
||||
export class SecretsManagerTrialComponent implements OnInit, OnDestroy {
|
||||
subscriptionType: string;
|
||||
organizationTypeQueryParameter: string;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@@ -15,7 +15,7 @@ export class SecretsManagerTrialComponent implements OnInit, OnDestroy {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((queryParameters) => {
|
||||
this.subscriptionType = queryParameters.org;
|
||||
this.organizationTypeQueryParameter = queryParameters.org;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,6 @@ export class SecretsManagerTrialComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
get freeOrganization() {
|
||||
return this.subscriptionType === "free";
|
||||
return this.organizationTypeQueryParameter === "free";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,14 +97,18 @@
|
||||
</button>
|
||||
</app-vertical-step>
|
||||
<app-vertical-step label="Billing" [subLabel]="billingSubLabel">
|
||||
<app-billing
|
||||
<app-trial-billing-step
|
||||
*ngIf="stepper.selectedIndex === 2"
|
||||
[plan]="plan"
|
||||
[product]="product"
|
||||
[orgInfoForm]="orgInfoFormGroup"
|
||||
(previousStep)="previousStep()"
|
||||
(onTrialBillingSuccess)="billingSuccess($event)"
|
||||
></app-billing>
|
||||
[organizationInfo]="{
|
||||
name: orgInfoFormGroup.get('name').value,
|
||||
email: orgInfoFormGroup.get('email').value,
|
||||
type: trialOrganizationType
|
||||
}"
|
||||
[subscriptionProduct]="SubscriptionProduct.PasswordManager"
|
||||
(steppedBack)="previousStep()"
|
||||
(organizationCreated)="createdOrganization($event)"
|
||||
>
|
||||
</app-trial-billing-step>
|
||||
</app-vertical-step>
|
||||
<app-vertical-step label="Confirmation Details" [applyBorder]="false">
|
||||
<app-trial-confirmation-details
|
||||
|
||||
@@ -17,8 +17,13 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
|
||||
import {
|
||||
OrganizationCreatedEvent,
|
||||
SubscriptionProduct,
|
||||
TrialOrganizationType,
|
||||
} from "../../billing/accounts/trial-initiation/trial-billing-step.component";
|
||||
|
||||
import { RouterService } from "./../../core/router.service";
|
||||
import { SubscriptionType } from "./secrets-manager/secrets-manager-trial-billing-step.component";
|
||||
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
|
||||
|
||||
enum ValidOrgParams {
|
||||
@@ -79,7 +84,6 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
ValidOrgParams.individual,
|
||||
];
|
||||
layouts = ValidLayoutParams;
|
||||
orgTypes = ValidOrgParams;
|
||||
referenceData: ReferenceEventRequest;
|
||||
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
|
||||
|
||||
@@ -171,6 +175,10 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
// Are they coming from an email for sponsoring a families organization
|
||||
// After logging in redirect them to setup the families sponsorship
|
||||
this.setupFamilySponsorship(qParams.sponsorshipToken);
|
||||
|
||||
this.referenceData.initiationPath = this.accountCreateOnly
|
||||
? "Registration form"
|
||||
: "Password Manager trial from marketing website";
|
||||
});
|
||||
|
||||
const invite = await this.stateService.getOrganizationInvitation();
|
||||
@@ -241,6 +249,12 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
this.verticalStepper.next();
|
||||
}
|
||||
|
||||
createdOrganization(event: OrganizationCreatedEvent) {
|
||||
this.orgId = event.organizationId;
|
||||
this.billingSubLabel = event.planDescription;
|
||||
this.verticalStepper.next();
|
||||
}
|
||||
|
||||
navigateToOrgVault() {
|
||||
// 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
|
||||
@@ -274,6 +288,15 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
return this.i18nService.t(translationKey, this.org);
|
||||
}
|
||||
|
||||
get trialOrganizationType(): TrialOrganizationType {
|
||||
switch (this.product) {
|
||||
case ProductType.Free:
|
||||
return null;
|
||||
default:
|
||||
return this.product;
|
||||
}
|
||||
}
|
||||
|
||||
private setupFamilySponsorship(sponsorshipToken: string) {
|
||||
if (sponsorshipToken != null) {
|
||||
const route = this.router.createUrlTree(["setup/families-for-enterprise"], {
|
||||
@@ -283,5 +306,5 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly SubscriptionType = SubscriptionType;
|
||||
protected readonly SubscriptionProduct = SubscriptionProduct;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SecretsManagerTrialFreeStepperComponent } from "../../auth/trial-initia
|
||||
import { SecretsManagerTrialPaidStepperComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component";
|
||||
import { SecretsManagerTrialComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial.component";
|
||||
import { PaymentComponent, TaxInfoComponent } from "../../billing";
|
||||
import { BillingComponent } from "../../billing/accounts/trial-initiation/billing.component";
|
||||
import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiation/trial-billing-step.component";
|
||||
import { EnvironmentSelectorModule } from "../../components/environment-selector/environment-selector.module";
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
@@ -35,7 +35,6 @@ import { TeamsContentComponent } from "./content/teams-content.component";
|
||||
import { Teams1ContentComponent } from "./content/teams1-content.component";
|
||||
import { Teams2ContentComponent } from "./content/teams2-content.component";
|
||||
import { Teams3ContentComponent } from "./content/teams3-content.component";
|
||||
import { SecretsManagerTrialBillingStepComponent } from "./secrets-manager/secrets-manager-trial-billing-step.component";
|
||||
import { TrialInitiationComponent } from "./trial-initiation.component";
|
||||
import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module";
|
||||
|
||||
@@ -50,14 +49,13 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
|
||||
EnvironmentSelectorModule,
|
||||
PaymentComponent,
|
||||
TaxInfoComponent,
|
||||
SecretsManagerTrialBillingStepComponent,
|
||||
TrialBillingStepComponent,
|
||||
],
|
||||
declarations: [
|
||||
TrialInitiationComponent,
|
||||
EnterpriseContentComponent,
|
||||
TeamsContentComponent,
|
||||
ConfirmationDetailsComponent,
|
||||
BillingComponent,
|
||||
DefaultContentComponent,
|
||||
EnterpriseContentComponent,
|
||||
Enterprise1ContentComponent,
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<form #form [formGroup]="formGroup" [appApiAction]="formPromise" (ngSubmit)="submit()">
|
||||
<div class="tw-container tw-mb-3">
|
||||
<div class="tw-mb-6">
|
||||
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "billingPlanLabel" | i18n }}</h2>
|
||||
<div class="tw-mb-1 tw-items-center" *ngFor="let selectablePlan of selectablePlans">
|
||||
<label class="tw- tw-block tw-text-main" for="interval{{ selectablePlan.type }}">
|
||||
<input
|
||||
checked
|
||||
class="tw-h-4 tw-w-4 tw-align-middle"
|
||||
id="interval{{ selectablePlan.type }}"
|
||||
name="plan"
|
||||
type="radio"
|
||||
[value]="selectablePlan.type"
|
||||
formControlName="plan"
|
||||
/>
|
||||
<ng-container *ngIf="selectablePlan.isAnnual">
|
||||
<ng-container *ngIf="selectablePlan.PasswordManager">
|
||||
{{ "annual" | i18n }} -
|
||||
{{
|
||||
(selectablePlan.PasswordManager.basePrice === 0
|
||||
? selectablePlan.PasswordManager.seatPrice
|
||||
: selectablePlan.PasswordManager.basePrice
|
||||
) | currency: "$"
|
||||
}}
|
||||
/{{ "yr" | i18n }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!selectablePlan.PasswordManager && selectablePlan.SecretsManager">
|
||||
{{ "annual" | i18n }} -
|
||||
{{
|
||||
(selectablePlan.SecretsManager.basePrice === 0
|
||||
? selectablePlan.SecretsManager.seatPrice
|
||||
: selectablePlan.SecretsManager.basePrice
|
||||
) | currency: "$"
|
||||
}}
|
||||
/{{ "yr" | i18n }}
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!selectablePlan.isAnnual">
|
||||
<ng-container *ngIf="selectablePlan.PasswordManager">
|
||||
{{ "monthly" | i18n }} -
|
||||
{{
|
||||
(selectablePlan.PasswordManager.basePrice === 0
|
||||
? selectablePlan.PasswordManager.seatPrice
|
||||
: selectablePlan.PasswordManager.basePrice
|
||||
) | currency: "$"
|
||||
}}
|
||||
/{{ "monthAbbr" | i18n }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!selectablePlan.PasswordManager && selectablePlan.SecretsManager">
|
||||
{{ "monthly" | i18n }} -
|
||||
{{
|
||||
(selectablePlan.SecretsManager.basePrice === 0
|
||||
? selectablePlan.SecretsManager.seatPrice
|
||||
: selectablePlan.SecretsManager.basePrice
|
||||
) | currency: "$"
|
||||
}}
|
||||
/{{ "monthAbbr" | i18n }}
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tw-mb-4">
|
||||
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2>
|
||||
<app-payment [hideCredit]="true" [trialFlow]="true"></app-payment>
|
||||
<app-tax-info [trialFlow]="true" (onCountryChanged)="changedCountry()"></app-tax-info>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-space-x-2">
|
||||
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
|
||||
{{ "startTrial" | i18n }}
|
||||
</button>
|
||||
|
||||
<button bitButton type="button" buttonType="secondary" (click)="stepBack()">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { FormGroup } from "@angular/forms";
|
||||
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { ProductType } from "@bitwarden/common/enums";
|
||||
|
||||
import { OrganizationPlansComponent } from "../../organizations";
|
||||
|
||||
@Component({
|
||||
selector: "app-billing",
|
||||
templateUrl: "./billing.component.html",
|
||||
})
|
||||
export class BillingComponent extends OrganizationPlansComponent {
|
||||
@Input() orgInfoForm: FormGroup;
|
||||
@Output() previousStep = new EventEmitter();
|
||||
|
||||
async ngOnInit() {
|
||||
const additionalSeats =
|
||||
this.product == ProductType.Families || this.plan === PlanType.TeamsStarter ? 0 : 1;
|
||||
this.formGroup.patchValue({
|
||||
name: this.orgInfoForm.value.name,
|
||||
billingEmail: this.orgInfoForm.value.email,
|
||||
additionalSeats: additionalSeats,
|
||||
plan: this.plan,
|
||||
product: this.product,
|
||||
});
|
||||
this.isInTrialFlow = true;
|
||||
await super.ngOnInit();
|
||||
}
|
||||
|
||||
stepBack() {
|
||||
this.previousStep.emit();
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="tw-container tw-mb-3">
|
||||
<div class="tw-mb-6">
|
||||
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "billingPlanLabel" | i18n }}</h2>
|
||||
<div class="tw-mb-1 tw-items-center">
|
||||
<div class="tw-mb-1 tw-items-center" *ngIf="annualPlan !== null">
|
||||
<label class="tw- tw-block tw-text-main" for="annual">
|
||||
<input
|
||||
class="tw-h-4 tw-w-4 tw-align-middle"
|
||||
@@ -27,16 +27,11 @@
|
||||
formControlName="cadence"
|
||||
/>
|
||||
{{ "annual" | i18n }} -
|
||||
{{
|
||||
(annualPlan.SecretsManager.basePrice === 0
|
||||
? annualPlan.SecretsManager.seatPrice
|
||||
: annualPlan.SecretsManager.basePrice
|
||||
) | currency: "$"
|
||||
}}
|
||||
{{ getPriceFor(annualCadence) | currency: "$" }}
|
||||
/{{ "yr" | i18n }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="tw-mb-1 tw-items-center">
|
||||
<div class="tw-mb-1 tw-items-center" *ngIf="monthlyPlan !== null">
|
||||
<label class="tw- tw-block tw-text-main" for="monthly">
|
||||
<input
|
||||
class="tw-h-4 tw-w-4 tw-align-middle"
|
||||
@@ -47,12 +42,7 @@
|
||||
formControlName="cadence"
|
||||
/>
|
||||
{{ "monthly" | i18n }} -
|
||||
{{
|
||||
(monthlyPlan.SecretsManager.basePrice === 0
|
||||
? monthlyPlan.SecretsManager.seatPrice
|
||||
: monthlyPlan.SecretsManager.basePrice
|
||||
) | currency: "$"
|
||||
}}
|
||||
{{ getPriceFor(monthlyCadence) | currency: "$" }}
|
||||
/{{ "monthAbbr" | i18n }}
|
||||
</label>
|
||||
</div>
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import {
|
||||
BillingInformation,
|
||||
OrganizationBillingServiceAbstraction as OrganizationBillingService,
|
||||
OrganizationInformation,
|
||||
PaymentInformation,
|
||||
PlanInformation,
|
||||
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { ProductType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { BillingSharedModule, PaymentComponent, TaxInfoComponent } from "../../shared";
|
||||
|
||||
export type TrialOrganizationType = Exclude<ProductType, ProductType.Free>;
|
||||
|
||||
export interface OrganizationInfo {
|
||||
name: string;
|
||||
email: string;
|
||||
type: TrialOrganizationType;
|
||||
}
|
||||
|
||||
export interface OrganizationCreatedEvent {
|
||||
organizationId: string;
|
||||
planDescription: string;
|
||||
}
|
||||
|
||||
enum SubscriptionCadence {
|
||||
Annual,
|
||||
Monthly,
|
||||
}
|
||||
|
||||
export enum SubscriptionProduct {
|
||||
PasswordManager,
|
||||
SecretsManager,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-trial-billing-step",
|
||||
templateUrl: "trial-billing-step.component.html",
|
||||
imports: [BillingSharedModule],
|
||||
standalone: true,
|
||||
})
|
||||
export class TrialBillingStepComponent implements OnInit {
|
||||
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
|
||||
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
|
||||
@Input() organizationInfo: OrganizationInfo;
|
||||
@Input() subscriptionProduct: SubscriptionProduct = SubscriptionProduct.PasswordManager;
|
||||
@Output() steppedBack = new EventEmitter();
|
||||
@Output() organizationCreated = new EventEmitter<OrganizationCreatedEvent>();
|
||||
|
||||
loading = true;
|
||||
|
||||
annualCadence = SubscriptionCadence.Annual;
|
||||
monthlyCadence = SubscriptionCadence.Monthly;
|
||||
|
||||
formGroup = this.formBuilder.group({
|
||||
cadence: [SubscriptionCadence.Annual, Validators.required],
|
||||
});
|
||||
formPromise: Promise<string>;
|
||||
|
||||
applicablePlans: PlanResponse[];
|
||||
annualPlan?: PlanResponse;
|
||||
monthlyPlan?: PlanResponse;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
private formBuilder: FormBuilder,
|
||||
private messagingService: MessagingService,
|
||||
private organizationBillingService: OrganizationBillingService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const plans = await this.apiService.getPlans();
|
||||
this.applicablePlans = plans.data.filter(this.isApplicable);
|
||||
this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual);
|
||||
this.monthlyPlan = this.findPlanFor(SubscriptionCadence.Monthly);
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
this.formPromise = this.createOrganization();
|
||||
|
||||
const organizationId = await this.formPromise;
|
||||
const planDescription = this.getPlanDescription();
|
||||
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
this.i18nService.t("organizationCreated"),
|
||||
this.i18nService.t("organizationReadyToGo"),
|
||||
);
|
||||
|
||||
this.organizationCreated.emit({
|
||||
organizationId,
|
||||
planDescription,
|
||||
});
|
||||
|
||||
this.messagingService.send("organizationCreated", organizationId);
|
||||
}
|
||||
|
||||
protected changedCountry() {
|
||||
this.paymentComponent.hideBank = this.taxInfoComponent.taxInfo.country !== "US";
|
||||
if (
|
||||
this.paymentComponent.hideBank &&
|
||||
this.paymentComponent.method === PaymentMethodType.BankAccount
|
||||
) {
|
||||
this.paymentComponent.method = PaymentMethodType.Card;
|
||||
this.paymentComponent.changeMethod();
|
||||
}
|
||||
}
|
||||
|
||||
protected getPriceFor(cadence: SubscriptionCadence): number {
|
||||
const plan = this.findPlanFor(cadence);
|
||||
return this.subscriptionProduct === SubscriptionProduct.PasswordManager
|
||||
? plan.PasswordManager.basePrice === 0
|
||||
? plan.PasswordManager.seatPrice
|
||||
: plan.PasswordManager.basePrice
|
||||
: plan.SecretsManager.basePrice === 0
|
||||
? plan.SecretsManager.seatPrice
|
||||
: plan.SecretsManager.basePrice;
|
||||
}
|
||||
|
||||
protected stepBack() {
|
||||
this.steppedBack.emit();
|
||||
}
|
||||
|
||||
private async createOrganization(): Promise<string> {
|
||||
const planResponse = this.findPlanFor(this.formGroup.value.cadence);
|
||||
const paymentMethod = await this.paymentComponent.createPaymentToken();
|
||||
|
||||
const organization: OrganizationInformation = {
|
||||
name: this.organizationInfo.name,
|
||||
billingEmail: this.organizationInfo.email,
|
||||
initiationPath:
|
||||
this.subscriptionProduct === SubscriptionProduct.PasswordManager
|
||||
? "Password Manager trial from marketing website"
|
||||
: "Secrets Manager trial from marketing website",
|
||||
};
|
||||
|
||||
const plan: PlanInformation = {
|
||||
type: planResponse.type,
|
||||
passwordManagerSeats: 1,
|
||||
};
|
||||
|
||||
if (this.subscriptionProduct === SubscriptionProduct.SecretsManager) {
|
||||
plan.subscribeToSecretsManager = true;
|
||||
plan.isFromSecretsManagerTrial = true;
|
||||
plan.secretsManagerSeats = 1;
|
||||
}
|
||||
|
||||
const payment: PaymentInformation = {
|
||||
paymentMethod,
|
||||
billing: this.getBillingInformationFromTaxInfoComponent(),
|
||||
};
|
||||
|
||||
const response = await this.organizationBillingService.purchaseSubscription({
|
||||
organization,
|
||||
plan,
|
||||
payment,
|
||||
});
|
||||
|
||||
return response.id;
|
||||
}
|
||||
|
||||
private productTypeToPlanTypeMap: {
|
||||
[productType in TrialOrganizationType]: {
|
||||
[cadence in SubscriptionCadence]?: PlanType;
|
||||
};
|
||||
} = {
|
||||
[ProductType.Enterprise]: {
|
||||
[SubscriptionCadence.Annual]: PlanType.EnterpriseAnnually,
|
||||
[SubscriptionCadence.Monthly]: PlanType.EnterpriseMonthly,
|
||||
},
|
||||
[ProductType.Families]: {
|
||||
[SubscriptionCadence.Annual]: PlanType.FamiliesAnnually,
|
||||
// No monthly option for Families plan
|
||||
},
|
||||
[ProductType.Teams]: {
|
||||
[SubscriptionCadence.Annual]: PlanType.TeamsAnnually,
|
||||
[SubscriptionCadence.Monthly]: PlanType.TeamsMonthly,
|
||||
},
|
||||
[ProductType.TeamsStarter]: {
|
||||
// No annual option for Teams Starter plan
|
||||
[SubscriptionCadence.Monthly]: PlanType.TeamsStarter,
|
||||
},
|
||||
};
|
||||
|
||||
private findPlanFor(cadence: SubscriptionCadence): PlanResponse | null {
|
||||
const productType = this.organizationInfo.type;
|
||||
const planType = this.productTypeToPlanTypeMap[productType]?.[cadence];
|
||||
return planType ? this.applicablePlans.find((plan) => plan.type === planType) : null;
|
||||
}
|
||||
|
||||
private getBillingInformationFromTaxInfoComponent(): BillingInformation {
|
||||
return {
|
||||
postalCode: this.taxInfoComponent.taxInfo.postalCode,
|
||||
country: this.taxInfoComponent.taxInfo.country,
|
||||
taxId: this.taxInfoComponent.taxInfo.taxId,
|
||||
addressLine1: this.taxInfoComponent.taxInfo.line1,
|
||||
addressLine2: this.taxInfoComponent.taxInfo.line2,
|
||||
city: this.taxInfoComponent.taxInfo.city,
|
||||
state: this.taxInfoComponent.taxInfo.state,
|
||||
};
|
||||
}
|
||||
|
||||
private getPlanDescription(): string {
|
||||
const plan = this.findPlanFor(this.formGroup.value.cadence);
|
||||
const price =
|
||||
this.subscriptionProduct === SubscriptionProduct.PasswordManager
|
||||
? plan.PasswordManager.basePrice === 0
|
||||
? plan.PasswordManager.seatPrice
|
||||
: plan.PasswordManager.basePrice
|
||||
: plan.SecretsManager.basePrice === 0
|
||||
? plan.SecretsManager.seatPrice
|
||||
: plan.SecretsManager.basePrice;
|
||||
|
||||
switch (this.formGroup.value.cadence) {
|
||||
case SubscriptionCadence.Annual:
|
||||
return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`;
|
||||
case SubscriptionCadence.Monthly:
|
||||
return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`;
|
||||
}
|
||||
}
|
||||
|
||||
private isApplicable(plan: PlanResponse): boolean {
|
||||
const hasCorrectProductType =
|
||||
plan.product === ProductType.Enterprise ||
|
||||
plan.product === ProductType.Families ||
|
||||
plan.product === ProductType.Teams ||
|
||||
plan.product === ProductType.TeamsStarter;
|
||||
const notDisabledOrLegacy = !plan.disabled && !plan.legacyYear;
|
||||
return hasCorrectProductType && notDisabledOrLegacy;
|
||||
}
|
||||
}
|
||||
@@ -642,6 +642,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
request.collectionName = collectionCt;
|
||||
request.name = this.formGroup.controls.name.value;
|
||||
request.billingEmail = this.formGroup.controls.billingEmail.value;
|
||||
request.initiationPath = "New organization creation in-product";
|
||||
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
|
||||
|
||||
if (this.selectedPlan.type === PlanType.Free) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PaymentMethodType, PlanType } from "../../../billing/enums";
|
||||
import { InitiationPath } from "../../../models/request/reference-event.request";
|
||||
|
||||
import { OrganizationKeysRequest } from "./organization-keys.request";
|
||||
|
||||
@@ -23,9 +24,9 @@ export class OrganizationCreateRequest {
|
||||
billingAddressState: string;
|
||||
billingAddressPostalCode: string;
|
||||
billingAddressCountry: string;
|
||||
|
||||
useSecretsManager: boolean;
|
||||
additionalSmSeats: number;
|
||||
additionalServiceAccounts: number;
|
||||
isFromSecretsManagerTrial: boolean;
|
||||
initiationPath: InitiationPath;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
|
||||
import { InitiationPath } from "../../models/request/reference-event.request";
|
||||
import { PaymentMethodType, PlanType } from "../enums";
|
||||
|
||||
export type OrganizationInformation = {
|
||||
name: string;
|
||||
billingEmail: string;
|
||||
businessName?: string;
|
||||
initiationPath?: InitiationPath;
|
||||
};
|
||||
|
||||
export type PlanInformation = {
|
||||
|
||||
@@ -76,6 +76,18 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
};
|
||||
}
|
||||
|
||||
private prohibitsAdditionalSeats(planType: PlanType) {
|
||||
switch (planType) {
|
||||
case PlanType.Free:
|
||||
case PlanType.FamiliesAnnually:
|
||||
case PlanType.FamiliesAnnually2019:
|
||||
case PlanType.TeamsStarter:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private setOrganizationInformation(
|
||||
request: OrganizationCreateRequest,
|
||||
information: OrganizationInformation,
|
||||
@@ -83,6 +95,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
request.name = information.name;
|
||||
request.businessName = information.businessName;
|
||||
request.billingEmail = information.billingEmail;
|
||||
request.initiationPath = information.initiationPath;
|
||||
}
|
||||
|
||||
private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void {
|
||||
@@ -121,7 +134,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
): void {
|
||||
request.planType = information.type;
|
||||
|
||||
if (request.planType === PlanType.Free) {
|
||||
if (this.prohibitsAdditionalSeats(request.planType)) {
|
||||
request.useSecretsManager = information.subscribeToSecretsManager;
|
||||
request.isFromSecretsManagerTrial = information.isFromSecretsManagerTrial;
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
export type InitiationPath =
|
||||
| "Registration form"
|
||||
| "Password Manager trial from marketing website"
|
||||
| "Secrets Manager trial from marketing website"
|
||||
| "New organization creation in-product"
|
||||
| "Upgrade in-product";
|
||||
|
||||
export class ReferenceEventRequest {
|
||||
id: string;
|
||||
session: string;
|
||||
layout: string;
|
||||
flow: string;
|
||||
initiationPath: InitiationPath;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user