diff --git a/apps/web/src/app/billing/organizations/icons/manage-billing.icon.ts b/apps/web/src/app/billing/organizations/icons/manage-billing.icon.ts deleted file mode 100644 index 6f583bf2e8..0000000000 --- a/apps/web/src/app/billing/organizations/icons/manage-billing.icon.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { svgIcon } from "@bitwarden/components"; - -export const ManageBilling = svgIcon` - - - - - - - - - - - - - - - - - - - - - - `; diff --git a/apps/web/src/app/billing/organizations/icons/subscription-hidden.icon.ts b/apps/web/src/app/billing/organizations/icons/subscription-hidden.icon.ts new file mode 100644 index 0000000000..82490e82a1 --- /dev/null +++ b/apps/web/src/app/billing/organizations/icons/subscription-hidden.icon.ts @@ -0,0 +1,24 @@ +import { Icon, svgIcon } from "@bitwarden/components"; + +export const SubscriptionHiddenIcon: Icon = svgIcon` + + + + + + + + + + + + + + + + + + + + +`; diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 643eeb93ba..0a83b2a56c 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -1,17 +1,12 @@ - - - - {{ "loading" | i18n }} - + + + {{ "loading" | i18n }} + - - - + + - -

- {{ "selfHostingTitle" | i18n }} -

-

- {{ "selfHostingEnterpriseOrganizationSectionCopy" | i18n }} - - -

-
- - -
+

{{ "additionalOptions" | i18n }}

@@ -302,13 +262,50 @@ - -

- - {{ - "manageBillingFromProviderPortalMessage" | i18n - }} -
-
+ + + +

{{ "manageSubscription" | i18n }}

+

+ {{ "manageSubscriptionFromThe" | i18n }} + {{ + "providerPortal" | i18n + }}. +

+ +

+ {{ "billingManagedByProvider" | i18n: userOrg.providerName }}. + {{ "billingContactProviderForAssistance" | i18n }}. +

+
+ +
+ +
+ +

{{ "billingManagedByProvider" | i18n: userOrg.providerName }}

+

{{ "billingContactProviderForAssistance" | i18n }}

+
+
+
+
+ + + +

+ {{ "selfHostingTitleProper" | i18n }} +

+

+ {{ "toHostBitwardenOnYourOwnServer" | i18n }} +

+
+ + +
+
+
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 7a66faa0a4..e604140e56 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 @@ -5,9 +5,9 @@ import { concatMap, firstValueFrom, lastValueFrom, Observable, Subject, takeUnti 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 { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { OrganizationApiKeyType, ProviderStatusType } from "@bitwarden/common/admin-console/enums"; +import { OrganizationApiKeyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { BillingSubscriptionItemResponse } from "@bitwarden/common/billing/models/response/subscription.response"; @@ -15,7 +15,6 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { @@ -34,7 +33,7 @@ import { import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; import { ChangePlanDialogResultType, openChangePlanDialog } from "./change-plan-dialog.component"; import { DownloadLicenceDialogComponent } from "./download-license.component"; -import { ManageBilling } from "./icons/manage-billing.icon"; +import { SubscriptionHiddenIcon } from "./icons/subscription-hidden.icon"; import { SecretsManagerSubscriptionOptions } from "./sm-adjust-subscription.component"; @Component({ @@ -50,19 +49,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy hasBillingSyncToken: boolean; showAdjustSecretsManager = false; showSecretsManagerSubscribe = false; - firstLoaded = false; - loading: boolean; + loading = true; locale: string; showUpdatedSubscriptionStatusSection$: Observable; - manageBillingFromProviderPortal = ManageBilling; - isManagedByConsolidatedBillingMSP = false; enableTimeThreshold: boolean; preSelectedProductTier: ProductTierType = ProductTierType.Free; + showSubscription = true; + showSelfHost = false; + protected readonly subscriptionHiddenIcon = SubscriptionHiddenIcon; protected readonly teamsStarter = ProductTierType.TeamsStarter; - private destroy$ = new Subject(); - protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$( FeatureFlag.EnableConsolidatedBilling, ); @@ -71,7 +68,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy FeatureFlag.EnableTimeThreshold, ); - protected EnableUpgradePasswordManagerSub$ = this.configService.getFeatureFlag$( + protected enableUpgradePasswordManagerSub$ = this.configService.getFeatureFlag$( FeatureFlag.EnableUpgradePasswordManagerSub, ); @@ -79,9 +76,10 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy FeatureFlag.AC2476_DeprecateStripeSourcesAPI, ); + private destroy$ = new Subject(); + constructor( private apiService: ApiService, - private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private logService: LogService, private organizationService: OrganizationService, @@ -89,15 +87,13 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy private route: ActivatedRoute, private dialogService: DialogService, private configService: ConfigService, - private providerService: ProviderService, private toastService: ToastService, + private billingApiService: BillingApiServiceAbstraction, ) {} async ngOnInit() { if (this.route.snapshot.queryParamMap.get("upgrade")) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.changePlan(); + await this.changePlan(); const productTierTypeStr = this.route.snapshot.queryParamMap.get("productTierType"); if (productTierTypeStr != null) { const productTier = Number(productTierTypeStr); @@ -112,7 +108,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy concatMap(async (params) => { this.organizationId = params.organizationId; await this.load(); - this.firstLoaded = true; }), takeUntil(this.destroy$), ) @@ -130,21 +125,34 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy } async load() { - if (this.loading) { - return; - } this.loading = true; this.locale = await firstValueFrom(this.i18nService.locale$); this.userOrg = await this.organizationService.get(this.organizationId); - if (this.userOrg.canViewSubscription) { - const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$); - const provider = await this.providerService.get(this.userOrg.providerId); - this.isManagedByConsolidatedBillingMSP = - enableConsolidatedBilling && - this.userOrg.hasProvider && - provider?.providerStatus == ProviderStatusType.Billable; + /* + +--------------------+--------------+----------------------+--------------+ + | User Type | Has Provider | Consolidated Billing | Subscription | + +--------------------+--------------+----------------------+--------------+ + | Organization Owner | False | N/A | Shown | + | Organization Owner | True | N/A | Hidden | + | Provider User | True | False | Shown | + | Provider User | True | True | Hidden | + +--------------------+--------------+----------------------+--------------+ + */ + const consolidatedBillingEnabled = await firstValueFrom(this.enableConsolidatedBilling$); + + this.showSubscription = + (!this.userOrg.hasProvider && this.userOrg.isOwner) || + (this.userOrg.hasProvider && this.userOrg.isProviderUser && !consolidatedBillingEnabled); + + const metadata = await this.billingApiService.getOrganizationBillingMetadata( + this.organizationId, + ); + + this.showSelfHost = metadata.isEligibleForSelfHost; + + if (this.showSubscription) { this.sub = await this.organizationApiService.getSubscription(this.organizationId); this.lineItems = this.sub?.subscription?.items; @@ -277,26 +285,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return this.sub.subscription?.items.some((i) => i.sponsoredSubscriptionItem); } - get canDownloadLicense() { - return ( - (this.sub.planType !== PlanType.Free && this.subscription == null) || - (this.subscription != null && !this.subscription.cancelled) - ); - } - - get canManageBillingSync() { - return ( - this.sub.planType === PlanType.EnterpriseAnnually || - this.sub.planType === PlanType.EnterpriseMonthly || - this.sub.planType === PlanType.EnterpriseAnnually2023 || - this.sub.planType === PlanType.EnterpriseMonthly2023 || - this.sub.planType === PlanType.EnterpriseAnnually2020 || - this.sub.planType === PlanType.EnterpriseMonthly2020 || - this.sub.planType === PlanType.EnterpriseAnnually2019 || - this.sub.planType === PlanType.EnterpriseMonthly2019 - ); - } - get subscriptionDesc() { if (this.sub.planType === PlanType.Free) { return this.i18nService.t("subscriptionFreePlan", this.sub.seats.toString()); @@ -353,13 +341,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy ); } - shownSelfHost(): boolean { - return ( - this.sub?.plan.productTier !== ProductTierType.Teams && - this.sub?.plan.productTier !== ProductTierType.Free - ); - } - cancelSubscription = async () => { const reference = openOffboardingSurvey(this.dialogService, { data: { @@ -399,9 +380,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy title: null, message: this.i18nService.t("reinstated"), }); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.load(); + await this.load(); } catch (e) { this.logService.error(e); } @@ -409,7 +388,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy async changePlan() { const EnableUpgradePasswordManagerSub = await firstValueFrom( - this.EnableUpgradePasswordManagerSub$, + this.enableUpgradePasswordManagerSub$, ); if (EnableUpgradePasswordManagerSub) { const reference = openChangePlanDialog(this.dialogService, { @@ -458,24 +437,15 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy }); await firstValueFrom(dialogRef.closed); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.load(); + await this.load(); } - closeDownloadLicense() { - this.showDownloadLicense = false; - } - - subscriptionAdjusted() { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.load(); + async subscriptionAdjusted() { + await this.load(); } calculateTotalAppliedDiscount(total: number) { - const discountedTotal = total / (1 - this.customerDiscount?.percentOff / 100); - return discountedTotal; + return total / (1 - this.customerDiscount?.percentOff / 100); } adjustStorage = (add: boolean) => { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d9be5d769b..c50775efa6 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9457,5 +9457,15 @@ }, "permanentlyDeleteAttachmentConfirmation": { "message": "Are you sure you want to permanently delete this attachment?" + }, + "manageSubscriptionFromThe": { + "message": "Manage subscription from the", + "description": "This represents the beginning of a sentence. The full sentence will be 'Manage subscription from the Provider Portal', but 'Provider Portal' will be a link and thus cannot be included in the translation file." + }, + "toHostBitwardenOnYourOwnServer": { + "message": "To host Bitwarden on your own server, you will need to upload your license file. To support Free Families plans and advanced billing capabilities for your self-hosted organization, you will need to set up automatic sync in your self-hosted organization." + }, + "selfHostingTitleProper": { + "message": "Self-Hosting" } } diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts index 33d7907fa8..4831d29069 100644 --- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts +++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts @@ -1,10 +1,12 @@ import { BaseResponse } from "../../../models/response/base.response"; export class OrganizationBillingMetadataResponse extends BaseResponse { + isEligibleForSelfHost: boolean; isOnSecretsManagerStandalone: boolean; constructor(response: any) { super(response); + this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost"); this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone"); } }