diff --git a/apps/web/src/app/billing/accounts/trial-initiation/billing.component.ts b/apps/web/src/app/billing/accounts/trial-initiation/billing.component.ts index 35aee967450..1830b49a45b 100644 --- a/apps/web/src/app/billing/accounts/trial-initiation/billing.component.ts +++ b/apps/web/src/app/billing/accounts/trial-initiation/billing.component.ts @@ -1,18 +1,7 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { UntypedFormBuilder, FormGroup } from "@angular/forms"; -import { Router } from "@angular/router"; +import { FormGroup } from "@angular/forms"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProductType } from "@bitwarden/common/enums"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { OrganizationPlansComponent } from "../../settings/organization-plans.component"; @@ -24,36 +13,6 @@ export class BillingComponent extends OrganizationPlansComponent { @Input() orgInfoForm: FormGroup; @Output() previousStep = new EventEmitter(); - constructor( - apiService: ApiService, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - cryptoService: CryptoService, - router: Router, - syncService: SyncService, - policyService: PolicyService, - organizationService: OrganizationService, - logService: LogService, - messagingService: MessagingService, - formBuilder: UntypedFormBuilder, - organizationApiService: OrganizationApiServiceAbstraction - ) { - super( - apiService, - i18nService, - platformUtilsService, - cryptoService, - router, - syncService, - policyService, - organizationService, - logService, - messagingService, - formBuilder, - organizationApiService - ); - } - async ngOnInit() { const additionalSeats = this.product == ProductType.Families ? 0 : 1; this.formGroup.patchValue({ diff --git a/apps/web/src/app/billing/organizations/adjust-subscription.component.html b/apps/web/src/app/billing/organizations/adjust-subscription.component.html index f7a6f616dbd..148d507a6a8 100644 --- a/apps/web/src/app/billing/organizations/adjust-subscription.component.html +++ b/apps/web/src/app/billing/organizations/adjust-subscription.component.html @@ -1,7 +1,7 @@
-
+
{{ "maxSeatLimit" | i18n }} -

+
+

{{ "subscription" | i18n }} - {{ "loading" | i18n }} + {{ "loading" | i18n }}

@@ -21,7 +21,7 @@ [providerName]="userOrg.providerName" > - + -
-
-
-
{{ "billingPlan" | i18n }}
-
{{ sub.plan.name }}
- -
{{ "status" | i18n }}
-
- {{ - isSponsoredSubscription ? "sponsored" : subscription.status || "-" - }} - {{ - "pendingCancellation" | i18n - }} -
-
- {{ "subscriptionExpiration" | i18n }} -
-
- {{ nextInvoice ? (nextInvoice.date | date : "mediumDate") : "-" }} -
-
-
-
- -
- {{ "details" | i18n }} - - - - + - + - -
+
+
{{ "billingPlan" | i18n }}
+
{{ sub.plan.name }}
+ +
{{ "status" | i18n }}
+
+ {{ + isSponsoredSubscription ? "sponsored" : subscription.status || "-" + }} + {{ + "pendingCancellation" | i18n + }} +
+
+ {{ "subscriptionExpiration" | i18n }} +
+
+ {{ nextInvoice ? (nextInvoice.date | date : "mediumDate") : "-" }} +
+
+
+ +
+ {{ + "details" | i18n + }} + + + +
+ {{ productName(i.bitwardenProduct) }} - {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ {{ i.amount | currency : "$" }} {{ i.quantity * i.amount | currency : "$" }} /{{ i.interval | i18n }} + {{ i.quantity * i.amount | currency : "$" }} /{{ i.interval | i18n }} +
-
-
-
- - - + + + + {{ "passwordManager" | i18n }} - {{ "freeOrganization" | i18n }} + {{ "free" | i18n }} + + + {{ "secretsManager" | i18n }} - {{ "freeOrganization" | i18n }} + {{ "free" | i18n }} + + + + +

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

{{ "manageSubscription" | i18n }}

-

{{ subscriptionDesc }}

+

{{ "manageSubscription" | i18n }}

+

{{ subscriptionDesc }}

-
- - -
+

{{ "passwordManager" | i18n }}

+ +
-

{{ "storage" | i18n }}

-

{{ "subscriptionStorage" | i18n : sub.maxStorageGb || 0 : sub.storageName || "0 MB" }}

-
-
- {{ storagePercentage / 100 | percent }} -
-
+

{{ "storage" | i18n }}

+

+ {{ "subscriptionStorage" | i18n : sub.maxStorageGb || 0 : sub.storageName || "0 MB" }} +

+ -
-
+
+
-
@@ -177,13 +181,21 @@ >
+ +

{{ "secretsManager" | i18n }}

+ +
-

{{ "selfHostingTitle" | i18n }}

-

+

{{ "selfHostingTitle" | i18n }}

+

{{ "selfHostingEnterpriseOrganizationSectionCopy" | i18n }}

-
+
-
+
-

{{ "additionalOptions" | i18n }}

-

+

{{ "additionalOptions" | i18n }}

+

{{ "additionalOptionsDesc" | i18n }}

-
+
- diff --git a/apps/web/src/app/billing/organizations/secrets-manager/enroll.component.ts b/apps/web/src/app/billing/organizations/secrets-manager/enroll.component.ts deleted file mode 100644 index edcedc8d548..00000000000 --- a/apps/web/src/app/billing/organizations/secrets-manager/enroll.component.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Component, Input, OnInit } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; - -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { OrganizationEnrollSecretsManagerRequest } from "@bitwarden/common/admin-console/models/request/organization/organization-enroll-secrets-manager.request"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; - -import { flagEnabled } from "../../../../utils/flags"; - -@Component({ - selector: "sm-enroll", - templateUrl: "enroll.component.html", -}) -export class SecretsManagerEnrollComponent implements OnInit { - @Input() enabled: boolean; - @Input() organizationId: string; - - protected formGroup = this.formBuilder.group({ - enabled: [false], - }); - - protected showSecretsManager = false; - - constructor( - private formBuilder: FormBuilder, - private organizationApiService: OrganizationApiServiceAbstraction, - private platformUtilsService: PlatformUtilsService, - private i18nService: I18nService, - private syncService: SyncService - ) { - this.showSecretsManager = flagEnabled("secretsManager"); - } - - ngOnInit(): void { - this.formGroup.setValue({ - enabled: this.enabled, - }); - } - - protected submit = async () => { - this.formGroup.markAllAsTouched(); - - const request = new OrganizationEnrollSecretsManagerRequest(); - request.enabled = this.formGroup.value.enabled; - - await this.organizationApiService.updateEnrollSecretsManager(this.organizationId, request); - await this.syncService.fullSync(true); - this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated")); - }; -} diff --git a/apps/web/src/app/billing/organizations/secrets-manager/index.ts b/apps/web/src/app/billing/organizations/secrets-manager/index.ts new file mode 100644 index 00000000000..fc0bcd35343 --- /dev/null +++ b/apps/web/src/app/billing/organizations/secrets-manager/index.ts @@ -0,0 +1,3 @@ +export * from "./sm-billing.module"; +export * from "./sm-subscribe.component"; +export * from "./sm-subscribe-standalone.component"; diff --git a/apps/web/src/app/billing/organizations/secrets-manager/sm-adjust-subscription.component.html b/apps/web/src/app/billing/organizations/secrets-manager/sm-adjust-subscription.component.html new file mode 100644 index 00000000000..be882cf66b9 --- /dev/null +++ b/apps/web/src/app/billing/organizations/secrets-manager/sm-adjust-subscription.component.html @@ -0,0 +1,93 @@ +
+ + {{ "subscriptionSeats" | i18n }} + + + {{ "total" | i18n }}: + {{ formGroup.value.seatCount || 0 }} × {{ options.seatPrice | currency : "$" }} = + {{ seatTotal | currency : "$" }} / {{ options.interval | i18n }} + + + + {{ "limitSubscription" | i18n }} + + + {{ "limitSmSubscriptionDesc" | i18n }} + + + + {{ "maxSeatLimit" | i18n }} + + + {{ "maxSeatCost" | i18n }}: + {{ formGroup.value.maxAutoscaleSeats || 0 }} × + {{ options.seatPrice | currency : "$" }} = {{ maxSeatTotal | currency : "$" }} / + {{ options.interval | i18n }} + + + + {{ "additionalServiceAccounts" | i18n }} + + +
+ {{ + "additionalServiceAccountsDesc" + | i18n : options.baseServiceAccountCount : (monthlyServiceAccountPrice | currency : "$") + }} +
+
+ {{ "total" | i18n }}: + {{ formGroup.value.additionalServiceAccounts || 0 }} × + {{ options.additionalServiceAccountPrice | currency : "$" }} = + {{ serviceAccountTotal | currency : "$" }} / {{ options.interval | i18n }} +
+
+
+ + {{ "limitServiceAccounts" | i18n }} + + + {{ "limitServiceAccountsDesc" | i18n }} + + + + {{ "serviceAccountLimit" | i18n }} + + + {{ "maxServiceAccountCost" | i18n }}: + {{ formGroup.value.maxAutoscaleServiceAccounts || 0 }} × + {{ options.additionalServiceAccountPrice | currency : "$" }} = + {{ maxServiceAccountTotal | currency : "$" }} / {{ options.interval | i18n }} + + + + +
diff --git a/apps/web/src/app/billing/organizations/secrets-manager/sm-adjust-subscription.component.ts b/apps/web/src/app/billing/organizations/secrets-manager/sm-adjust-subscription.component.ts new file mode 100644 index 00000000000..dd09c15f6f7 --- /dev/null +++ b/apps/web/src/app/billing/organizations/secrets-manager/sm-adjust-subscription.component.ts @@ -0,0 +1,172 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { Subject, takeUntil } from "rxjs"; + +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationSmSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/organization-sm-subscription-update.request"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +export interface SecretsManagerSubscriptionOptions { + interval: "year" | "month"; + + /** + * The current number of seats the organization subscribes to. + */ + seatCount: number; + + /** + * Optional auto-scaling limit for the number of seats the organization can subscribe to. + */ + maxAutoscaleSeats: number; + + /** + * The price per seat for the subscription. + */ + seatPrice: number; + + /** + * The number of service accounts that are included in the base subscription. + */ + baseServiceAccountCount: number; + + /** + * The current number of additional service accounts the organization subscribes to. + */ + additionalServiceAccounts: number; + + /** + * Optional auto-scaling limit for the number of additional service accounts the organization can subscribe to. + */ + maxAutoscaleServiceAccounts: number; + + /** + * The price per additional service account for the subscription. + */ + additionalServiceAccountPrice: number; +} + +@Component({ + selector: "app-sm-adjust-subscription", + templateUrl: "sm-adjust-subscription.component.html", +}) +export class SecretsManagerAdjustSubscriptionComponent implements OnInit, OnDestroy { + @Input() organizationId: string; + @Input() options: SecretsManagerSubscriptionOptions; + @Output() onAdjusted = new EventEmitter(); + + private destroy$ = new Subject(); + + formGroup = this.formBuilder.group({ + seatCount: [0, [Validators.required, Validators.min(1)]], + limitSeats: [false], + maxAutoscaleSeats: [null as number | null], + additionalServiceAccounts: [0, [Validators.required, Validators.min(0)]], + limitServiceAccounts: [false], + maxAutoscaleServiceAccounts: [null as number | null], + }); + + get monthlyServiceAccountPrice(): number { + return this.options.interval == "month" + ? this.options.additionalServiceAccountPrice + : this.options.additionalServiceAccountPrice / 12; + } + + get serviceAccountTotal(): number { + return Math.abs( + this.formGroup.value.additionalServiceAccounts * this.options.additionalServiceAccountPrice + ); + } + + get seatTotal(): number { + return Math.abs(this.formGroup.value.seatCount * this.options.seatPrice); + } + + get maxServiceAccountTotal(): number { + return Math.abs( + (this.formGroup.value.maxAutoscaleServiceAccounts ?? 0) * + this.options.additionalServiceAccountPrice + ); + } + + get maxSeatTotal(): number { + return Math.abs((this.formGroup.value.maxAutoscaleSeats ?? 0) * this.options.seatPrice); + } + + constructor( + private formBuilder: FormBuilder, + private organizationApiService: OrganizationApiServiceAbstraction, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService + ) {} + + ngOnInit() { + this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { + const maxAutoscaleSeatsControl = this.formGroup.controls.maxAutoscaleSeats; + const maxAutoscaleServiceAccountsControl = + this.formGroup.controls.maxAutoscaleServiceAccounts; + + if (value.limitSeats) { + maxAutoscaleSeatsControl.setValidators([Validators.min(value.seatCount)]); + maxAutoscaleSeatsControl.enable({ emitEvent: false }); + } else { + maxAutoscaleSeatsControl.disable({ emitEvent: false }); + } + + if (value.limitServiceAccounts) { + maxAutoscaleServiceAccountsControl.setValidators([ + Validators.min(value.additionalServiceAccounts), + ]); + maxAutoscaleServiceAccountsControl.enable({ emitEvent: false }); + } else { + maxAutoscaleServiceAccountsControl.disable({ emitEvent: false }); + } + }); + + this.formGroup.patchValue({ + seatCount: this.options.seatCount, + maxAutoscaleSeats: this.options.maxAutoscaleSeats, + additionalServiceAccounts: this.options.additionalServiceAccounts, + maxAutoscaleServiceAccounts: this.options.maxAutoscaleServiceAccounts, + limitSeats: this.options.maxAutoscaleSeats != null, + limitServiceAccounts: this.options.maxAutoscaleServiceAccounts != null, + }); + } + + submit = async () => { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + return; + } + + const request = new OrganizationSmSubscriptionUpdateRequest(); + request.seatAdjustment = this.formGroup.value.seatCount - this.options.seatCount; + request.serviceAccountAdjustment = + this.formGroup.value.additionalServiceAccounts - this.options.additionalServiceAccounts; + request.maxAutoscaleSeats = this.formGroup.value.limitSeats + ? this.formGroup.value.maxAutoscaleSeats + : null; + request.maxAutoscaleServiceAccounts = this.formGroup.value.limitServiceAccounts + ? this.formGroup.value.maxAutoscaleServiceAccounts + : null; + + await this.organizationApiService.updateSecretsManagerSubscription( + this.organizationId, + request + ); + + await this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("subscriptionUpdated") + ); + + this.onAdjusted.emit(); + }; + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/apps/web/src/app/billing/organizations/secrets-manager/sm-billing.module.ts b/apps/web/src/app/billing/organizations/secrets-manager/sm-billing.module.ts new file mode 100644 index 00000000000..127a6e49fe6 --- /dev/null +++ b/apps/web/src/app/billing/organizations/secrets-manager/sm-billing.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from "@angular/core"; + +import { SharedModule } from "../../../shared"; + +import { SecretsManagerAdjustSubscriptionComponent } from "./sm-adjust-subscription.component"; +import { SecretsManagerSubscribeStandaloneComponent } from "./sm-subscribe-standalone.component"; +import { SecretsManagerSubscribeComponent } from "./sm-subscribe.component"; + +@NgModule({ + imports: [SharedModule], + declarations: [ + SecretsManagerSubscribeComponent, + SecretsManagerSubscribeStandaloneComponent, + SecretsManagerAdjustSubscriptionComponent, + ], + exports: [ + SecretsManagerSubscribeComponent, + SecretsManagerSubscribeStandaloneComponent, + SecretsManagerAdjustSubscriptionComponent, + ], +}) +export class SecretsManagerBillingModule {} diff --git a/apps/web/src/app/billing/organizations/secrets-manager/sm-subscribe-standalone.component.html b/apps/web/src/app/billing/organizations/secrets-manager/sm-subscribe-standalone.component.html new file mode 100644 index 00000000000..84c74ee4282 --- /dev/null +++ b/apps/web/src/app/billing/organizations/secrets-manager/sm-subscribe-standalone.component.html @@ -0,0 +1,8 @@ +
+ +
diff --git a/apps/web/src/app/billing/organizations/secrets-manager/sm-subscribe-standalone.component.ts b/apps/web/src/app/billing/organizations/secrets-manager/sm-subscribe-standalone.component.ts new file mode 100644 index 00000000000..473452c1e13 --- /dev/null +++ b/apps/web/src/app/billing/organizations/secrets-manager/sm-subscribe-standalone.component.ts @@ -0,0 +1,46 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; + +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { SecretsManagerSubscribeRequest } from "@bitwarden/common/billing/models/request/sm-subscribe.request"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { secretsManagerSubscribeFormFactory } from "./sm-subscribe.component"; + +@Component({ + selector: "sm-subscribe-standalone", + templateUrl: "sm-subscribe-standalone.component.html", +}) +export class SecretsManagerSubscribeStandaloneComponent { + @Input() plan: PlanResponse; + @Input() organization: Organization; + @Output() onSubscribe = new EventEmitter(); + + formGroup = secretsManagerSubscribeFormFactory(this.formBuilder); + + constructor( + private formBuilder: FormBuilder, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private organizationApiService: OrganizationApiServiceAbstraction + ) {} + + submit = async () => { + const request = new SecretsManagerSubscribeRequest(); + request.additionalSmSeats = this.plan.hasAdditionalSeatsOption + ? this.formGroup.value.userSeats + : 0; + request.additionalServiceAccounts = this.plan.hasAdditionalServiceAccountOption + ? this.formGroup.value.additionalServiceAccounts + : 0; + + await this.organizationApiService.subscribeToSecretsManager(this.organization.id, request); + + this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated")); + + this.onSubscribe.emit(); + }; +} diff --git a/apps/web/src/app/billing/organizations/secrets-manager/sm-subscribe.component.html b/apps/web/src/app/billing/organizations/secrets-manager/sm-subscribe.component.html new file mode 100644 index 00000000000..d00f7bbe10e --- /dev/null +++ b/apps/web/src/app/billing/organizations/secrets-manager/sm-subscribe.component.html @@ -0,0 +1,71 @@ +
+

{{ "moreFromBitwarden" | i18n }}

+
+
+ +
+
+
+

{{ "secretsManagerForPlan" | i18n : planName }}

+
+ {{ "secretsManagerForPlanDesc" | i18n }} +
    +
  • {{ "limitedUsers" | i18n : maxUsers }}
  • +
  • {{ "unlimitedSecrets" | i18n }}
  • +
  • + {{ "projectsIncluded" | i18n : maxProjects }} +
  • + +
  • {{ "unlimitedProjects" | i18n }}
  • +
    +
  • {{ "serviceAccountsIncluded" | i18n : serviceAccountsIncluded }}
  • +
  • + {{ + "additionalServiceAccountCost" | i18n : (monthlyCostPerServiceAccount | currency : "$") + }} +
  • +
+
+ +
+ + {{ "costPerUser" | i18n : (monthlyCostPerUser | currency : "$") }} /{{ "month" | i18n }} + + + {{ "freeForever" | i18n }} + +
+ + + + {{ "addSecretsManager" | i18n }} + {{ "addSecretsManagerUpgradeDesc" | i18n }} + + + +
+ + {{ "userSeats" | i18n }} + + {{ "userSeatsHowManyDesc" | i18n }} + +
+
+ + {{ "additionalServiceAccounts" | i18n }} + + {{ + "additionalServiceAccountsDesc" + | i18n : serviceAccountsIncluded : (monthlyCostPerServiceAccount | currency : "$") + }} + +
+ + +
+
+
diff --git a/apps/web/src/app/billing/organizations/secrets-manager/sm-subscribe.component.ts b/apps/web/src/app/billing/organizations/secrets-manager/sm-subscribe.component.ts new file mode 100644 index 00000000000..ef6b73584aa --- /dev/null +++ b/apps/web/src/app/billing/organizations/secrets-manager/sm-subscribe.component.ts @@ -0,0 +1,104 @@ +import { Component, Input, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder, FormGroup, Validators } from "@angular/forms"; +import { Subject, startWith, takeUntil } from "rxjs"; + +import { ControlsOf } from "@bitwarden/angular/types/controls-of"; +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 { SecretsManagerLogo } from "../../../../../../../bitwarden_license/bit-web/src/app/secrets-manager/layout/secrets-manager-logo"; + +export interface SecretsManagerSubscription { + enabled: boolean; + userSeats: number; + additionalServiceAccounts: number; +} + +export const secretsManagerSubscribeFormFactory = ( + formBuilder: FormBuilder +): FormGroup> => + formBuilder.group({ + enabled: [false], + userSeats: [1, [Validators.required, Validators.min(1), Validators.max(100000)]], + additionalServiceAccounts: [ + 0, + [Validators.required, Validators.min(0), Validators.max(100000)], + ], + }); + +@Component({ + selector: "sm-subscribe", + templateUrl: "sm-subscribe.component.html", +}) +export class SecretsManagerSubscribeComponent implements OnInit, OnDestroy { + @Input() formGroup: FormGroup>; + @Input() upgradeOrganization: boolean; + @Input() showSubmitButton = false; + @Input() selectedPlan: PlanResponse; + + logo = SecretsManagerLogo; + productTypes = ProductType; + + private destroy$ = new Subject(); + + constructor(private i18nService: I18nService) {} + + ngOnInit() { + this.formGroup.controls.enabled.valueChanges + .pipe(startWith(this.formGroup.value.enabled), takeUntil(this.destroy$)) + .subscribe((enabled) => { + if (enabled) { + this.formGroup.controls.userSeats.enable(); + this.formGroup.controls.additionalServiceAccounts.enable(); + } else { + this.formGroup.controls.userSeats.disable(); + this.formGroup.controls.additionalServiceAccounts.disable(); + } + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + get product() { + return this.selectedPlan.product; + } + + get planName() { + switch (this.product) { + case ProductType.Free: + return this.i18nService.t("free2PersonOrganization"); + case ProductType.Teams: + return this.i18nService.t("planNameTeams"); + case ProductType.Enterprise: + return this.i18nService.t("planNameEnterprise"); + } + } + + get serviceAccountsIncluded() { + return this.selectedPlan.baseServiceAccount; + } + + get monthlyCostPerServiceAccount() { + return this.selectedPlan.isAnnual + ? this.selectedPlan.additionalPricePerServiceAccount / 12 + : this.selectedPlan.additionalPricePerServiceAccount; + } + + get maxUsers() { + return this.selectedPlan.maxUsers; + } + + get maxProjects() { + return this.selectedPlan.maxProjects; + } + + get monthlyCostPerUser() { + return this.selectedPlan.isAnnual + ? this.selectedPlan.seatPrice / 12 + : this.selectedPlan.seatPrice; + } +} diff --git a/apps/web/src/app/billing/settings/organization-plans.component.html b/apps/web/src/app/billing/settings/organization-plans.component.html index a7698f1bf2c..dea4e21c9d3 100644 --- a/apps/web/src/app/billing/settings/organization-plans.component.html +++ b/apps/web/src/app/billing/settings/organization-plans.component.html @@ -28,7 +28,7 @@ (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate - *ngIf="!loading && !selfHosted && this.plans" + *ngIf="!loading && !selfHosted && this.passwordManagerPlans && this.secretsManagerPlans" class="tw-pt-6" > {{ "freeForever" | i18n }}
-
+

{{ "users" | i18n }}

@@ -230,7 +230,8 @@ {{ "users" | i18n }}: {{ formGroup.controls["additionalSeats"].value || 0 }} × {{ selectablePlan.seatPrice / 12 | currency : "$" }} × 12 - {{ "monthAbbr" | i18n }} = {{ seatTotal(selectablePlan) | currency : "$" }} /{{ + {{ "monthAbbr" | i18n }} = + {{ seatTotal(selectablePlan, formGroup.value.additionalSeats) | currency : "$" }} /{{ "year" | i18n }} @@ -256,7 +257,9 @@ {{ "users" | i18n }}: {{ formGroup.controls["additionalSeats"].value || 0 }} × {{ selectablePlan.seatPrice | currency : "$" }} {{ "monthAbbr" | i18n }} = - {{ seatTotal(selectablePlan) | currency : "$" }} /{{ "month" | i18n }} + {{ seatTotal(selectablePlan, formGroup.value.additionalSeats) | currency : "$" }} /{{ + "month" | i18n + }} {{ "additionalStorageGb" | i18n }}: @@ -268,8 +271,21 @@
-
-

+

+ + +
+ +
+ + +
+

{{ (createOrganization ? "paymentInformation" : "billingInformation") | i18n }}

@@ -279,8 +295,12 @@
- {{ "planPrice" | i18n }}: {{ subtotal | currency : "USD $" }} + {{ "passwordManagerPlanPrice" | i18n }}: {{ passwordManagerSubtotal | currency : "USD $" }}
+ + {{ "secretsManagerPlanPrice" | i18n }}: {{ secretsManagerSubtotal | currency : "USD $" }} +
+
{{ "estimatedTax" | i18n }}: {{ taxCharges | currency : "USD $" }} diff --git a/apps/web/src/app/billing/settings/organization-plans.component.ts b/apps/web/src/app/billing/settings/organization-plans.component.ts index 363f7bc46bc..7d9f76708ea 100644 --- a/apps/web/src/app/billing/settings/organization-plans.component.ts +++ b/apps/web/src/app/billing/settings/organization-plans.component.ts @@ -7,7 +7,7 @@ import { Output, ViewChild, } from "@angular/core"; -import { UntypedFormBuilder, Validators } from "@angular/forms"; +import { FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; import { Subject, takeUntil } from "rxjs"; @@ -20,9 +20,11 @@ import { OrganizationCreateRequest } from "@bitwarden/common/admin-console/model import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request"; import { ProviderOrganizationCreateRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-organization-create.request"; -import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; +import { BitwardenProductType, PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { ProductType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -32,6 +34,8 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { secretsManagerSubscribeFormFactory } from "../organizations/secrets-manager/sm-subscribe.component"; + import { PaymentComponent } from "./payment.component"; import { TaxInfoComponent } from "./tax-info.component"; @@ -51,24 +55,29 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { @Input() showFree = true; @Input() showCancel = false; @Input() acceptingSponsorship = false; + @Input() get product(): ProductType { return this._product; } + set product(product: ProductType) { this._product = product; this.formGroup?.controls?.product?.setValue(product); } + private _product = ProductType.Free; @Input() get plan(): PlanType { return this._plan; } + set plan(plan: PlanType) { this._plan = plan; this.formGroup?.controls?.plan?.setValue(plan); } + private _plan = PlanType.Free; @Input() providerId?: string; @Output() onSuccess = new EventEmitter(); @@ -82,6 +91,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { singleOrgPolicyAppliesToActiveUser = false; isInTrialFlow = false; discount = 0; + showSecretsManagerSubscribe: boolean; + + secretsManagerSubscription = secretsManagerSubscribeFormFactory(this.formBuilder); formGroup = this.formBuilder.group({ name: [""], @@ -94,9 +106,11 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { businessName: [""], plan: [this.plan], product: [this.product], + secretsManager: this.secretsManagerSubscription, }); - plans: PlanResponse[]; + passwordManagerPlans: PlanResponse[]; + secretsManagerPlans: PlanResponse[]; private destroy$ = new Subject(); @@ -111,8 +125,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { private organizationService: OrganizationService, private logService: LogService, private messagingService: MessagingService, - private formBuilder: UntypedFormBuilder, - private organizationApiService: OrganizationApiServiceAbstraction + private formBuilder: FormBuilder, + private organizationApiService: OrganizationApiServiceAbstraction, + private configService: ConfigServiceAbstraction ) { this.selfHosted = platformUtilsService.isSelfHost(); } @@ -120,7 +135,13 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { async ngOnInit() { if (!this.selfHosted) { const plans = await this.apiService.getPlans(); - this.plans = plans.data; + this.passwordManagerPlans = plans.data.filter( + (plan) => plan.bitwardenProduct === BitwardenProductType.PasswordManager + ); + this.secretsManagerPlans = plans.data.filter( + (plan) => plan.bitwardenProduct === BitwardenProductType.SecretsManager + ); + if (this.product === ProductType.Enterprise || this.product === ProductType.Teams) { this.formGroup.controls.businessOwned.setValue(true); } @@ -131,12 +152,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.changedOwnedBusiness(); } - if (!this.createOrganization || this.acceptingSponsorship) { - this.formGroup.controls.product.setValue(ProductType.Families); - this.changedProduct(); - } - - if (this.createOrganization) { + if (!this.createOrganization) { + this.upgradeFlowPrefillForm(); + } else { this.formGroup.controls.name.addValidators([Validators.required, Validators.maxLength(50)]); this.formGroup.controls.billingEmail.addValidators(Validators.required); } @@ -148,6 +166,11 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.singleOrgPolicyAppliesToActiveUser = policyAppliesToActiveUser; }); + this.showSecretsManagerSubscribe = await this.configService.getFeatureFlagBool( + FeatureFlag.SecretsManagerBilling, + false + ); + this.loading = false; } @@ -165,7 +188,15 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } get selectedPlan() { - return this.plans.find((plan) => plan.type === this.formGroup.controls.plan.value); + return this.passwordManagerPlans.find( + (plan) => plan.type === this.formGroup.controls.plan.value + ); + } + + get selectedSecretsManagerPlan() { + return this.secretsManagerPlans.find( + (plan) => plan.type === this.formGroup.controls.plan.value + ); } get selectedPlanInterval() { @@ -173,7 +204,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } get selectableProducts() { - let validPlans = this.plans.filter((plan) => plan.type !== PlanType.Custom); + let validPlans = this.passwordManagerPlans.filter((plan) => plan.type !== PlanType.Custom); if (this.formGroup.controls.businessOwned.value) { validPlans = validPlans.filter((plan) => plan.canBeUsedByBusiness); @@ -191,7 +222,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { ); if (this.acceptingSponsorship) { - const familyPlan = this.plans.find((plan) => plan.type === PlanType.FamiliesAnnually); + const familyPlan = this.passwordManagerPlans.find( + (plan) => plan.type === PlanType.FamiliesAnnually + ); this.discount = familyPlan.basePrice; validPlans = [familyPlan]; } @@ -200,7 +233,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } get selectablePlans() { - return this.plans?.filter( + return this.passwordManagerPlans?.filter( (plan) => !plan.legacyYear && !plan.disabled && plan.product === this.formGroup.controls.product.value ); @@ -231,21 +264,32 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { ); } - seatTotal(plan: PlanResponse): number { + seatTotal(plan: PlanResponse, seats: number): number { if (!plan.hasAdditionalSeatsOption) { return 0; } - return plan.seatPrice * Math.abs(this.formGroup.controls.additionalSeats.value || 0); + return plan.seatPrice * Math.abs(seats || 0); } - get subtotal() { + additionalServiceAccountTotal(plan: PlanResponse): number { + if (!plan.hasAdditionalServiceAccountOption) { + return 0; + } + + return ( + plan.additionalPricePerServiceAccount * + Math.abs(this.secretsManagerForm.value.additionalServiceAccounts || 0) + ); + } + + get passwordManagerSubtotal() { let subTotal = this.selectedPlan.basePrice; if ( this.selectedPlan.hasAdditionalSeatsOption && this.formGroup.controls.additionalSeats.value ) { - subTotal += this.seatTotal(this.selectedPlan); + subTotal += this.seatTotal(this.selectedPlan, this.formGroup.value.additionalSeats); } if ( this.selectedPlan.hasAdditionalStorageOption && @@ -262,18 +306,39 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { return subTotal - this.discount; } + get secretsManagerSubtotal() { + const plan = this.selectedSecretsManagerPlan; + const formValues = this.secretsManagerForm.value; + + if (!this.planOffersSecretsManager || !formValues.enabled) { + return 0; + } + + let subTotal = plan.basePrice; + if (plan.hasAdditionalSeatsOption && formValues.userSeats) { + subTotal += this.seatTotal(plan, formValues.userSeats); + } + + if (plan.hasAdditionalStorageOption && formValues.additionalServiceAccounts) { + subTotal += this.additionalServiceAccountTotal(this.selectedPlan); + } + + return subTotal; + } + get freeTrial() { return this.selectedPlan.trialPeriodDays != null; } get taxCharges() { return this.taxComponent != null && this.taxComponent.taxRate != null - ? (this.taxComponent.taxRate / 100) * this.subtotal + ? (this.taxComponent.taxRate / 100) * + (this.passwordManagerSubtotal + this.secretsManagerSubtotal) : 0; } get total() { - return this.subtotal + this.taxCharges || 0; + return this.passwordManagerSubtotal + this.secretsManagerSubtotal + this.taxCharges || 0; } get paymentDesc() { @@ -286,6 +351,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } } + get secretsManagerForm() { + return this.formGroup.controls.secretsManager; + } + + get planOffersSecretsManager() { + return this.selectedSecretsManagerPlan != null; + } + changedProduct() { this.formGroup.controls.plan.setValue(this.selectablePlans[0].type); if (!this.selectedPlan.hasPremiumAccessOption) { @@ -303,6 +376,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { ) { this.formGroup.controls.additionalSeats.setValue(1); } + + if (this.planOffersSecretsManager) { + this.secretsManagerForm.enable(); + } else { + this.secretsManagerForm.disable(); + } + + this.secretsManagerForm.updateValueAndValidity(); } changedOwnedBusiness() { @@ -407,6 +488,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { request.billingAddressCountry = this.taxComponent.taxInfo.country; request.billingAddressPostalCode = this.taxComponent.taxInfo.postalCode; + // Secrets Manager + this.buildSecretsManagerRequest(request); + // Retrieve org info to backfill pub/priv key if necessary const org = await this.organizationService.get(this.organizationId); if (!org.hasPublicAndPrivateKeys) { @@ -462,6 +546,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } } + // Secrets Manager + this.buildSecretsManagerRequest(request); + if (this.providerId) { const providerRequest = new ProviderOrganizationCreateRequest( this.formGroup.controls.clientOwnerEmail.value, @@ -517,4 +604,40 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { return text; } + + private buildSecretsManagerRequest( + request: OrganizationCreateRequest | OrganizationUpgradeRequest + ): void { + const formValues = this.secretsManagerForm.value; + + request.useSecretsManager = this.planOffersSecretsManager && formValues.enabled; + + if (!request.useSecretsManager) { + return; + } + + if (this.selectedSecretsManagerPlan.hasAdditionalSeatsOption) { + request.additionalSmSeats = formValues.userSeats; + } + + if (this.selectedSecretsManagerPlan.hasAdditionalServiceAccountOption) { + request.additionalServiceAccounts = formValues.additionalServiceAccounts; + } + } + + private upgradeFlowPrefillForm() { + if (this.acceptingSponsorship) { + this.formGroup.controls.product.setValue(ProductType.Families); + this.changedProduct(); + return; + } + + // If they already have SM enabled, bump them up to Teams and enable SM to maintain this access + const organization = this.organizationService.get(this.organizationId); + if (organization.useSecretsManager) { + this.formGroup.controls.product.setValue(ProductType.Teams); + this.secretsManagerForm.controls.enabled.setValue(true); + this.changedProduct(); + } + } } diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index afbc047a321..38a4b40c6e3 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -51,6 +51,7 @@ import { UpdatePasswordComponent } from "../auth/update-password.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component"; import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.component"; +import { SecretsManagerBillingModule } from "../billing/organizations/secrets-manager/sm-billing.module"; import { AddCreditComponent } from "../billing/settings/add-credit.component"; import { AdjustPaymentComponent } from "../billing/settings/adjust-payment.component"; import { BillingHistoryViewComponent } from "../billing/settings/billing-history-view.component"; @@ -125,6 +126,9 @@ import { SharedModule } from "./shared.module"; DynamicAvatarComponent, EnvironmentSelectorModule, AccountFingerprintComponent, + + // To be removed when OrganizationPlansComponent is moved to its own module (see AC-1453) + SecretsManagerBillingModule, ], declarations: [ AcceptEmergencyComponent, diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index ace5160a53b..350ecce8dad 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -23,6 +23,7 @@ import { LinkModule, MenuModule, MultiSelectModule, + ProgressModule, RadioButtonModule, SelectModule, TableModule, @@ -69,6 +70,7 @@ import "./locales"; LinkModule, MenuModule, MultiSelectModule, + ProgressModule, RadioButtonModule, TableModule, TabsModule, @@ -103,6 +105,7 @@ import "./locales"; LinkModule, MenuModule, MultiSelectModule, + ProgressModule, RadioButtonModule, SelectModule, TableModule, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3d6e9f6018f..a8e08399e69 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3323,6 +3323,9 @@ "limitSubscriptionDesc": { "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." + }, "maxSeatLimit": { "message": "Seat Limit (optional)", "description": "Upper limit of seats to allow through autoscaling" @@ -6617,12 +6620,12 @@ "changeKdfLoggedOutWarning": { "message": "Proceeding will log you out of all active sessions. You will need to log back in and complete two-step login setup. We recommend exporting your vault before changing your encryption settings to prevent data loss." }, - "secretsManagerBeta": { - "message": "Secrets Manager Beta" - }, "secretsManager": { "message": "Secrets Manager" }, + "secretsManagerBeta": { + "message": "Secrets Manager Beta" + }, "secretsManagerAccessDescription": { "message": "Activate user access to Secrets Manager." }, @@ -6955,5 +6958,99 @@ }, "selectedRegionFlag": { "message": "Selected region flag" + }, + "secretsManagerForPlan": { + "message": "Secrets Manager for $PLAN$", + "placeholders": { + "plan": { + "content": "$1", + "example": "Teams" + } + } + }, + "secretsManagerForPlanDesc": { + "message": "For engineering and DevOps teams to manage secrets throughout the software development lifecycle." + }, + "free2PersonOrganization": { + "message": "Free 2-person Organizations" + }, + "unlimitedSecrets": { + "message": "Unlimited secrets" + }, + "unlimitedProjects": { + "message": "Unlimited projects" + }, + "projectsIncluded": { + "message": "$COUNT$ projects included", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "serviceAccountsIncluded": { + "message": "$COUNT$ service accounts included", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "additionalServiceAccountCost": { + "message": "$COST$ per month for additional service accounts", + "placeholders": { + "cost": { + "content": "$1", + "example": "$0.50" + } + } + }, + "addSecretsManager": { + "message": "Add Secrets Manager" + }, + "addSecretsManagerUpgradeDesc": { + "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." + }, + "additionalServiceAccounts": { + "message": "Additional service accounts" + }, + "additionalServiceAccountsDesc": { + "message": "Your plan comes with $COUNT$ service accounts. You can add additional service accounts for $COST$ per month.", + "placeholders": { + "count": { + "content": "$1", + "example": "50" + }, + "cost": { + "content": "$2", + "example": "$0.50" + } + } + }, + "passwordManagerPlanPrice": { + "message": "Password Manager plan price" + }, + "secretsManagerPlanPrice": { + "message": "Secrets Manager plan price" + }, + "passwordManager": { + "message": "Password Manager" + }, + "freeOrganization": { + "message": "Free Organization" + }, + "limitServiceAccounts": { + "message": "Limit service accounts (optional)" + }, + "limitServiceAccountsDesc": { + "message": "Set a limit for your service accounts. Once this limit is reached, you will not be able to create new service accounts." + }, + "serviceAccountLimit": { + "message": "Service account limit (optional)" + }, + "maxServiceAccountCost": { + "message": "Max potential service account cost" } } diff --git a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts index 93f4de2ffdf..b6778cf61e6 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts @@ -3,9 +3,11 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; +import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { OrganizationTaxInfoUpdateRequest } from "../../../billing/models/request/organization-tax-info-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; +import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request"; import { BillingResponse } from "../../../billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response"; import { PaymentResponse } from "../../../billing/models/response/payment.response"; @@ -16,7 +18,6 @@ import { StorageRequest } from "../../../models/request/storage.request"; import { VerifyBankRequest } from "../../../models/request/verify-bank.request"; import { ListResponse } from "../../../models/response/list.response"; import { OrganizationApiKeyType } from "../../enums"; -import { OrganizationEnrollSecretsManagerRequest } from "../../models/request/organization/organization-enroll-secrets-manager.request"; import { OrganizationCreateRequest } from "../../models/request/organization-create.request"; import { OrganizationKeysRequest } from "../../models/request/organization-keys.request"; import { OrganizationUpdateRequest } from "../../models/request/organization-update.request"; @@ -37,7 +38,14 @@ export class OrganizationApiServiceAbstraction { save: (id: string, request: OrganizationUpdateRequest) => Promise; updatePayment: (id: string, request: PaymentRequest) => Promise; upgrade: (id: string, request: OrganizationUpgradeRequest) => Promise; - updateSubscription: (id: string, request: OrganizationSubscriptionUpdateRequest) => Promise; + updatePasswordManagerSeats: ( + id: string, + request: OrganizationSubscriptionUpdateRequest + ) => Promise; + updateSecretsManagerSubscription: ( + id: string, + request: OrganizationSmSubscriptionUpdateRequest + ) => Promise; updateSeats: (id: string, request: SeatRequest) => Promise; updateStorage: (id: string, request: StorageRequest) => Promise; verifyBank: (id: string, request: VerifyBankRequest) => Promise; @@ -60,8 +68,5 @@ export class OrganizationApiServiceAbstraction { getSso: (id: string) => Promise; updateSso: (id: string, request: OrganizationSsoRequest) => Promise; selfHostedSyncLicense: (id: string) => Promise; - updateEnrollSecretsManager: ( - id: string, - request: OrganizationEnrollSecretsManagerRequest - ) => Promise; + subscribeToSecretsManager: (id: string, request: SecretsManagerSubscribeRequest) => Promise; } diff --git a/libs/common/src/admin-console/models/data/organization.data.ts b/libs/common/src/admin-console/models/data/organization.data.ts index 36ebb0f3df8..10f5e9f2625 100644 --- a/libs/common/src/admin-console/models/data/organization.data.ts +++ b/libs/common/src/admin-console/models/data/organization.data.ts @@ -22,6 +22,7 @@ export class OrganizationData { useCustomPermissions: boolean; useResetPassword: boolean; useSecretsManager: boolean; + usePasswordManager: boolean; useActivateAutofillPolicy: boolean; selfHost: boolean; usersGetPremium: boolean; @@ -74,6 +75,7 @@ export class OrganizationData { this.useCustomPermissions = response.useCustomPermissions; this.useResetPassword = response.useResetPassword; this.useSecretsManager = response.useSecretsManager; + this.usePasswordManager = response.usePasswordManager; this.useActivateAutofillPolicy = response.useActivateAutofillPolicy; this.selfHost = response.selfHost; this.usersGetPremium = response.usersGetPremium; diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index 11e0ccc0349..89751eb3e8d 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -31,6 +31,7 @@ export class Organization { useCustomPermissions: boolean; useResetPassword: boolean; useSecretsManager: boolean; + usePasswordManager: boolean; useActivateAutofillPolicy: boolean; selfHost: boolean; usersGetPremium: boolean; @@ -87,6 +88,7 @@ export class Organization { this.useCustomPermissions = obj.useCustomPermissions; this.useResetPassword = obj.useResetPassword; this.useSecretsManager = obj.useSecretsManager; + this.usePasswordManager = obj.usePasswordManager; this.useActivateAutofillPolicy = obj.useActivateAutofillPolicy; this.selfHost = obj.selfHost; this.usersGetPremium = obj.usersGetPremium; 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 616c37c00ca..729cf453653 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 @@ -23,4 +23,8 @@ export class OrganizationCreateRequest { billingAddressState: string; billingAddressPostalCode: string; billingAddressCountry: string; + + useSecretsManager: boolean; + additionalSmSeats: number; + additionalServiceAccounts: number; } diff --git a/libs/common/src/admin-console/models/request/organization-upgrade.request.ts b/libs/common/src/admin-console/models/request/organization-upgrade.request.ts index bf0eb5f47f8..eba897f31b6 100644 --- a/libs/common/src/admin-console/models/request/organization-upgrade.request.ts +++ b/libs/common/src/admin-console/models/request/organization-upgrade.request.ts @@ -11,4 +11,8 @@ export class OrganizationUpgradeRequest { billingAddressCountry: string; billingAddressPostalCode: string; keys: OrganizationKeysRequest; + + useSecretsManager: boolean; + additionalSmSeats: number; + additionalServiceAccounts: number; } diff --git a/libs/common/src/admin-console/models/request/organization/organization-enroll-secrets-manager.request.ts b/libs/common/src/admin-console/models/request/organization/organization-enroll-secrets-manager.request.ts deleted file mode 100644 index a213b07bba7..00000000000 --- a/libs/common/src/admin-console/models/request/organization/organization-enroll-secrets-manager.request.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class OrganizationEnrollSecretsManagerRequest { - enabled: boolean; -} diff --git a/libs/common/src/admin-console/models/response/organization.response.ts b/libs/common/src/admin-console/models/response/organization.response.ts index 2c056ee2875..b248c6d0df9 100644 --- a/libs/common/src/admin-console/models/response/organization.response.ts +++ b/libs/common/src/admin-console/models/response/organization.response.ts @@ -13,6 +13,7 @@ export class OrganizationResponse extends BaseResponse { businessTaxNumber: string; billingEmail: string; plan: PlanResponse; + secretsManagerPlan: PlanResponse; planType: PlanType; seats: number; maxAutoscaleSeats: number; @@ -27,6 +28,11 @@ export class OrganizationResponse extends BaseResponse { useResetPassword: boolean; useSecretsManager: boolean; hasPublicAndPrivateKeys: boolean; + usePasswordManager: boolean; + smSeats?: number; + smServiceAccounts?: number; + maxAutoscaleSmSeats?: number; + maxAutoscaleSmServiceAccounts?: number; constructor(response: any) { super(response); @@ -39,8 +45,14 @@ export class OrganizationResponse extends BaseResponse { this.businessCountry = this.getResponseProperty("BusinessCountry"); this.businessTaxNumber = this.getResponseProperty("BusinessTaxNumber"); this.billingEmail = this.getResponseProperty("BillingEmail"); + const plan = this.getResponseProperty("Plan"); this.plan = plan == null ? null : new PlanResponse(plan); + + const secretsManagerPlan = this.getResponseProperty("SecretsManagerPlan"); + this.secretsManagerPlan = + secretsManagerPlan == null ? null : new PlanResponse(secretsManagerPlan); + this.planType = this.getResponseProperty("PlanType"); this.seats = this.getResponseProperty("Seats"); this.maxAutoscaleSeats = this.getResponseProperty("MaxAutoscaleSeats"); @@ -55,5 +67,10 @@ export class OrganizationResponse extends BaseResponse { this.useResetPassword = this.getResponseProperty("UseResetPassword"); this.useSecretsManager = this.getResponseProperty("UseSecretsManager"); this.hasPublicAndPrivateKeys = this.getResponseProperty("HasPublicAndPrivateKeys"); + this.usePasswordManager = this.getResponseProperty("UsePasswordManager"); + this.smSeats = this.getResponseProperty("SmSeats"); + this.smServiceAccounts = this.getResponseProperty("SmServiceAccounts"); + this.maxAutoscaleSmSeats = this.getResponseProperty("MaxAutoscaleSmSeats"); + this.maxAutoscaleSmServiceAccounts = this.getResponseProperty("MaxAutoscaleSmServiceAccounts"); } } diff --git a/libs/common/src/admin-console/models/response/profile-organization.response.ts b/libs/common/src/admin-console/models/response/profile-organization.response.ts index 18bf4d45e8a..e042bf145f8 100644 --- a/libs/common/src/admin-console/models/response/profile-organization.response.ts +++ b/libs/common/src/admin-console/models/response/profile-organization.response.ts @@ -19,6 +19,7 @@ export class ProfileOrganizationResponse extends BaseResponse { useCustomPermissions: boolean; useResetPassword: boolean; useSecretsManager: boolean; + usePasswordManager: boolean; useActivateAutofillPolicy: boolean; selfHost: boolean; usersGetPremium: boolean; @@ -65,6 +66,7 @@ export class ProfileOrganizationResponse extends BaseResponse { this.useCustomPermissions = this.getResponseProperty("UseCustomPermissions") ?? false; this.useResetPassword = this.getResponseProperty("UseResetPassword"); this.useSecretsManager = this.getResponseProperty("UseSecretsManager"); + this.usePasswordManager = this.getResponseProperty("UsePasswordManager"); this.useActivateAutofillPolicy = this.getResponseProperty("UseActivateAutofillPolicy"); this.selfHost = this.getResponseProperty("SelfHost"); this.usersGetPremium = this.getResponseProperty("UsersGetPremium"); diff --git a/libs/common/src/admin-console/services/organization/organization-api.service.ts b/libs/common/src/admin-console/services/organization/organization-api.service.ts index 3a1d3555242..c235b4b8eea 100644 --- a/libs/common/src/admin-console/services/organization/organization-api.service.ts +++ b/libs/common/src/admin-console/services/organization/organization-api.service.ts @@ -4,9 +4,11 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; +import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { OrganizationTaxInfoUpdateRequest } from "../../../billing/models/request/organization-tax-info-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; +import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request"; import { BillingResponse } from "../../../billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response"; import { PaymentResponse } from "../../../billing/models/response/payment.response"; @@ -19,7 +21,6 @@ import { ListResponse } from "../../../models/response/list.response"; import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction"; import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction"; import { OrganizationApiKeyType } from "../../enums"; -import { OrganizationEnrollSecretsManagerRequest } from "../../models/request/organization/organization-enroll-secrets-manager.request"; import { OrganizationCreateRequest } from "../../models/request/organization-create.request"; import { OrganizationKeysRequest } from "../../models/request/organization-keys.request"; import { OrganizationUpdateRequest } from "../../models/request/organization-update.request"; @@ -120,7 +121,7 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return new PaymentResponse(r); } - async updateSubscription( + async updatePasswordManagerSeats( id: string, request: OrganizationSubscriptionUpdateRequest ): Promise { @@ -133,6 +134,19 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction ); } + async updateSecretsManagerSubscription( + id: string, + request: OrganizationSmSubscriptionUpdateRequest + ): Promise { + return this.apiService.send( + "POST", + "/organizations/" + id + "/sm-subscription", + request, + true, + false + ); + } + async updateSeats(id: string, request: SeatRequest): Promise { const r = await this.apiService.send( "POST", @@ -294,13 +308,16 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction ); } - async updateEnrollSecretsManager(id: string, request: OrganizationEnrollSecretsManagerRequest) { - await this.apiService.send( + async subscribeToSecretsManager( + id: string, + request: SecretsManagerSubscribeRequest + ): Promise { + return await this.apiService.send( "POST", - "/organizations/" + id + "/enroll-secrets-manager", + "/organizations/" + id + "/subscribe-secrets-manager", request, true, - true + false ); } } diff --git a/libs/common/src/billing/enums/bitwarden-product-type.enum.ts b/libs/common/src/billing/enums/bitwarden-product-type.enum.ts new file mode 100644 index 00000000000..76b0899fd9c --- /dev/null +++ b/libs/common/src/billing/enums/bitwarden-product-type.enum.ts @@ -0,0 +1,4 @@ +export enum BitwardenProductType { + PasswordManager = 0, + SecretsManager = 1, +} diff --git a/libs/common/src/billing/enums/index.ts b/libs/common/src/billing/enums/index.ts index b4f96cd8fd6..70a3495a8bf 100644 --- a/libs/common/src/billing/enums/index.ts +++ b/libs/common/src/billing/enums/index.ts @@ -2,3 +2,4 @@ export * from "./payment-method-type.enum"; export * from "./plan-sponsorship-type.enum"; export * from "./plan-type.enum"; export * from "./transaction-type.enum"; +export * from "./bitwarden-product-type.enum"; diff --git a/libs/common/src/billing/models/request/organization-sm-subscription-update.request.ts b/libs/common/src/billing/models/request/organization-sm-subscription-update.request.ts new file mode 100644 index 00000000000..7971b1f6a91 --- /dev/null +++ b/libs/common/src/billing/models/request/organization-sm-subscription-update.request.ts @@ -0,0 +1,21 @@ +export class OrganizationSmSubscriptionUpdateRequest { + /** + * The number of seats to add or remove from the subscription. + */ + seatAdjustment: number; + + /** + * The maximum number of seats that can be auto-scaled for the subscription. + */ + maxAutoscaleSeats?: number; + + /** + * The number of additional service accounts to add or remove from the subscription. + */ + serviceAccountAdjustment: number; + + /** + * The maximum number of additional service accounts that can be auto-scaled for the subscription. + */ + maxAutoscaleServiceAccounts?: number; +} diff --git a/libs/common/src/billing/models/request/organization-subscription-update.request.ts b/libs/common/src/billing/models/request/organization-subscription-update.request.ts index 9db3d4be80e..d7566806f6b 100644 --- a/libs/common/src/billing/models/request/organization-subscription-update.request.ts +++ b/libs/common/src/billing/models/request/organization-subscription-update.request.ts @@ -1,3 +1,23 @@ export class OrganizationSubscriptionUpdateRequest { - constructor(public seatAdjustment: number, public maxAutoscaleSeats?: number) {} + /** + * The number of seats to add or remove from the subscription. + * Applies to both PM and SM request types. + */ + seatAdjustment: number; + + /** + * The maximum number of seats that can be auto-scaled for the subscription. + * Applies to both PM and SM request types. + */ + maxAutoscaleSeats?: number; + + /** + * Build a subscription update request for the Password Manager product type. + * @param seatAdjustment - The number of seats to add or remove from the subscription. + * @param maxAutoscaleSeats - The maximum number of seats that can be auto-scaled for the subscription. + */ + constructor(seatAdjustment: number, maxAutoscaleSeats?: number) { + this.seatAdjustment = seatAdjustment; + this.maxAutoscaleSeats = maxAutoscaleSeats; + } } diff --git a/libs/common/src/billing/models/request/sm-subscribe.request.ts b/libs/common/src/billing/models/request/sm-subscribe.request.ts new file mode 100644 index 00000000000..581d3007c81 --- /dev/null +++ b/libs/common/src/billing/models/request/sm-subscribe.request.ts @@ -0,0 +1,4 @@ +export class SecretsManagerSubscribeRequest { + additionalSmSeats: number; + additionalServiceAccounts: number; +} diff --git a/libs/common/src/billing/models/response/plan.response.ts b/libs/common/src/billing/models/response/plan.response.ts index 8368849b86d..bb09a9b143c 100644 --- a/libs/common/src/billing/models/response/plan.response.ts +++ b/libs/common/src/billing/models/response/plan.response.ts @@ -1,10 +1,11 @@ import { ProductType } from "../../../enums"; import { BaseResponse } from "../../../models/response/base.response"; -import { PlanType } from "../../enums"; +import { BitwardenProductType, PlanType } from "../../enums"; export class PlanResponse extends BaseResponse { type: PlanType; product: ProductType; + bitwardenProduct: BitwardenProductType; name: string; isAnnual: boolean; nameLocalizationKey: string; @@ -48,6 +49,15 @@ export class PlanResponse extends BaseResponse { additionalStoragePricePerGb: number; premiumAccessOptionPrice: number; + // SM only + additionalPricePerServiceAccount: number; + baseServiceAccount: number; + maxServiceAccount: number; + hasAdditionalServiceAccountOption: boolean; + maxProjects: number; + maxAdditionalServiceAccounts: number; + stripeServiceAccountPlanId: string; + constructor(response: any) { super(response); this.type = this.getResponseProperty("Type"); @@ -90,5 +100,18 @@ export class PlanResponse extends BaseResponse { this.seatPrice = this.getResponseProperty("SeatPrice"); this.additionalStoragePricePerGb = this.getResponseProperty("AdditionalStoragePricePerGb"); this.premiumAccessOptionPrice = this.getResponseProperty("PremiumAccessOptionPrice"); + + this.bitwardenProduct = this.getResponseProperty("BitwardenProduct"); + this.additionalPricePerServiceAccount = this.getResponseProperty( + "AdditionalPricePerServiceAccount" + ); + this.baseServiceAccount = this.getResponseProperty("BaseServiceAccount"); + this.maxServiceAccount = this.getResponseProperty("MaxServiceAccount"); + this.hasAdditionalServiceAccountOption = this.getResponseProperty( + "HasAdditionalServiceAccountOption" + ); + this.maxProjects = this.getResponseProperty("MaxProjects"); + this.maxAdditionalServiceAccounts = this.getResponseProperty("MaxAdditionalServiceAccounts"); + this.stripeServiceAccountPlanId = this.getResponseProperty("StripeServiceAccountPlanId"); } } diff --git a/libs/common/src/billing/models/response/subscription.response.ts b/libs/common/src/billing/models/response/subscription.response.ts index 8230d98417a..ffcc9c23768 100644 --- a/libs/common/src/billing/models/response/subscription.response.ts +++ b/libs/common/src/billing/models/response/subscription.response.ts @@ -1,4 +1,5 @@ import { BaseResponse } from "../../../models/response/base.response"; +import { BitwardenProductType } from "../../enums"; export class SubscriptionResponse extends BaseResponse { storageName: string; @@ -62,6 +63,8 @@ export class BillingSubscriptionItemResponse extends BaseResponse { quantity: number; interval: string; sponsoredSubscriptionItem: boolean; + addonSubscriptionItem: boolean; + bitwardenProduct: BitwardenProductType; constructor(response: any) { super(response); @@ -70,6 +73,8 @@ export class BillingSubscriptionItemResponse extends BaseResponse { this.quantity = this.getResponseProperty("Quantity"); this.interval = this.getResponseProperty("Interval"); this.sponsoredSubscriptionItem = this.getResponseProperty("SponsoredSubscriptionItem"); + this.addonSubscriptionItem = this.getResponseProperty("AddonSubscriptionItem"); + this.bitwardenProduct = this.getResponseProperty("BitwardenProduct"); } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index e8a05911b9f..fb155f54e2d 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -2,4 +2,5 @@ export enum FeatureFlag { DisplayEuEnvironmentFlag = "display-eu-environment", DisplayLowKdfIterationWarningFlag = "display-kdf-iteration-warning", TrustedDeviceEncryption = "trusted-device-encryption", + SecretsManagerBilling = "sm-ga-billing", } diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index ad7c1348896..9c098632b18 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -881,7 +881,7 @@ export class ApiService implements ApiServiceAbstraction { // Plan APIs async getPlans(): Promise> { - const r = await this.send("GET", "/plans/", null, false, true); + const r = await this.send("GET", "/plans/all", null, false, true); return new ListResponse(r, PlanResponse); }