From e622d7431f8e84edb6b916f702390f3975e7bbf0 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Tue, 31 Jan 2023 07:41:23 +1000 Subject: [PATCH] [EC-826] Merge license sync feature branch to master (#4503) * [EC-816] Separate cloud and selfhosted subscription components (#4383) * [EC-636] Add license sync to web vault (#4441) * [EC-1036] Show correct last license sync date (#4558) * [EC-1044] Fix: accidentally changed shared i18n string --- .../app/core/web-platform-utils.service.ts | 4 + .../billing/billing-sync-api-key.component.ts | 14 +- .../organization-billing-routing.module.ts | 8 +- .../billing/organization-billing.module.ts | 8 +- ...nization-subscription-cloud.component.html | 230 ++++++++++ ...anization-subscription-cloud.component.ts} | 403 +++++++----------- ...ation-subscription-selfhost.component.html | 117 +++++ ...ization-subscription-selfhost.component.ts | 178 ++++++++ .../organization-subscription.component.html | 313 -------------- ...on.ts => subscription-hidden.component.ts} | 17 +- .../settings/billing-sync-key.component.ts | 23 +- .../settings/update-license.component.html | 2 +- .../app/settings/update-license.component.ts | 1 + apps/web/src/app/shared/shared.module.ts | 3 + apps/web/src/locales/en/messages.json | 38 +- .../organization-api.service.abstraction.ts | 1 + .../src/models/api/billing-sync-config.api.ts | 6 + .../organization/organization-api.service.ts | 26 +- libs/components/src/form/form.stories.ts | 12 +- .../radio-button/radio-button.component.html | 3 +- .../radio-button.component.spec.ts | 2 +- .../src/radio-button/radio-button.stories.ts | 79 +++- 22 files changed, 891 insertions(+), 597 deletions(-) create mode 100644 apps/web/src/app/organizations/billing/organization-subscription-cloud.component.html rename apps/web/src/app/organizations/billing/{organization-subscription.component.ts => organization-subscription-cloud.component.ts} (62%) create mode 100644 apps/web/src/app/organizations/billing/organization-subscription-selfhost.component.html create mode 100644 apps/web/src/app/organizations/billing/organization-subscription-selfhost.component.ts delete mode 100644 apps/web/src/app/organizations/billing/organization-subscription.component.html rename apps/web/src/app/organizations/billing/{subscription-hidden.icon.ts => subscription-hidden.component.ts} (96%) diff --git a/apps/web/src/app/core/web-platform-utils.service.ts b/apps/web/src/app/core/web-platform-utils.service.ts index 2d682a35aaa..e2271f713b3 100644 --- a/apps/web/src/app/core/web-platform-utils.service.ts +++ b/apps/web/src/app/core/web-platform-utils.service.ts @@ -198,6 +198,10 @@ export class WebPlatformUtilsService implements PlatformUtilsService { } isSelfHost(): boolean { + return WebPlatformUtilsService.isSelfHost(); + } + + static isSelfHost(): boolean { return process.env.ENV.toString() === "selfhosted"; } diff --git a/apps/web/src/app/organizations/billing/billing-sync-api-key.component.ts b/apps/web/src/app/organizations/billing/billing-sync-api-key.component.ts index 5fbd5aa907d..acc13ee6d5a 100644 --- a/apps/web/src/app/organizations/billing/billing-sync-api-key.component.ts +++ b/apps/web/src/app/organizations/billing/billing-sync-api-key.component.ts @@ -1,5 +1,6 @@ import { Component } from "@angular/core"; +import { ModalConfig } from "@bitwarden/angular/services/modal.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization-api.service.abstraction"; @@ -10,6 +11,11 @@ import { OrganizationApiKeyRequest } from "@bitwarden/common/models/request/orga import { ApiKeyResponse } from "@bitwarden/common/models/response/api-key.response"; import { Verification } from "@bitwarden/common/types/verification"; +export interface BillingSyncApiModalData { + organizationId: string; + hasBillingToken: boolean; +} + @Component({ selector: "app-billing-sync-api-key", templateUrl: "billing-sync-api-key.component.html", @@ -30,8 +36,12 @@ export class BillingSyncApiKeyComponent { private apiService: ApiService, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, - private organizationApiService: OrganizationApiServiceAbstraction - ) {} + private organizationApiService: OrganizationApiServiceAbstraction, + modalConfig: ModalConfig + ) { + this.organizationId = modalConfig.data.organizationId; + this.hasBillingToken = modalConfig.data.hasBillingToken; + } copy() { this.platformUtilsService.copyToClipboard(this.clientSecret); diff --git a/apps/web/src/app/organizations/billing/organization-billing-routing.module.ts b/apps/web/src/app/organizations/billing/organization-billing-routing.module.ts index 0e410d9734d..b441e033398 100644 --- a/apps/web/src/app/organizations/billing/organization-billing-routing.module.ts +++ b/apps/web/src/app/organizations/billing/organization-billing-routing.module.ts @@ -3,12 +3,14 @@ import { RouterModule, Routes } from "@angular/router"; import { canAccessBillingTab } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; +import { WebPlatformUtilsService } from "../../core/web-platform-utils.service"; import { PaymentMethodComponent } from "../../settings/payment-method.component"; import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard"; import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component"; import { OrganizationBillingTabComponent } from "./organization-billing-tab.component"; -import { OrganizationSubscriptionComponent } from "./organization-subscription.component"; +import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component"; +import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component"; const routes: Routes = [ { @@ -20,7 +22,9 @@ const routes: Routes = [ { path: "", pathMatch: "full", redirectTo: "subscription" }, { path: "subscription", - component: OrganizationSubscriptionComponent, + component: WebPlatformUtilsService.isSelfHost() + ? OrganizationSubscriptionSelfhostComponent + : OrganizationSubscriptionCloudComponent, data: { titleId: "subscription" }, }, { diff --git a/apps/web/src/app/organizations/billing/organization-billing.module.ts b/apps/web/src/app/organizations/billing/organization-billing.module.ts index 513b7ba766f..71222e3ba5f 100644 --- a/apps/web/src/app/organizations/billing/organization-billing.module.ts +++ b/apps/web/src/app/organizations/billing/organization-billing.module.ts @@ -9,7 +9,9 @@ import { DownloadLicenseComponent } from "./download-license.component"; import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component"; import { OrganizationBillingRoutingModule } from "./organization-billing-routing.module"; import { OrganizationBillingTabComponent } from "./organization-billing-tab.component"; -import { OrganizationSubscriptionComponent } from "./organization-subscription.component"; +import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component"; +import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component"; +import { SubscriptionHiddenComponent } from "./subscription-hidden.component"; @NgModule({ imports: [SharedModule, LooseComponentsModule, OrganizationBillingRoutingModule], @@ -19,8 +21,10 @@ import { OrganizationSubscriptionComponent } from "./organization-subscription.c ChangePlanComponent, DownloadLicenseComponent, OrganizationBillingTabComponent, - OrganizationSubscriptionComponent, OrgBillingHistoryViewComponent, + OrganizationSubscriptionSelfhostComponent, + OrganizationSubscriptionCloudComponent, + SubscriptionHiddenComponent, ], }) export class OrganizationBillingModule {} diff --git a/apps/web/src/app/organizations/billing/organization-subscription-cloud.component.html b/apps/web/src/app/organizations/billing/organization-subscription-cloud.component.html new file mode 100644 index 00000000000..25dc12dc2fb --- /dev/null +++ b/apps/web/src/app/organizations/billing/organization-subscription-cloud.component.html @@ -0,0 +1,230 @@ + + + + {{ "loading" | i18n }} + + + + + + + {{ "subscriptionCanceled" | i18n }} + +

{{ "subscriptionPendingCanceled" | i18n }}

+ +
+ +
+
+
+
{{ "billingPlan" | i18n }}
+
{{ sub.plan.name }}
+ +
{{ "status" | i18n }}
+
+ {{ + isSponsoredSubscription ? "sponsored" : subscription.status || "-" + }} + {{ + "pendingCancellation" | i18n + }} +
+
{{ "nextCharge" | i18n }}
+
+ {{ + nextInvoice + ? (nextInvoice.date | date: "mediumDate") + + ", " + + (nextInvoice.amount | currency: "$") + : "-" + }} +
+
+
+
+
+ {{ "details" | i18n }} + + + + + + + +
+ {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ + {{ i.amount | currency: "$" }} + {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}
+
+ +
+
+
{{ "provider" | i18n }}
+
{{ "yourProviderIs" | i18n: userOrg.providerName }}
+
+
+
+
+ + + + +

{{ "manageSubscription" | i18n }}

+

{{ subscriptionDesc }}

+ +
+ + +
+
+ +

{{ "storage" | i18n }}

+

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

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

{{ "selfHostingTitle" | i18n }}

+

+ {{ "selfHostingEnterpriseOrganizationSectionCopy" | i18n }} +

+
+ + +
+
+ +
+

{{ "additionalOptions" | i18n }}

+

+ {{ "additionalOptionsDesc" | i18n }} +

+
+ +
+
diff --git a/apps/web/src/app/organizations/billing/organization-subscription.component.ts b/apps/web/src/app/organizations/billing/organization-subscription-cloud.component.ts similarity index 62% rename from apps/web/src/app/organizations/billing/organization-subscription.component.ts rename to apps/web/src/app/organizations/billing/organization-subscription-cloud.component.ts index 1cfbd7ef868..c70b3a2929d 100644 --- a/apps/web/src/app/organizations/billing/organization-subscription.component.ts +++ b/apps/web/src/app/organizations/billing/organization-subscription-cloud.component.ts @@ -1,62 +1,40 @@ -import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { concatMap, Subject, takeUntil } from "rxjs"; -import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; +import { ModalConfig, ModalService } from "@bitwarden/angular/services/modal.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { OrganizationApiKeyType } from "@bitwarden/common/enums/organizationApiKeyType"; -import { OrganizationConnectionType } from "@bitwarden/common/enums/organizationConnectionType"; import { PlanType } from "@bitwarden/common/enums/planType"; -import { BillingSyncConfigApi } from "@bitwarden/common/models/api/billing-sync-config.api"; import { Organization } from "@bitwarden/common/models/domain/organization"; -import { OrganizationConnectionResponse } from "@bitwarden/common/models/response/organization-connection.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/models/response/organization-subscription.response"; -import { BillingSyncKeyComponent } from "../../settings/billing-sync-key.component"; - -import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; -import { SubscriptionHiddenIcon } from "./subscription-hidden.icon"; +import { + BillingSyncApiKeyComponent, + BillingSyncApiModalData, +} from "./billing-sync-api-key.component"; @Component({ - selector: "app-org-subscription", - templateUrl: "organization-subscription.component.html", + selector: "app-org-subscription-cloud", + templateUrl: "organization-subscription-cloud.component.html", }) -export class OrganizationSubscriptionComponent implements OnInit, OnDestroy { - @ViewChild("setupBillingSyncTemplate", { read: ViewContainerRef, static: true }) - setupBillingSyncModalRef: ViewContainerRef; - - loading = false; - firstLoaded = false; +export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy { + sub: OrganizationSubscriptionResponse; organizationId: string; + userOrg: Organization; + showChangePlan = false; + showDownloadLicense = false; adjustStorageAdd = true; showAdjustStorage = false; - showUpdateLicense = false; - showBillingSyncKey = false; - showDownloadLicense = false; - showChangePlan = false; - sub: OrganizationSubscriptionResponse; - selfHosted = false; hasBillingSyncToken: boolean; - userOrg: Organization; - existingBillingSyncConnection: OrganizationConnectionResponse; - - removeSponsorshipPromise: Promise; - cancelPromise: Promise; - reinstatePromise: Promise; - - @ViewChild("rotateBillingSyncKeyTemplate", { read: ViewContainerRef, static: true }) - billingSyncKeyViewContainerRef: ViewContainerRef; - billingSyncKeyRef: [ModalRef, BillingSyncKeyComponent]; - - subscriptionHiddenIcon = SubscriptionHiddenIcon; + firstLoaded = false; + loading: boolean; private destroy$ = new Subject(); @@ -64,15 +42,12 @@ export class OrganizationSubscriptionComponent implements OnInit, OnDestroy { private apiService: ApiService, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, - private messagingService: MessagingService, - private route: ActivatedRoute, - private organizationService: OrganizationService, private logService: LogService, private modalService: ModalService, - private organizationApiService: OrganizationApiServiceAbstraction - ) { - this.selfHosted = platformUtilsService.isSelfHost(); - } + private organizationService: OrganizationService, + private organizationApiService: OrganizationApiServiceAbstraction, + private route: ActivatedRoute + ) {} async ngOnInit() { if (this.route.snapshot.queryParamMap.get("upgrade")) { @@ -105,6 +80,7 @@ export class OrganizationSubscriptionComponent implements OnInit, OnDestroy { if (this.userOrg.canManageBilling) { this.sub = await this.organizationApiService.getSubscription(this.organizationId); } + const apiKeyResponse = await this.organizationApiService.getApiKeyInformation( this.organizationId ); @@ -112,199 +88,9 @@ export class OrganizationSubscriptionComponent implements OnInit, OnDestroy { (i) => i.keyType === OrganizationApiKeyType.BillingSync ); - if (this.selfHosted) { - this.showBillingSyncKey = await this.apiService.getCloudCommunicationsEnabled(); - } - - if (this.showBillingSyncKey) { - this.existingBillingSyncConnection = await this.apiService.getOrganizationConnection( - this.organizationId, - OrganizationConnectionType.CloudBillingSync, - BillingSyncConfigApi - ); - } - this.loading = false; } - async reinstate() { - if (this.loading) { - return; - } - - const confirmed = await this.platformUtilsService.showDialog( - this.i18nService.t("reinstateConfirmation"), - this.i18nService.t("reinstateSubscription"), - this.i18nService.t("yes"), - this.i18nService.t("cancel") - ); - if (!confirmed) { - return; - } - - try { - this.reinstatePromise = this.organizationApiService.reinstate(this.organizationId); - await this.reinstatePromise; - this.platformUtilsService.showToast("success", null, this.i18nService.t("reinstated")); - this.load(); - } catch (e) { - this.logService.error(e); - } - } - - async cancel() { - if (this.loading) { - return; - } - - const confirmed = await this.platformUtilsService.showDialog( - this.i18nService.t("cancelConfirmation"), - this.i18nService.t("cancelSubscription"), - this.i18nService.t("yes"), - this.i18nService.t("no"), - "warning" - ); - if (!confirmed) { - return; - } - - try { - this.cancelPromise = this.organizationApiService.cancel(this.organizationId); - await this.cancelPromise; - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t("canceledSubscription") - ); - this.load(); - } catch (e) { - this.logService.error(e); - } - } - - async changePlan() { - this.showChangePlan = !this.showChangePlan; - } - - closeChangePlan() { - this.showChangePlan = false; - } - - downloadLicense() { - this.showDownloadLicense = !this.showDownloadLicense; - } - - async manageBillingSync() { - const [ref] = await this.modalService.openViewRef( - BillingSyncApiKeyComponent, - this.setupBillingSyncModalRef, - (comp) => { - comp.organizationId = this.organizationId; - comp.hasBillingToken = this.hasBillingSyncToken; - } - ); - ref.onClosed - .pipe( - concatMap(async () => { - await this.load(); - }), - takeUntil(this.destroy$) - ) - .subscribe(); - } - - closeDownloadLicense() { - this.showDownloadLicense = false; - } - - updateLicense() { - if (this.loading) { - return; - } - this.showUpdateLicense = true; - } - - closeUpdateLicense(updated: boolean) { - this.showUpdateLicense = false; - if (updated) { - this.load(); - this.messagingService.send("updatedOrgLicense"); - } - } - - subscriptionAdjusted() { - this.load(); - } - - adjustStorage(add: boolean) { - this.adjustStorageAdd = add; - this.showAdjustStorage = true; - } - - closeStorage(load: boolean) { - this.showAdjustStorage = false; - if (load) { - this.load(); - } - } - - async removeSponsorship() { - const isConfirmed = await this.platformUtilsService.showDialog( - this.i18nService.t("removeSponsorshipConfirmation"), - this.i18nService.t("removeSponsorship"), - this.i18nService.t("remove"), - this.i18nService.t("cancel"), - "warning" - ); - - if (!isConfirmed) { - return; - } - - try { - this.removeSponsorshipPromise = this.apiService.deleteRemoveSponsorship(this.organizationId); - await this.removeSponsorshipPromise; - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t("removeSponsorshipSuccess") - ); - await this.load(); - } catch (e) { - this.logService.error(e); - } - } - - async manageBillingSyncSelfHosted() { - this.billingSyncKeyRef = await this.modalService.openViewRef( - BillingSyncKeyComponent, - this.billingSyncKeyViewContainerRef, - (comp) => { - comp.entityId = this.organizationId; - comp.existingConnectionId = this.existingBillingSyncConnection?.id; - comp.billingSyncKey = this.existingBillingSyncConnection?.config?.billingSyncKey; - comp.setParentConnection = ( - connection: OrganizationConnectionResponse - ) => { - this.existingBillingSyncConnection = connection; - this.billingSyncKeyRef[0].close(); - }; - } - ); - } - - get isExpired() { - return ( - this.sub != null && this.sub.expiration != null && new Date(this.sub.expiration) < new Date() - ); - } - - get subscriptionMarkedForCancel() { - return ( - this.subscription != null && !this.subscription.cancelled && this.subscription.cancelAtEndDate - ); - } - get subscription() { return this.sub != null ? this.sub.subscription : null; } @@ -361,11 +147,10 @@ export class OrganizationSubscriptionComponent implements OnInit, OnDestroy { get canManageBillingSync() { return ( - !this.selfHosted && - (this.sub.planType === PlanType.EnterpriseAnnually || - this.sub.planType === PlanType.EnterpriseMonthly || - this.sub.planType === PlanType.EnterpriseAnnually2019 || - this.sub.planType === PlanType.EnterpriseMonthly2019) + this.sub.planType === PlanType.EnterpriseAnnually || + this.sub.planType === PlanType.EnterpriseMonthly || + this.sub.planType === PlanType.EnterpriseAnnually2019 || + this.sub.planType === PlanType.EnterpriseMonthly2019 ); } @@ -393,11 +178,143 @@ export class OrganizationSubscriptionComponent implements OnInit, OnDestroy { } } + get subscriptionMarkedForCancel() { + return ( + this.subscription != null && !this.subscription.cancelled && this.subscription.cancelAtEndDate + ); + } + + cancel = async () => { + if (this.loading) { + return; + } + + const confirmed = await this.platformUtilsService.showDialog( + this.i18nService.t("cancelConfirmation"), + this.i18nService.t("cancelSubscription"), + this.i18nService.t("yes"), + this.i18nService.t("no"), + "warning" + ); + if (!confirmed) { + return; + } + + try { + await this.organizationApiService.cancel(this.organizationId); + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("canceledSubscription") + ); + this.load(); + } catch (e) { + this.logService.error(e); + } + }; + + reinstate = async () => { + if (this.loading) { + return; + } + + const confirmed = await this.platformUtilsService.showDialog( + this.i18nService.t("reinstateConfirmation"), + this.i18nService.t("reinstateSubscription"), + this.i18nService.t("yes"), + this.i18nService.t("cancel") + ); + if (!confirmed) { + return; + } + + try { + await this.organizationApiService.reinstate(this.organizationId); + this.platformUtilsService.showToast("success", null, this.i18nService.t("reinstated")); + this.load(); + } catch (e) { + this.logService.error(e); + } + }; + + async changePlan() { + this.showChangePlan = !this.showChangePlan; + } + + closeChangePlan() { + this.showChangePlan = false; + } + + downloadLicense() { + this.showDownloadLicense = !this.showDownloadLicense; + } + + async manageBillingSync() { + const modalConfig: ModalConfig = { + data: { + organizationId: this.organizationId, + hasBillingToken: this.hasBillingSyncToken, + }, + }; + const modalRef = this.modalService.open(BillingSyncApiKeyComponent, modalConfig); + + modalRef.onClosed + .pipe( + concatMap(async () => { + this.load(); + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + closeDownloadLicense() { + this.showDownloadLicense = false; + } + + subscriptionAdjusted() { + this.load(); + } + + adjustStorage(add: boolean) { + this.adjustStorageAdd = add; + this.showAdjustStorage = true; + } + + closeStorage(load: boolean) { + this.showAdjustStorage = false; + if (load) { + this.load(); + } + } + + removeSponsorship = async () => { + const isConfirmed = await this.platformUtilsService.showDialog( + this.i18nService.t("removeSponsorshipConfirmation"), + this.i18nService.t("removeSponsorship"), + this.i18nService.t("remove"), + this.i18nService.t("cancel"), + "warning" + ); + + if (!isConfirmed) { + return; + } + + try { + await this.apiService.deleteRemoveSponsorship(this.organizationId); + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("removeSponsorshipSuccess") + ); + await this.load(); + } catch (e) { + this.logService.error(e); + } + }; + get showChangePlanButton() { return this.subscription == null && this.sub.planType === PlanType.Free && !this.showChangePlan; } - - get billingSyncSetUp() { - return this.existingBillingSyncConnection?.id != null; - } } diff --git a/apps/web/src/app/organizations/billing/organization-subscription-selfhost.component.html b/apps/web/src/app/organizations/billing/organization-subscription-selfhost.component.html new file mode 100644 index 00000000000..0b71bfd9576 --- /dev/null +++ b/apps/web/src/app/organizations/billing/organization-subscription-selfhost.component.html @@ -0,0 +1,117 @@ + + + + + {{ "loading" | i18n }} + + + + + +
+
{{ "billingPlan" | i18n }}
+
{{ sub.plan.name }}
+
{{ "expiration" | i18n }}
+
+ {{ sub.expiration | date: "mediumDate" }} + + + {{ "licenseIsExpired" | i18n }} + +
+
{{ "neverExpires" | i18n }}
+ +
{{ "lastLicenseSync" | i18n }}
+
+ {{ lastLicenseSync != null ? (lastLicenseSync | date: "medium") : ("never" | i18n) }} +
+
+
+ + + {{ "launchCloudSubscription" | i18n }} + +
+ +

+ {{ "licenseAndBillingManagement" | i18n }} +

+ + {{ "automaticSync" | i18n }} + + + {{ "billingSyncHelp" | i18n }} + + + + {{ "billingSyncDesc" | i18n }} + + + + + + + + + {{ "manualUpload" | i18n }} + + {{ "manualUploadDesc" | i18n }} + + + +

{{ "uploadLicense" | i18n }}

+ +
+
+
+
diff --git a/apps/web/src/app/organizations/billing/organization-subscription-selfhost.component.ts b/apps/web/src/app/organizations/billing/organization-subscription-selfhost.component.ts new file mode 100644 index 00000000000..824447c1224 --- /dev/null +++ b/apps/web/src/app/organizations/billing/organization-subscription-selfhost.component.ts @@ -0,0 +1,178 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { concatMap, takeUntil, Subject } from "rxjs"; + +import { ModalConfig, ModalService } from "@bitwarden/angular/services/modal.service"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { OrganizationConnectionType } from "@bitwarden/common/enums/organizationConnectionType"; +import { BillingSyncConfigApi } from "@bitwarden/common/models/api/billing-sync-config.api"; +import { Organization } from "@bitwarden/common/models/domain/organization"; +import { OrganizationConnectionResponse } from "@bitwarden/common/models/response/organization-connection.response"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/models/response/organization-subscription.response"; + +import { + BillingSyncKeyComponent, + BillingSyncKeyModalData, +} from "../../settings/billing-sync-key.component"; + +enum LicenseOptions { + SYNC = 0, + UPLOAD = 1, +} + +@Component({ + selector: "app-org-subscription-selfhost", + templateUrl: "organization-subscription-selfhost.component.html", +}) +export class OrganizationSubscriptionSelfhostComponent implements OnInit, OnDestroy { + sub: OrganizationSubscriptionResponse; + organizationId: string; + userOrg: Organization; + + licenseOptions = LicenseOptions; + form = new FormGroup({ + updateMethod: new FormControl(LicenseOptions.UPLOAD), + }); + + disableLicenseSyncControl = false; + + firstLoaded = false; + loading = false; + + private _existingBillingSyncConnection: OrganizationConnectionResponse; + + private destroy$ = new Subject(); + + set existingBillingSyncConnection(value: OrganizationConnectionResponse) { + this._existingBillingSyncConnection = value; + + this.form + .get("updateMethod") + .setValue(this.billingSyncEnabled ? LicenseOptions.SYNC : LicenseOptions.UPLOAD); + } + + get existingBillingSyncConnection() { + return this._existingBillingSyncConnection; + } + + get billingSyncEnabled() { + return this.existingBillingSyncConnection?.enabled; + } + + constructor( + private modalService: ModalService, + private messagingService: MessagingService, + private apiService: ApiService, + private organizationService: OrganizationService, + private route: ActivatedRoute, + private organizationApiService: OrganizationApiServiceAbstraction, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService + ) {} + + async ngOnInit() { + this.route.params + .pipe( + concatMap(async (params) => { + this.organizationId = params.organizationId; + await this.load(); + await this.loadOrganizationConnection(); + this.firstLoaded = true; + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + async load() { + if (this.loading) { + return; + } + this.loading = true; + this.userOrg = this.organizationService.get(this.organizationId); + if (this.userOrg.canManageBilling) { + this.sub = await this.organizationApiService.getSubscription(this.organizationId); + } + + this.loading = false; + } + + async loadOrganizationConnection() { + if (!this.firstLoaded) { + const cloudCommunicationEnabled = await this.apiService.getCloudCommunicationsEnabled(); + this.disableLicenseSyncControl = !cloudCommunicationEnabled; + } + + if (this.disableLicenseSyncControl) { + return; + } + + this.existingBillingSyncConnection = await this.apiService.getOrganizationConnection( + this.organizationId, + OrganizationConnectionType.CloudBillingSync, + BillingSyncConfigApi + ); + } + + licenseUploaded() { + this.load(); + this.messagingService.send("updatedOrgLicense"); + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("licenseUploadSuccess") + ); + } + + manageBillingSyncSelfHosted() { + const modalConfig: ModalConfig = { + data: { + entityId: this.organizationId, + existingConnectionId: this.existingBillingSyncConnection?.id, + billingSyncKey: this.existingBillingSyncConnection?.config?.billingSyncKey, + setParentConnection: (connection: OrganizationConnectionResponse) => { + this.existingBillingSyncConnection = connection; + }, + }, + }; + + this.modalService.open(BillingSyncKeyComponent, modalConfig); + } + + syncLicense = async () => { + this.form.get("updateMethod").setValue(LicenseOptions.SYNC); + await this.organizationApiService.selfHostedSyncLicense(this.organizationId); + + this.load(); + await this.loadOrganizationConnection(); + this.messagingService.send("updatedOrgLicense"); + this.platformUtilsService.showToast("success", null, this.i18nService.t("licenseSyncSuccess")); + }; + + get billingSyncSetUp() { + return this.existingBillingSyncConnection?.id != null; + } + + get isExpired() { + return this.sub?.expiration != null && new Date(this.sub.expiration) < new Date(); + } + + get updateMethod() { + return this.form.get("updateMethod").value; + } + + get lastLicenseSync() { + return this.existingBillingSyncConnection?.config?.lastLicenseSync; + } +} diff --git a/apps/web/src/app/organizations/billing/organization-subscription.component.html b/apps/web/src/app/organizations/billing/organization-subscription.component.html deleted file mode 100644 index 1ebda8f76c0..00000000000 --- a/apps/web/src/app/organizations/billing/organization-subscription.component.html +++ /dev/null @@ -1,313 +0,0 @@ - - - - {{ "loading" | i18n }} - - - -
- -

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

-

{{ "billingContactProviderForAssistance" | i18n }}

-
-
- - - - {{ "subscriptionCanceled" | i18n }} - -

{{ "subscriptionPendingCanceled" | i18n }}

- -
- -
-
-
-
{{ "billingPlan" | i18n }}
-
{{ sub.plan.name }}
- -
{{ "status" | i18n }}
-
- {{ - isSponsoredSubscription ? "sponsored" : subscription.status || "-" - }} - {{ - "pendingCancellation" | i18n - }} -
-
{{ "nextCharge" | i18n }}
-
- {{ - nextInvoice - ? (nextInvoice.date | date: "mediumDate") + - ", " + - (nextInvoice.amount | currency: "$") - : "-" - }} -
-
-
-
-
- {{ "details" | i18n }} - - - - - - - -
- {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ - {{ i.amount | currency: "$" }} - {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}
-
- -
-
-
{{ "provider" | i18n }}
-
{{ "yourProviderIs" | i18n: userOrg.providerName }}
-
-
-
-
- - - - -

{{ "manageSubscription" | i18n }}

-

{{ subscriptionDesc }}

- -
- - -
-
- -

{{ "storage" | i18n }}

-

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

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

{{ "selfHostingTitle" | i18n }}

-

- {{ "selfHostingEnterpriseOrganizationSectionCopy" | i18n }} -

-
- - -
-
- -
-

{{ "additionalOptions" | i18n }}

-

- {{ "additionalOptionsDesc" | i18n }} -

-
- -
-
- -
-
{{ "billingPlan" | i18n }}
-
{{ sub.plan.name }}
-
{{ "expiration" | i18n }}
-
- {{ sub.expiration | date: "mediumDate" }} - - - {{ "licenseIsExpired" | i18n }} - -
-
{{ "neverExpires" | i18n }}
-
-
- - - {{ "manageSubscription" | i18n }} - -
-
-
- -

{{ "updateLicense" | i18n }}

- -
-
-
-

- {{ "billingSync" | i18n }} -

-

- {{ "billingSyncDesc" | i18n }} -

- - - {{ "lastSync" | i18n }}: - - {{ userOrg.familySponsorshipLastSyncDate | date: "medium" }} - - - {{ "never" | i18n | lowercase }} - - -
-
-
- - diff --git a/apps/web/src/app/organizations/billing/subscription-hidden.icon.ts b/apps/web/src/app/organizations/billing/subscription-hidden.component.ts similarity index 96% rename from apps/web/src/app/organizations/billing/subscription-hidden.icon.ts rename to apps/web/src/app/organizations/billing/subscription-hidden.component.ts index 3745747eceb..a603fff7804 100644 --- a/apps/web/src/app/organizations/billing/subscription-hidden.icon.ts +++ b/apps/web/src/app/organizations/billing/subscription-hidden.component.ts @@ -1,6 +1,8 @@ +import { Component, Input } from "@angular/core"; + import { svgIcon } from "@bitwarden/components"; -export const SubscriptionHiddenIcon = svgIcon` +const SubscriptionHiddenIcon = svgIcon` @@ -22,3 +24,16 @@ export const SubscriptionHiddenIcon = svgIcon` `; + +@Component({ + selector: "app-org-subscription-hidden", + template: `
+ +

{{ "billingManagedByProvider" | i18n: providerName }}

+

{{ "billingContactProviderForAssistance" | i18n }}

+
`, +}) +export class SubscriptionHiddenComponent { + @Input() providerName: string; + subscriptionHiddenIcon = SubscriptionHiddenIcon; +} diff --git a/apps/web/src/app/settings/billing-sync-key.component.ts b/apps/web/src/app/settings/billing-sync-key.component.ts index 70929ed8081..45b3a06d698 100644 --- a/apps/web/src/app/settings/billing-sync-key.component.ts +++ b/apps/web/src/app/settings/billing-sync-key.component.ts @@ -1,5 +1,7 @@ import { Component } from "@angular/core"; +import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; +import { ModalConfig } from "@bitwarden/angular/services/modal.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; import { OrganizationConnectionType } from "@bitwarden/common/enums/organizationConnectionType"; @@ -8,6 +10,13 @@ import { BillingSyncConfigRequest } from "@bitwarden/common/models/request/billi import { OrganizationConnectionRequest } from "@bitwarden/common/models/request/organization-connection.request"; import { OrganizationConnectionResponse } from "@bitwarden/common/models/response/organization-connection.response"; +export interface BillingSyncKeyModalData { + entityId: string; + existingConnectionId: string; + billingSyncKey: string; + setParentConnection: (connection: OrganizationConnectionResponse) => void; +} + @Component({ selector: "app-billing-sync-key", templateUrl: "billing-sync-key.component.html", @@ -20,7 +29,17 @@ export class BillingSyncKeyComponent { formPromise: Promise> | Promise; - constructor(private apiService: ApiService, private logService: LogService) {} + constructor( + private apiService: ApiService, + private logService: LogService, + protected modalRef: ModalRef, + config: ModalConfig + ) { + this.entityId = config.data.entityId; + this.existingConnectionId = config.data.existingConnectionId; + this.billingSyncKey = config.data.billingSyncKey; + this.setParentConnection = config.data.setParentConnection; + } async submit() { try { @@ -47,6 +66,7 @@ export class BillingSyncKeyComponent { this.existingConnectionId = response?.id; this.billingSyncKey = response?.config?.billingSyncKey; this.setParentConnection(response); + this.modalRef.close(); } catch (e) { this.logService.error(e); } @@ -56,5 +76,6 @@ export class BillingSyncKeyComponent { this.formPromise = this.apiService.deleteOrganizationConnection(this.existingConnectionId); await this.formPromise; this.setParentConnection(null); + this.modalRef.close(); } } diff --git a/apps/web/src/app/settings/update-license.component.html b/apps/web/src/app/settings/update-license.component.html index 3884f569350..56058b158e8 100644 --- a/apps/web/src/app/settings/update-license.component.html +++ b/apps/web/src/app/settings/update-license.component.html @@ -14,7 +14,7 @@ {{ "submit" | i18n }} - diff --git a/apps/web/src/app/settings/update-license.component.ts b/apps/web/src/app/settings/update-license.component.ts index de23be5f777..6a43a9df99f 100644 --- a/apps/web/src/app/settings/update-license.component.ts +++ b/apps/web/src/app/settings/update-license.component.ts @@ -12,6 +12,7 @@ import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUti }) export class UpdateLicenseComponent { @Input() organizationId: string; + @Input() showCancel = true; @Output() onUpdated = new EventEmitter(); @Output() onCanceled = new EventEmitter(); diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index 0bc8321c171..7ac8e3a8e39 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -25,6 +25,7 @@ import { MultiSelectModule, TableModule, TabsModule, + RadioButtonModule, ToggleGroupModule, } from "@bitwarden/components"; @@ -68,6 +69,7 @@ import "./locales"; MultiSelectModule, TableModule, TabsModule, + RadioButtonModule, ToggleGroupModule, // Web specific @@ -100,6 +102,7 @@ import "./locales"; MultiSelectModule, TableModule, TabsModule, + RadioButtonModule, ToggleGroupModule, // Web specific diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index dcec317bf08..6cf0751911f 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -2058,6 +2058,9 @@ "manageSubscription": { "message": "Manage subscription" }, + "launchCloudSubscription": { + "message": "Launch Cloud Subscription" + }, "storage": { "message": "Storage" }, @@ -5239,11 +5242,8 @@ "billingSyncApiKeyRotated": { "message": "Token rotated" }, - "billingSync": { - "message": "Billing sync" - }, "billingSyncDesc": { - "message": "Billing sync provides Free Families plans for members and advanced billing capabilities by linking your self-hosted Bitwarden to the Bitwarden cloud server." + "message": "Billing sync unlocks Families sponsorships and automatic license syncing on your server. After making updates in the Bitwarden cloud server, select Sync License to apply changes." }, "billingSyncKeyDesc": { "message": "A billing sync token from your cloud organization's subscription settings is required to complete this form." @@ -6105,6 +6105,36 @@ } } }, + "licenseAndBillingManagement": { + "message": "License and billing management" + }, + "automaticSync": { + "message": "Automatic sync" + }, + "manualUpload": { + "message": "Manual upload" + }, + "manualUploadDesc": { + "message": "If you do not want to opt into billing sync, manually upload your license here." + }, + "syncLicense": { + "message": "Sync license" + }, + "licenseSyncSuccess": { + "message": "Successfully synced license" + }, + "licenseUploadSuccess": { + "message": "Successfully uploaded license" + }, + "lastLicenseSync": { + "message": "Last license sync" + }, + "billingSyncHelp": { + "message": "Billing Sync help" + }, + "uploadLicense": { + "message": "Upload license" + }, "lowKdfIterations": { "message": "Low KDF Iterations" }, diff --git a/libs/common/src/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/abstractions/organization/organization-api.service.abstraction.ts index cd86b0b46aa..92f4711d146 100644 --- a/libs/common/src/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/abstractions/organization/organization-api.service.abstraction.ts @@ -58,4 +58,5 @@ export class OrganizationApiServiceAbstraction { updateKeys: (id: string, request: OrganizationKeysRequest) => Promise; getSso: (id: string) => Promise; updateSso: (id: string, request: OrganizationSsoRequest) => Promise; + selfHostedSyncLicense: (id: string) => Promise; } diff --git a/libs/common/src/models/api/billing-sync-config.api.ts b/libs/common/src/models/api/billing-sync-config.api.ts index 8850dc8c513..b99c6a89ce7 100644 --- a/libs/common/src/models/api/billing-sync-config.api.ts +++ b/libs/common/src/models/api/billing-sync-config.api.ts @@ -2,6 +2,7 @@ import { BaseResponse } from "../response/base.response"; export class BillingSyncConfigApi extends BaseResponse { billingSyncKey: string; + lastLicenseSync: Date; constructor(data: any) { super(data); @@ -9,5 +10,10 @@ export class BillingSyncConfigApi extends BaseResponse { return; } this.billingSyncKey = this.getResponseProperty("BillingSyncKey"); + + const lastLicenseSyncString = this.getResponseProperty("LastLicenseSync"); + if (lastLicenseSyncString) { + this.lastLicenseSync = new Date(lastLicenseSyncString); + } } } diff --git a/libs/common/src/services/organization/organization-api.service.ts b/libs/common/src/services/organization/organization-api.service.ts index 58630e73d87..51337eb91e6 100644 --- a/libs/common/src/services/organization/organization-api.service.ts +++ b/libs/common/src/services/organization/organization-api.service.ts @@ -87,7 +87,13 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction } async createLicense(data: FormData): Promise { - const r = await this.apiService.send("POST", "/organizations/license", data, true, true); + const r = await this.apiService.send( + "POST", + "/organizations/licenses/self-hosted", + data, + true, + true + ); return new OrganizationResponse(r); } @@ -177,7 +183,13 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction } async updateLicense(id: string, data: FormData): Promise { - await this.apiService.send("POST", "/organizations/" + id + "/license", data, true, false); + await this.apiService.send( + "POST", + "/organizations/licenses/self-hosted/" + id, + data, + true, + false + ); } async importDirectory(organizationId: string, request: ImportDirectoryRequest): Promise { @@ -270,4 +282,14 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction // Not broadcasting anything because data on this response doesn't correspond to `Organization` return new OrganizationSsoResponse(r); } + + async selfHostedSyncLicense(id: string) { + await this.apiService.send( + "POST", + "/organizations/licenses/self-hosted/" + id + "/sync/", + null, + true, + false + ); + } } diff --git a/libs/components/src/form/form.stories.ts b/libs/components/src/form/form.stories.ts index 050ea5e2c8f..741aac859a2 100644 --- a/libs/components/src/form/form.stories.ts +++ b/libs/components/src/form/form.stories.ts @@ -98,9 +98,15 @@ const FullExampleTemplate: Story = (args) => ({ Subscribe to updates? - Yes - No - Decide later + + Yes + + + No + + + Decide later + diff --git a/libs/components/src/radio-button/radio-button.component.html b/libs/components/src/radio-button/radio-button.component.html index 2bcd4c0d5f1..0d455596536 100644 --- a/libs/components/src/radio-button/radio-button.component.html +++ b/libs/components/src/radio-button/radio-button.component.html @@ -10,5 +10,6 @@ (change)="onInputChange()" (blur)="onBlur()" /> - + + diff --git a/libs/components/src/radio-button/radio-button.component.spec.ts b/libs/components/src/radio-button/radio-button.component.spec.ts index 1d2759c1af1..400b631d1ee 100644 --- a/libs/components/src/radio-button/radio-button.component.spec.ts +++ b/libs/components/src/radio-button/radio-button.component.spec.ts @@ -72,7 +72,7 @@ class MockedButtonGroupComponent implements Partial { @Component({ selector: "test-app", - template: ` Element`, + template: ` Element`, }) class TestApp { value?: string; diff --git a/libs/components/src/radio-button/radio-button.stories.ts b/libs/components/src/radio-button/radio-button.stories.ts index 4990c6b6ce0..abc7ddb92a5 100644 --- a/libs/components/src/radio-button/radio-button.stories.ts +++ b/libs/components/src/radio-button/radio-button.stories.ts @@ -12,21 +12,26 @@ const template = `
Group of radio buttons - First - Second - Third + + {{ option.key }} + This is a hint for the {{option.key}} option +
`; -enum TestValue { - First, - Second, - Third, -} +const TestValue = { + First: 0, + Second: 1, + Third: 2, +}; + +const reverseObject = (obj: Record) => + Object.fromEntries(Object.entries(obj).map(([key, value]) => [value, key])); @Component({ selector: "app-example", - template, + template: template, }) class ExampleComponent { protected TestValue = TestValue; @@ -35,9 +40,11 @@ class ExampleComponent { radio: TestValue.First, }); + @Input() layout: "block" | "inline" = "inline"; + @Input() label: boolean; - @Input() set selected(value: TestValue) { + @Input() set selected(value: number) { this.formObj.patchValue({ radio: value }); } @@ -49,7 +56,11 @@ class ExampleComponent { } } - @Input() optionDisabled = false; + @Input() optionDisabled: number[] = []; + + get blockLayout() { + return this.layout === "block"; + } constructor(private formBuilder: FormBuilder) {} } @@ -84,27 +95,53 @@ export default { args: { selected: TestValue.First, groupDisabled: false, - optionDisabled: false, + optionDisabled: null, label: true, + layout: "inline", }, argTypes: { selected: { - options: [TestValue.First, TestValue.Second, TestValue.Third], + options: Object.values(TestValue), control: { type: "inline-radio", - labels: { - [TestValue.First]: "First", - [TestValue.Second]: "Second", - [TestValue.Third]: "Third", - }, + labels: reverseObject(TestValue), + }, + }, + optionDisabled: { + options: Object.values(TestValue), + control: { + type: "check", + labels: reverseObject(TestValue), + }, + }, + layout: { + options: ["inline", "block"], + control: { + type: "inline-radio", + labels: ["inline", "block"], }, }, }, } as Meta; -const DefaultTemplate: Story = (args: ExampleComponent) => ({ +const storyTemplate = ``; + +const InlineTemplate: Story = (args: ExampleComponent) => ({ props: args, - template: ``, + template: storyTemplate, }); -export const Default = DefaultTemplate.bind({}); +export const Inline = InlineTemplate.bind({}); +Inline.args = { + layout: "inline", +}; + +const BlockTemplate: Story = (args: ExampleComponent) => ({ + props: args, + template: storyTemplate, +}); + +export const Block = BlockTemplate.bind({}); +Block.args = { + layout: "block", +};