diff --git a/apps/web/src/app/auth/trial-initiation/content/review-blurb.component.html b/apps/web/src/app/auth/trial-initiation/content/review-blurb.component.html new file mode 100644 index 0000000000..cd719a35af --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/content/review-blurb.component.html @@ -0,0 +1,13 @@ +
+

+ {{ header }} +

+
+ "{{ quote }}" +
+
+ +

{{ source }}

+
+
+
diff --git a/apps/web/src/app/auth/trial-initiation/content/review-blurb.component.ts b/apps/web/src/app/auth/trial-initiation/content/review-blurb.component.ts new file mode 100644 index 0000000000..ac67c499bb --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/content/review-blurb.component.ts @@ -0,0 +1,11 @@ +import { Component, Input } from "@angular/core"; + +@Component({ + selector: "app-review-blurb", + templateUrl: "review-blurb.component.html", +}) +export class ReviewBlurbComponent { + @Input() header: string; + @Input() quote: string; + @Input() source: string; +} diff --git a/apps/web/src/app/auth/trial-initiation/content/secrets-manager-content.component.html b/apps/web/src/app/auth/trial-initiation/content/secrets-manager-content.component.html new file mode 100644 index 0000000000..f72c127bcd --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/content/secrets-manager-content.component.html @@ -0,0 +1,32 @@ +

{{ header }}

+
+

+ Secure your business with easy-to-use secrets management +

+
+ +
+
+
+

Limited time offer

+
    +
  • + Sign up today and receive a free 12-month subscription to Bitwarden Password Manager +
  • +
  • Experience complete security across your organization
  • +
  • Secure all your sensitive credentials, from passwords to machine secrets
  • +
+
+
+
+
+ +
diff --git a/apps/web/src/app/auth/trial-initiation/content/secrets-manager-content.component.ts b/apps/web/src/app/auth/trial-initiation/content/secrets-manager-content.component.ts new file mode 100644 index 0000000000..d190623ae2 --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/content/secrets-manager-content.component.ts @@ -0,0 +1,37 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; + +@Component({ + selector: "app-secrets-manager-content", + templateUrl: "secrets-manager-content.component.html", +}) +export class SecretsManagerContentComponent implements OnInit, OnDestroy { + header: string; + + private destroy$ = new Subject(); + + constructor(private activatedRoute: ActivatedRoute) {} + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + ngOnInit(): void { + this.activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((queryParameters) => { + switch (queryParameters.org) { + case "enterprise": + this.header = "Secrets Manager for Enterprise"; + break; + case "free": + this.header = "Bitwarden Secrets Manager"; + break; + case "teams": + case "teamsStarter": + this.header = "Secrets Manager for Teams"; + break; + } + }); + } +} diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-billing-step.component.html b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-billing-step.component.html new file mode 100644 index 0000000000..ed6daca672 --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-billing-step.component.html @@ -0,0 +1,72 @@ + + + {{ "loading" | i18n }} + +
+
+
+

{{ "billingPlanLabel" | i18n }}

+
+ +
+
+ +
+
+
+

{{ "paymentType" | i18n }}

+ + +
+
+ + +
+
+
diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-billing-step.component.ts b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-billing-step.component.ts new file mode 100644 index 0000000000..a0854b4900 --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-billing-step.component.ts @@ -0,0 +1,183 @@ +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(); + + loading = true; + + annualCadence = SubscriptionCadence.Annual; + monthlyCadence = SubscriptionCadence.Monthly; + + formGroup = this.formBuilder.group({ + cadence: [SubscriptionCadence.Annual, Validators.required], + }); + formPromise: Promise; + + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.html b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.html new file mode 100644 index 0000000000..2956151d04 --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.html @@ -0,0 +1,54 @@ + + + + + + + + + + +
+

{{ "smFreeTrialThankYou" | i18n }}

+
    +
  • +

    + {{ "smFreeTrialConfirmationEmail" | i18n }} + {{ formGroup.get("email").value }}. +

    +
  • +
+
+
+ + +
+
+
diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts new file mode 100644 index 0000000000..1d83dcf01b --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts @@ -0,0 +1,76 @@ +import { Component, 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component"; + +@Component({ + selector: "app-secrets-manager-trial-free-stepper", + templateUrl: "secrets-manager-trial-free-stepper.component.html", +}) +export class SecretsManagerTrialFreeStepperComponent { + @ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent; + + formGroup = this.formBuilder.group({ + name: [ + "", + { + validators: [Validators.required, Validators.maxLength(50)], + updateOn: "change", + }, + ], + email: [ + "", + { + validators: [Validators.email], + }, + ], + }); + + subLabels = { + createAccount: + "Before creating your free organization, you first need to log in or create a personal account.", + organizationInfo: "Enter your organization information", + }; + + organizationId: string; + + constructor( + protected formBuilder: UntypedFormBuilder, + protected i18nService: I18nService, + protected organizationBillingService: OrganizationBillingService, + private router: Router, + ) {} + + accountCreated(email: string): void { + this.formGroup.get("email")?.setValue(email); + this.subLabels.createAccount = email; + this.verticalStepper.next(); + } + + async createOrganization(): Promise { + const response = await this.organizationBillingService.startFree({ + organization: { + name: this.formGroup.get("name").value, + billingEmail: this.formGroup.get("email").value, + }, + plan: { + type: PlanType.Free, + subscribeToSecretsManager: true, + isFromSecretsManagerTrial: true, + }, + }); + + this.organizationId = response.id; + this.subLabels.organizationInfo = response.name; + this.verticalStepper.next(); + } + + async navigateTo(organizationRoute: string): Promise { + await this.router.navigate(["organizations", this.organizationId, organizationRoute]); + } +} diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html new file mode 100644 index 0000000000..8a93ac8262 --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + +
+ + +
+
+
diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.ts b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.ts new file mode 100644 index 0000000000..23c51940d7 --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.ts @@ -0,0 +1,46 @@ +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 { + OrganizationCreatedEvent, + SubscriptionType, +} from "./secrets-manager-trial-billing-step.component"; + +@Component({ + selector: "app-secrets-manager-trial-paid-stepper", + templateUrl: "secrets-manager-trial-paid-stepper.component.html", +}) +export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrialFreeStepperComponent { + @ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent; + @Input() subscriptionType: string; + + billingSubLabel = this.i18nService.t("billingTrialSubLabel"); + organizationId: string; + + organizationCreated(event: OrganizationCreatedEvent) { + this.organizationId = event.organizationId; + this.billingSubLabel = event.planDescription; + this.verticalStepper.next(); + } + + steppedBack() { + this.verticalStepper.previous(); + } + + get createAccountLabel() { + const organizationType = + this.paidSubscriptionType == SubscriptionType.Enterprise ? "Enterprise" : "Teams"; + return `Before creating your ${organizationType} organization, you first need to log in or create a personal account.`; + } + + get paidSubscriptionType() { + switch (this.subscriptionType) { + case "enterprise": + return SubscriptionType.Enterprise; + case "teams": + return SubscriptionType.Teams; + } + } +} diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial.component.html b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial.component.html new file mode 100644 index 0000000000..bd1d200300 --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial.component.html @@ -0,0 +1,41 @@ + + +
+
+
+ Bitwarden +
+ +
+
+
+
+
+
+

+ {{ "startYour7DayFreeTrialOfBitwardenSecretsManagerFor" | i18n: subscriptionType }} +

+ +
+ + +
+
+
+
+
diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial.component.ts b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial.component.ts new file mode 100644 index 0000000000..3e2d9589f8 --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial.component.ts @@ -0,0 +1,30 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; + +@Component({ + selector: "app-secrets-manager-trial", + templateUrl: "secrets-manager-trial.component.html", +}) +export class SecretsManagerTrialComponent implements OnInit, OnDestroy { + subscriptionType: string; + + private destroy$ = new Subject(); + + constructor(private route: ActivatedRoute) {} + + ngOnInit(): void { + this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((queryParameters) => { + this.subscriptionType = queryParameters.org; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + get freeOrganization() { + return this.subscriptionType === "free"; + } +} diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html index e2d774bcf7..ce149edd0b 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html @@ -1,126 +1,140 @@ -
-

{{ "createAccount" | i18n }}

-
- -
-
-
-
-
-
- Bitwarden - -
- - - - - - - - - - - - - - -
+ + +
+

{{ "createAccount" | i18n }}

+
+
-
-
-
- +
+
+
+
+
+ Bitwarden + +
+ + + + + + + + + + + + + +
-
-
-
-

- {{ "startYour7DayFreeTrialOfBitwardenFor" | i18n: orgDisplayName }} -

- +
+
+
+
- - - - - - - - - - - - - -
- +
+
+
+
+

+ {{ freeTrialText }} +

+ +
+ + + + + + -
- - + + + + + + +
+ + +
+
+ +
-
+ diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts index 002e1687e0..279c23d553 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts @@ -18,6 +18,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; 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 { @@ -44,6 +45,7 @@ enum ValidLayoutParams { cnetcmpgnteams = "cnetcmpgnteams", abmenterprise = "abmenterprise", abmteams = "abmteams", + secretsManager = "secretsManager", } @Component({ @@ -77,6 +79,7 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { ValidOrgParams.individual, ]; layouts = ValidLayoutParams; + orgTypes = ValidOrgParams; referenceData: ReferenceEventRequest; @ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent; @@ -258,6 +261,15 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { return this.org; } + get freeTrialText() { + const translationKey = + this.layout === this.layouts.secretsManager + ? "startYour7DayFreeTrialOfBitwardenSecretsManagerFor" + : "startYour7DayFreeTrialOfBitwardenFor"; + + return this.i18nService.t(translationKey, this.org); + } + private setupFamilySponsorship(sponsorshipToken: string) { if (sponsorshipToken != null) { const route = this.router.createUrlTree(["setup/families-for-enterprise"], { @@ -266,4 +278,6 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { this.routerService.setPreviousUrl(route.toString()); } } + + protected readonly SubscriptionType = SubscriptionType; } diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.module.ts b/apps/web/src/app/auth/trial-initiation/trial-initiation.module.ts index 0e014bfcfc..52f11def9e 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.module.ts +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.module.ts @@ -6,6 +6,9 @@ import { FormFieldModule } from "@bitwarden/components"; import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module"; import { RegisterFormModule } from "../../auth/register-form/register-form.module"; +import { SecretsManagerTrialFreeStepperComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component"; +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 { EnvironmentSelectorModule } from "../../components/environment-selector/environment-selector.module"; @@ -25,11 +28,14 @@ import { LogoCnet5StarsComponent } from "./content/logo-cnet-5-stars.component"; import { LogoCnetComponent } from "./content/logo-cnet.component"; import { LogoForbesComponent } from "./content/logo-forbes.component"; import { LogoUSNewsComponent } from "./content/logo-us-news.component"; +import { ReviewBlurbComponent } from "./content/review-blurb.component"; import { ReviewLogoComponent } from "./content/review-logo.component"; +import { SecretsManagerContentComponent } from "./content/secrets-manager-content.component"; 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"; @@ -44,6 +50,7 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul EnvironmentSelectorModule, PaymentComponent, TaxInfoComponent, + SecretsManagerTrialBillingStepComponent, ], declarations: [ TrialInitiationComponent, @@ -69,6 +76,11 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul LogoForbesComponent, LogoUSNewsComponent, ReviewLogoComponent, + SecretsManagerContentComponent, + ReviewBlurbComponent, + SecretsManagerTrialComponent, + SecretsManagerTrialFreeStepperComponent, + SecretsManagerTrialPaidStepperComponent, ], exports: [TrialInitiationComponent], providers: [TitleCasePipe], diff --git a/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step.component.html b/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step.component.html index 34e3af03a3..427a409917 100644 --- a/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step.component.html +++ b/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step.component.html @@ -1,7 +1,10 @@
diff --git a/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step.component.ts b/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step.component.ts index 1737153224..1ff900875d 100644 --- a/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step.component.ts +++ b/apps/web/src/app/auth/trial-initiation/vertical-stepper/vertical-step.component.ts @@ -9,4 +9,5 @@ import { Component, Input } from "@angular/core"; export class VerticalStep extends CdkStep { @Input() subLabel = ""; @Input() applyBorder = true; + @Input() addSubLabelSpacing = false; } 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 1c18ab6fc4..ad9c20bef8 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 @@ -140,7 +140,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy get subscriptionLineItems() { return this.lineItems.map((lineItem: BillingSubscriptionItemResponse) => ({ name: lineItem.name, - amount: this.discountPrice(lineItem.amount), + amount: this.discountPrice(lineItem.amount, lineItem.productId), quantity: lineItem.quantity, interval: lineItem.interval, sponsoredSubscriptionItem: lineItem.sponsoredSubscriptionItem, @@ -183,7 +183,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy } get storageGbPrice() { - return this.discountPrice(this.sub.plan.PasswordManager.additionalStoragePricePerGb); + return this.sub.plan.PasswordManager.additionalStoragePricePerGb; } get seatPrice() { @@ -198,14 +198,12 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return { seatCount: this.sub.smSeats, maxAutoscaleSeats: this.sub.maxAutoscaleSmSeats, - seatPrice: this.discountPrice(this.sub.plan.SecretsManager.seatPrice), + seatPrice: this.sub.plan.SecretsManager.seatPrice, maxAutoscaleServiceAccounts: this.sub.maxAutoscaleSmServiceAccounts, additionalServiceAccounts: this.sub.smServiceAccounts - this.sub.plan.SecretsManager.baseServiceAccount, interval: this.sub.plan.isAnnual ? "year" : "month", - additionalServiceAccountPrice: this.discountPrice( - this.sub.plan.SecretsManager.additionalPricePerServiceAccount, - ), + additionalServiceAccountPrice: this.sub.plan.SecretsManager.additionalPricePerServiceAccount, baseServiceAccountCount: this.sub.plan.SecretsManager.baseServiceAccount, }; } @@ -404,9 +402,12 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy } }; - discountPrice = (price: number) => { + discountPrice = (price: number, productId: string = null) => { const discount = - !!this.customerDiscount && this.customerDiscount.active + this.customerDiscount?.active && + (!productId || + !this.customerDiscount.appliesTo.length || + this.customerDiscount.appliesTo.includes(productId)) ? price * (this.customerDiscount.percentOff / 100) : 0; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 2fb5f002f7..419dcb605d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -2531,6 +2531,15 @@ } } }, + "trialSecretsManagerThankYou": { + "message": "Thanks for signing up for Bitwarden Secrets Manager for $PLAN$!", + "placeholders": { + "plan": { + "content": "$1", + "example": "Teams" + } + } + }, "trialPaidInfoMessage": { "message": "Your $PLAN$ 7 day free trial will be converted to a paid subscription after 7 days.", "placeholders": { @@ -3471,7 +3480,7 @@ "message": "Set a seat limit for your subscription. Once this limit is reached, you will not be able to invite new members." }, "limitSmSubscriptionDesc": { - "message": "Set a seat limit for your Secrets Manger subscription. Once this limit is reached, you will not be able to invite new members." + "message": "Set a seat limit for your Secrets Manager subscription. Once this limit is reached, you will not be able to invite new members." }, "maxSeatLimit": { "message": "Seat Limit (optional)", @@ -7234,6 +7243,15 @@ } } }, + "startYour7DayFreeTrialOfBitwardenSecretsManagerFor": { + "message": "Start your 7-Day free trial of Bitwarden Secrets Manager for $ORG$", + "placeholders": { + "org": { + "content": "$1", + "example": "Organization name" + } + } + }, "next": { "message": "Next" }, @@ -7508,5 +7526,17 @@ }, "collectionEnhancementsLearnMore": { "message": "Learn more about collection management" + }, + "organizationInformation": { + "message": "Organization information" + }, + "confirmationDetails": { + "message": "Confirmation details" + }, + "smFreeTrialThankYou": { + "message": "Thank you for signing up for Bitwarden Secrets Manager!" + }, + "smFreeTrialConfirmationEmail": { + "message": "We've sent a confirmation email to your email at " } } diff --git a/clients.code-workspace b/clients.code-workspace index 608f57096b..6263182b4d 100644 --- a/clients.code-workspace +++ b/clients.code-workspace @@ -2,47 +2,47 @@ "folders": [ { "name": "root", - "path": ".", + "path": "." }, { "name": "web vault", - "path": "apps/web", + "path": "apps/web" }, { "name": "web vault (bit)", - "path": "bitwarden_license/bit-web", + "path": "bitwarden_license/bit-web" }, { "name": "cli", - "path": "apps/cli", + "path": "apps/cli" }, { "name": "desktop", - "path": "apps/desktop", + "path": "apps/desktop" }, { "name": "browser", - "path": "apps/browser", + "path": "apps/browser" }, { "name": "libs", - "path": "libs", - }, + "path": "libs" + } ], "settings": { "eslint.options": { "overrideConfig": { "parserOptions": { - "project": ["${workspaceFolder}/tsconfig.eslint.json"], - }, - }, + "project": ["${workspaceFolder}/tsconfig.eslint.json"] + } + } }, "debug.javascript.terminalOptions": { "sourceMapPathOverrides": { "webpack:///./~/*": "${workspaceFolder:root}/node_modules/*", "webpack://?:*/*": "${workspaceFolder}/*", - "webpack://@bitwarden/cli/*": "${workspaceFolder}/*", - }, + "webpack://@bitwarden/cli/*": "${workspaceFolder}/*" + } }, "jest.disabledWorkspaceFolders": [ "browser", @@ -56,14 +56,14 @@ "jest.jestCommandLine": "npx jest", "angular.enable-strict-mode-prompt": false, "typescript.preferences.importModuleSpecifier": "project-relative", - "javascript.preferences.importModuleSpecifier": "project-relative", + "javascript.preferences.importModuleSpecifier": "project-relative" }, "extensions": { "recommendations": [ "orta.vscode-jest", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", - "Angular.ng-template", - ], - }, + "Angular.ng-template" + ] + } } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index f75ae1ea12..75a39a8aae 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -76,7 +76,9 @@ import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauth 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 { BillingBannerServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-banner.service.abstraction"; +import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-billing.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"; import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; @@ -864,6 +866,16 @@ import { ModalService } from "./modal.service"; useClass: BillingBannerService, deps: [StateProvider], }, + { + provide: OrganizationBillingServiceAbstraction, + useClass: OrganizationBillingService, + deps: [ + CryptoServiceAbstraction, + EncryptService, + I18nServiceAbstraction, + OrganizationApiServiceAbstraction, + ], + }, ], }) export class JslibServicesModule {} diff --git a/libs/common/src/admin-console/models/request/organization-create.request.ts b/libs/common/src/admin-console/models/request/organization-create.request.ts index 729cf45365..9c7d620d29 100644 --- a/libs/common/src/admin-console/models/request/organization-create.request.ts +++ b/libs/common/src/admin-console/models/request/organization-create.request.ts @@ -27,4 +27,5 @@ export class OrganizationCreateRequest { useSecretsManager: boolean; additionalSmSeats: number; additionalServiceAccounts: number; + isFromSecretsManagerTrial: boolean; } diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts new file mode 100644 index 0000000000..8e2bdc2efe --- /dev/null +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -0,0 +1,45 @@ +import { OrganizationResponse } from "../../admin-console/models/response/organization.response"; +import { PaymentMethodType, PlanType } from "../enums"; + +export type OrganizationInformation = { + name: string; + billingEmail: string; + businessName?: string; +}; + +export type PlanInformation = { + type: PlanType; + passwordManagerSeats?: number; + subscribeToSecretsManager?: boolean; + isFromSecretsManagerTrial?: boolean; + secretsManagerSeats?: number; + secretsManagerServiceAccounts?: number; + storage?: number; +}; + +export type BillingInformation = { + postalCode: string; + country: string; + taxId?: string; + addressLine1?: string; + addressLine2?: string; + city?: string; + state?: string; +}; + +export type PaymentInformation = { + paymentMethod: [string, PaymentMethodType]; + billing: BillingInformation; +}; + +export type SubscriptionInformation = { + organization: OrganizationInformation; + plan?: PlanInformation; + payment?: PaymentInformation; +}; + +export abstract class OrganizationBillingServiceAbstraction { + purchaseSubscription: (subscription: SubscriptionInformation) => Promise; + + startFree: (subscription: SubscriptionInformation) => Promise; +} diff --git a/libs/common/src/billing/models/response/organization-subscription.response.ts b/libs/common/src/billing/models/response/organization-subscription.response.ts index 404540c616..c3704f6023 100644 --- a/libs/common/src/billing/models/response/organization-subscription.response.ts +++ b/libs/common/src/billing/models/response/organization-subscription.response.ts @@ -40,17 +40,13 @@ export class BillingCustomerDiscount extends BaseResponse { id: string; active: boolean; percentOff?: number; + appliesTo: string[]; constructor(response: any) { super(response); this.id = this.getResponseProperty("Id"); this.active = this.getResponseProperty("Active"); this.percentOff = this.getResponseProperty("PercentOff"); + this.appliesTo = this.getResponseProperty("AppliesTo"); } - - discountPrice = (price: number) => { - const discount = this !== null && this.active ? price * (this.percentOff / 100) : 0; - - return price - discount; - }; } diff --git a/libs/common/src/billing/models/response/subscription.response.ts b/libs/common/src/billing/models/response/subscription.response.ts index b5286ead42..0a2cb2290e 100644 --- a/libs/common/src/billing/models/response/subscription.response.ts +++ b/libs/common/src/billing/models/response/subscription.response.ts @@ -39,7 +39,7 @@ export class BillingSubscriptionResponse extends BaseResponse { constructor(response: any) { super(response); - this.trialEndDate = this.getResponseProperty("TrialStartDate"); + this.trialStartDate = this.getResponseProperty("TrialStartDate"); this.trialEndDate = this.getResponseProperty("TrialEndDate"); this.periodStartDate = this.getResponseProperty("PeriodStartDate"); this.periodEndDate = this.getResponseProperty("PeriodEndDate"); @@ -55,6 +55,7 @@ export class BillingSubscriptionResponse extends BaseResponse { } export class BillingSubscriptionItemResponse extends BaseResponse { + productId: string; name: string; amount: number; quantity: number; @@ -65,6 +66,7 @@ export class BillingSubscriptionItemResponse extends BaseResponse { constructor(response: any) { super(response); + this.productId = this.getResponseProperty("ProductId"); this.name = this.getResponseProperty("Name"); this.amount = this.getResponseProperty("Amount"); this.quantity = this.getResponseProperty("Quantity"); diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts new file mode 100644 index 0000000000..3e5d67ade6 --- /dev/null +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -0,0 +1,143 @@ +import { OrganizationApiServiceAbstraction as OrganizationApiService } from "../../admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; +import { OrganizationKeysRequest } from "../../admin-console/models/request/organization-keys.request"; +import { OrganizationResponse } from "../../admin-console/models/response/organization.response"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { OrgKey } from "../../types/key"; +import { + OrganizationBillingServiceAbstraction, + OrganizationInformation, + PaymentInformation, + PlanInformation, + SubscriptionInformation, +} from "../abstractions/organization-billing.service"; +import { PlanType } from "../enums"; + +interface OrganizationKeys { + encryptedKey: EncString; + publicKey: string; + encryptedPrivateKey: EncString; + encryptedCollectionName: EncString; +} + +export class OrganizationBillingService implements OrganizationBillingServiceAbstraction { + constructor( + private cryptoService: CryptoService, + private encryptService: EncryptService, + private i18nService: I18nService, + private organizationApiService: OrganizationApiService, + ) {} + + async purchaseSubscription(subscription: SubscriptionInformation): Promise { + const request = new OrganizationCreateRequest(); + + const organizationKeys = await this.makeOrganizationKeys(); + + this.setOrganizationKeys(request, organizationKeys); + + this.setOrganizationInformation(request, subscription.organization); + + this.setPlanInformation(request, subscription.plan); + + this.setPaymentInformation(request, subscription.payment); + + return await this.organizationApiService.create(request); + } + + async startFree(subscription: SubscriptionInformation): Promise { + const request = new OrganizationCreateRequest(); + + const organizationKeys = await this.makeOrganizationKeys(); + + this.setOrganizationKeys(request, organizationKeys); + + this.setOrganizationInformation(request, subscription.organization); + + this.setPlanInformation(request, subscription.plan); + + return await this.organizationApiService.create(request); + } + + private async makeOrganizationKeys(): Promise { + const [encryptedKey, key] = await this.cryptoService.makeOrgKey(); + const [publicKey, encryptedPrivateKey] = await this.cryptoService.makeKeyPair(key); + const encryptedCollectionName = await this.encryptService.encrypt( + this.i18nService.t("defaultCollection"), + key, + ); + return { + encryptedKey, + publicKey, + encryptedPrivateKey, + encryptedCollectionName, + }; + } + + private setOrganizationInformation( + request: OrganizationCreateRequest, + information: OrganizationInformation, + ): void { + request.name = information.name; + request.businessName = information.businessName; + request.billingEmail = information.billingEmail; + } + + private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void { + request.key = keys.encryptedKey.encryptedString; + request.keys = new OrganizationKeysRequest( + keys.publicKey, + keys.encryptedPrivateKey.encryptedString, + ); + request.collectionName = keys.encryptedCollectionName.encryptedString; + } + + private setPaymentInformation( + request: OrganizationCreateRequest, + information: PaymentInformation, + ) { + const [paymentToken, paymentMethodType] = information.paymentMethod; + request.paymentToken = paymentToken; + request.paymentMethodType = paymentMethodType; + + const billingInformation = information.billing; + request.billingAddressPostalCode = billingInformation.postalCode; + request.billingAddressCountry = billingInformation.country; + + if (billingInformation.taxId) { + request.taxIdNumber = billingInformation.taxId; + request.billingAddressLine1 = billingInformation.addressLine1; + request.billingAddressLine2 = billingInformation.addressLine2; + request.billingAddressCity = billingInformation.city; + request.billingAddressState = billingInformation.state; + } + } + + private setPlanInformation( + request: OrganizationCreateRequest, + information: PlanInformation, + ): void { + request.planType = information.type; + + if (request.planType === PlanType.Free) { + request.useSecretsManager = information.subscribeToSecretsManager; + request.isFromSecretsManagerTrial = information.isFromSecretsManagerTrial; + return; + } + + request.additionalSeats = information.passwordManagerSeats; + + if (information.subscribeToSecretsManager) { + request.useSecretsManager = true; + request.isFromSecretsManagerTrial = information.isFromSecretsManagerTrial; + request.additionalSmSeats = information.secretsManagerSeats; + request.additionalServiceAccounts = information.secretsManagerServiceAccounts; + } + + if (information.storage) { + request.additionalStorageGb = information.storage; + } + } +}