[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
This commit is contained in:
Thomas Rittson 2023-01-31 07:41:23 +10:00 committed by GitHub
parent b208866109
commit e622d7431f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 891 additions and 597 deletions

View File

@ -198,6 +198,10 @@ export class WebPlatformUtilsService implements PlatformUtilsService {
}
isSelfHost(): boolean {
return WebPlatformUtilsService.isSelfHost();
}
static isSelfHost(): boolean {
return process.env.ENV.toString() === "selfhosted";
}

View File

@ -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<BillingSyncApiModalData>
) {
this.organizationId = modalConfig.data.organizationId;
this.hasBillingToken = modalConfig.data.hasBillingToken;
}
copy() {
this.platformUtilsService.copyToClipboard(this.clientSecret);

View File

@ -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" },
},
{

View File

@ -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 {}

View File

@ -0,0 +1,230 @@
<div class="page-header">
<h1>
{{ "subscription" | i18n }}
<small *ngIf="firstLoaded && loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</small>
</h1>
</div>
<ng-container *ngIf="!firstLoaded && loading">
<i class="bwi bwi-spinner bwi-spin text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<app-org-subscription-hidden
*ngIf="firstLoaded && !userOrg.canManageBilling"
[providerName]="userOrg.providerName"
></app-org-subscription-hidden>
<ng-container *ngIf="sub">
<bit-callout
type="warning"
title="{{ 'canceled' | i18n }}"
*ngIf="subscription && subscription.cancelled"
>
{{ "subscriptionCanceled" | i18n }}</bit-callout
>
<bit-callout
type="warning"
title="{{ 'pendingCancellation' | i18n }}"
*ngIf="subscriptionMarkedForCancel"
>
<p>{{ "subscriptionPendingCanceled" | i18n }}</p>
<button bitButton buttonType="secondary" [bitAction]="reinstate" type="button">
{{ "reinstateSubscription" | i18n }}
</button>
</bit-callout>
<div class="row">
<div class="col-4">
<dl>
<dt>{{ "billingPlan" | i18n }}</dt>
<dd>{{ sub.plan.name }}</dd>
<ng-container *ngIf="subscription">
<dt>{{ "status" | i18n }}</dt>
<dd>
<span class="text-capitalize">{{
isSponsoredSubscription ? "sponsored" : subscription.status || "-"
}}</span>
<span bitBadge badgeType="warning" *ngIf="subscriptionMarkedForCancel">{{
"pendingCancellation" | i18n
}}</span>
</dd>
<dt>{{ "nextCharge" | i18n }}</dt>
<dd>
{{
nextInvoice
? (nextInvoice.date | date: "mediumDate") +
", " +
(nextInvoice.amount | currency: "$")
: "-"
}}
</dd>
</ng-container>
</dl>
</div>
<div class="col-8" *ngIf="subscription">
<strong class="d-block mb-1">{{ "details" | i18n }}</strong>
<table class="table">
<tbody>
<tr *ngFor="let i of subscription.items">
<td>
{{ i.name }} {{ i.quantity > 1 ? "&times;" + i.quantity : "" }} @
{{ i.amount | currency: "$" }}
</td>
<td>{{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}</td>
</tr>
</tbody>
</table>
</div>
<ng-container *ngIf="userOrg?.providerId != null">
<div class="col-sm">
<dl>
<dt>{{ "provider" | i18n }}</dt>
<dd>{{ "yourProviderIs" | i18n: userOrg.providerName }}</dd>
</dl>
</div>
</ng-container>
</div>
<ng-container>
<button
bitButton
buttonType="secondary"
type="button"
(click)="changePlan()"
*ngIf="showChangePlanButton"
>
{{ "changeBillingPlan" | i18n }}
</button>
<app-change-plan
[organizationId]="organizationId"
(onChanged)="closeChangePlan()"
(onCanceled)="closeChangePlan()"
*ngIf="showChangePlan"
></app-change-plan>
</ng-container>
<h2 class="spaced-header">{{ "manageSubscription" | i18n }}</h2>
<p class="mb-4">{{ subscriptionDesc }}</p>
<ng-container
*ngIf="
subscription && canAdjustSeats && !subscription.cancelled && !subscriptionMarkedForCancel
"
>
<div class="mt-3">
<app-adjust-subscription
[seatPrice]="seatPrice"
[organizationId]="organizationId"
[interval]="billingInterval"
[currentSeatCount]="seats"
[maxAutoscaleSeats]="maxAutoscaleSeats"
(onAdjusted)="subscriptionAdjusted()"
>
</app-adjust-subscription>
</div>
</ng-container>
<button
bitButton
buttonType="danger"
type="button"
[bitAction]="removeSponsorship"
*ngIf="isSponsoredSubscription"
>
{{ "removeSponsorship" | i18n }}
</button>
<h2 class="spaced-header">{{ "storage" | i18n }}</h2>
<p>{{ "subscriptionStorage" | i18n: sub.maxStorageGb || 0:sub.storageName || "0 MB" }}</p>
<div class="progress">
<div
class="progress-bar bg-success"
role="progressbar"
[ngStyle]="{ width: storageProgressWidth + '%' }"
[attr.aria-valuenow]="storagePercentage"
aria-valuemin="0"
aria-valuemax="100"
>
{{ storagePercentage / 100 | percent }}
</div>
</div>
<ng-container *ngIf="subscription && !subscription.cancelled && !subscriptionMarkedForCancel">
<div class="mt-3">
<div class="d-flex" *ngIf="!showAdjustStorage">
<button bitButton buttonType="secondary" type="button" (click)="adjustStorage(true)">
{{ "addStorage" | i18n }}
</button>
<button
bitButton
buttonType="secondary"
type="button"
class="ml-1"
(click)="adjustStorage(false)"
>
{{ "removeStorage" | i18n }}
</button>
</div>
<app-adjust-storage
[storageGbPrice]="storageGbPrice"
[add]="adjustStorageAdd"
[organizationId]="organizationId"
[interval]="billingInterval"
(onAdjusted)="closeStorage(true)"
(onCanceled)="closeStorage(false)"
*ngIf="showAdjustStorage"
></app-adjust-storage>
</div>
</ng-container>
<h2 class="spaced-header">{{ "selfHostingTitle" | i18n }}</h2>
<p class="mb-4">
{{ "selfHostingEnterpriseOrganizationSectionCopy" | i18n }}
</p>
<div class="d-flex">
<button
bitButton
buttonType="secondary"
type="button"
(click)="downloadLicense()"
*ngIf="canDownloadLicense"
[disabled]="showDownloadLicense"
>
{{ "downloadLicense" | i18n }}
</button>
<button
bitButton
buttonType="secondary"
type="button"
class="ml-1"
(click)="manageBillingSync()"
*ngIf="canManageBillingSync"
>
{{ (hasBillingSyncToken ? "manageBillingSync" : "setUpBillingSync") | i18n }}
</button>
</div>
<div class="mt-3" *ngIf="showDownloadLicense">
<app-download-license
[organizationId]="organizationId"
(onDownloaded)="closeDownloadLicense()"
(onCanceled)="closeDownloadLicense()"
></app-download-license>
</div>
<h2 class="spaced-header">{{ "additionalOptions" | i18n }}</h2>
<p class="mb-4">
{{ "additionalOptionsDesc" | i18n }}
</p>
<div class="d-flex">
<button
bitButton
buttonType="danger"
[bitAction]="cancel"
type="button"
class="ml-1"
*ngIf="subscription && !subscription.cancelled && !subscriptionMarkedForCancel"
>
{{ "cancelSubscription" | i18n }}
</button>
</div>
</ng-container>

View File

@ -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<BillingSyncConfigApi>;
removeSponsorshipPromise: Promise<void>;
cancelPromise: Promise<void>;
reinstatePromise: Promise<void>;
@ViewChild("rotateBillingSyncKeyTemplate", { read: ViewContainerRef, static: true })
billingSyncKeyViewContainerRef: ViewContainerRef;
billingSyncKeyRef: [ModalRef, BillingSyncKeyComponent];
subscriptionHiddenIcon = SubscriptionHiddenIcon;
firstLoaded = false;
loading: boolean;
private destroy$ = new Subject<void>();
@ -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<BillingSyncConfigApi>
) => {
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<BillingSyncApiModalData> = {
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;
}
}

View File

@ -0,0 +1,117 @@
<div class="page-header">
<h1>
{{ "subscription" | i18n }}
<small *ngIf="firstLoaded && loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</small>
</h1>
</div>
<ng-container *ngIf="!firstLoaded && loading">
<i class="bwi bwi-spinner bwi-spin text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<app-org-subscription-hidden
*ngIf="firstLoaded && !userOrg.canManageBilling"
[providerName]="userOrg.providerName"
></app-org-subscription-hidden>
<ng-container *ngIf="sub && firstLoaded">
<dl>
<dt>{{ "billingPlan" | i18n }}</dt>
<dd>{{ sub.plan.name }}</dd>
<dt>{{ "expiration" | i18n }}</dt>
<dd *ngIf="sub.expiration">
{{ sub.expiration | date: "mediumDate" }}
<span *ngIf="isExpired" class="text-danger ml-2">
<i class="bwi bwi-exclamation-triangle" aria-hidden="true"></i>
{{ "licenseIsExpired" | i18n }}
</span>
</dd>
<dd *ngIf="!sub.expiration">{{ "neverExpires" | i18n }}</dd>
<ng-container *ngIf="billingSyncSetUp">
<dt>{{ "lastLicenseSync" | i18n }}</dt>
<dd>
{{ lastLicenseSync != null ? (lastLicenseSync | date: "medium") : ("never" | i18n) }}
</dd>
</ng-container>
</dl>
<a
bitButton
buttonType="secondary"
href="https://vault.bitwarden.com"
target="_blank"
rel="noopener"
>
{{ "launchCloudSubscription" | i18n }}
</a>
<form [formGroup]="form">
<bit-radio-group formControlName="updateMethod">
<h2 class="mt-5">
{{ "licenseAndBillingManagement" | i18n }}
</h2>
<bit-radio-button
id="automatic-sync"
[value]="licenseOptions.SYNC"
[disabled]="disableLicenseSyncControl"
class="tw-block"
>
<bit-label
>{{ "automaticSync" | i18n }}
<a
href="https://bitwarden.com/help/families-for-enterprise-self-hosted/"
target="_blank"
rel="noopener"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
<span class="sr-only">{{ "billingSyncHelp" | i18n }}</span>
</a>
</bit-label>
<bit-hint>
{{ "billingSyncDesc" | i18n }}
</bit-hint>
</bit-radio-button>
<ng-container *ngIf="updateMethod === licenseOptions.SYNC">
<button
bitButton
buttonType="secondary"
type="button"
(click)="manageBillingSyncSelfHosted()"
>
{{ "manageBillingSync" | i18n }}
</button>
<button
bitButton
buttonType="primary"
type="button"
[bitAction]="syncLicense"
[disabled]="!billingSyncEnabled"
>
{{ "syncLicense" | i18n }}
</button>
</ng-container>
<bit-radio-button id="manual-upload" [value]="licenseOptions.UPLOAD" class="tw-mt-6 tw-block">
<bit-label>{{ "manualUpload" | i18n }}</bit-label>
<bit-hint>
{{ "manualUploadDesc" | i18n }}
</bit-hint>
</bit-radio-button>
<ng-container *ngIf="updateMethod === licenseOptions.UPLOAD">
<h3 class="tw-font-semibold">{{ "uploadLicense" | i18n }}</h3>
<app-update-license
[organizationId]="organizationId"
[showCancel]="false"
(onUpdated)="licenseUploaded()"
></app-update-license>
</ng-container>
</bit-radio-group>
</form>
</ng-container>

View File

@ -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<BillingSyncConfigApi>;
private destroy$ = new Subject<void>();
set existingBillingSyncConnection(value: OrganizationConnectionResponse<BillingSyncConfigApi>) {
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<BillingSyncKeyModalData> = {
data: {
entityId: this.organizationId,
existingConnectionId: this.existingBillingSyncConnection?.id,
billingSyncKey: this.existingBillingSyncConnection?.config?.billingSyncKey,
setParentConnection: (connection: OrganizationConnectionResponse<BillingSyncConfigApi>) => {
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;
}
}

View File

@ -1,313 +0,0 @@
<div class="page-header">
<h1>
{{ "subscription" | i18n }}
<small *ngIf="firstLoaded && loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</small>
</h1>
</div>
<ng-container *ngIf="!firstLoaded && loading">
<i class="bwi bwi-spinner bwi-spin text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="firstLoaded && !userOrg.canManageBilling">
<div class="tw-flex tw-flex-col tw-items-center tw-text-info">
<bit-icon [icon]="subscriptionHiddenIcon"></bit-icon>
<p class="tw-font-bold">{{ "billingManagedByProvider" | i18n: this.userOrg.providerName }}</p>
<p>{{ "billingContactProviderForAssistance" | i18n }}</p>
</div>
</ng-container>
<ng-container *ngIf="sub">
<app-callout
type="warning"
title="{{ 'canceled' | i18n }}"
*ngIf="subscription && subscription.cancelled"
>
{{ "subscriptionCanceled" | i18n }}</app-callout
>
<app-callout
type="warning"
title="{{ 'pendingCancellation' | i18n }}"
*ngIf="subscriptionMarkedForCancel"
>
<p>{{ "subscriptionPendingCanceled" | i18n }}</p>
<button
#reinstateBtn
type="button"
class="btn btn-outline-secondary btn-submit"
(click)="reinstate()"
[appApiAction]="reinstatePromise"
[disabled]="$any(reinstateBtn).loading"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "reinstateSubscription" | i18n }}</span>
</button>
</app-callout>
<ng-container *ngIf="!selfHosted">
<div class="row">
<div class="col-4">
<dl>
<dt>{{ "billingPlan" | i18n }}</dt>
<dd>{{ sub.plan.name }}</dd>
<ng-container *ngIf="subscription">
<dt>{{ "status" | i18n }}</dt>
<dd>
<span class="text-capitalize">{{
isSponsoredSubscription ? "sponsored" : subscription.status || "-"
}}</span>
<span bitBadge badgeType="warning" *ngIf="subscriptionMarkedForCancel">{{
"pendingCancellation" | i18n
}}</span>
</dd>
<dt>{{ "nextCharge" | i18n }}</dt>
<dd>
{{
nextInvoice
? (nextInvoice.date | date: "mediumDate") +
", " +
(nextInvoice.amount | currency: "$")
: "-"
}}
</dd>
</ng-container>
</dl>
</div>
<div class="col-8" *ngIf="subscription">
<strong class="d-block mb-1">{{ "details" | i18n }}</strong>
<table class="table">
<tbody>
<tr *ngFor="let i of subscription.items">
<td>
{{ i.name }} {{ i.quantity > 1 ? "&times;" + i.quantity : "" }} @
{{ i.amount | currency: "$" }}
</td>
<td>{{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}</td>
</tr>
</tbody>
</table>
</div>
<ng-container *ngIf="userOrg?.providerId != null">
<div class="col-sm">
<dl>
<dt>{{ "provider" | i18n }}</dt>
<dd>{{ "yourProviderIs" | i18n: userOrg.providerName }}</dd>
</dl>
</div>
</ng-container>
</div>
<ng-container>
<button
type="button"
class="btn btn-outline-secondary"
(click)="changePlan()"
*ngIf="showChangePlanButton"
>
{{ "changeBillingPlan" | i18n }}
</button>
<app-change-plan
[organizationId]="organizationId"
(onChanged)="closeChangePlan()"
(onCanceled)="closeChangePlan()"
*ngIf="showChangePlan"
></app-change-plan>
</ng-container>
<h2 class="spaced-header">{{ "manageSubscription" | i18n }}</h2>
<p class="mb-4">{{ subscriptionDesc }}</p>
<ng-container
*ngIf="
subscription && canAdjustSeats && !subscription.cancelled && !subscriptionMarkedForCancel
"
>
<div class="mt-3">
<app-adjust-subscription
[seatPrice]="seatPrice"
[organizationId]="organizationId"
[interval]="billingInterval"
[currentSeatCount]="seats"
[maxAutoscaleSeats]="maxAutoscaleSeats"
(onAdjusted)="subscriptionAdjusted()"
>
</app-adjust-subscription>
</div>
</ng-container>
<button
#removeSponsorshipBtn
type="button"
class="btn btn-outline-danger btn-submit"
(click)="removeSponsorship()"
[appApiAction]="removeSponsorshipPromise"
[disabled]="$any(removeSponsorshipBtn).loading"
*ngIf="isSponsoredSubscription"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "removeSponsorship" | i18n }}</span>
</button>
<h2 class="spaced-header">{{ "storage" | i18n }}</h2>
<p>{{ "subscriptionStorage" | i18n: sub.maxStorageGb || 0:sub.storageName || "0 MB" }}</p>
<div class="progress">
<div
class="progress-bar bg-success"
role="progressbar"
[ngStyle]="{ width: storageProgressWidth + '%' }"
[attr.aria-valuenow]="storagePercentage"
aria-valuemin="0"
aria-valuemax="100"
>
{{ storagePercentage / 100 | percent }}
</div>
</div>
<ng-container *ngIf="subscription && !subscription.cancelled && !subscriptionMarkedForCancel">
<div class="mt-3">
<div class="d-flex" *ngIf="!showAdjustStorage">
<button type="button" class="btn btn-outline-secondary" (click)="adjustStorage(true)">
{{ "addStorage" | i18n }}
</button>
<button
type="button"
class="btn btn-outline-secondary ml-1"
(click)="adjustStorage(false)"
>
{{ "removeStorage" | i18n }}
</button>
</div>
<app-adjust-storage
[storageGbPrice]="storageGbPrice"
[add]="adjustStorageAdd"
[organizationId]="organizationId"
[interval]="billingInterval"
(onAdjusted)="closeStorage(true)"
(onCanceled)="closeStorage(false)"
*ngIf="showAdjustStorage"
></app-adjust-storage>
</div>
</ng-container>
<!--Switch to i18n-->
<h2 class="spaced-header">{{ "selfHostingTitle" | i18n }}</h2>
<p class="mb-4">
{{ "selfHostingEnterpriseOrganizationSectionCopy" | i18n }}
</p>
<div class="d-flex">
<button
type="button"
class="btn btn-outline-secondary"
(click)="downloadLicense()"
*ngIf="canDownloadLicense"
[disabled]="showDownloadLicense"
>
{{ "downloadLicense" | i18n }}
</button>
<button
type="button"
class="btn btn-outline-secondary ml-1"
(click)="manageBillingSync()"
*ngIf="canManageBillingSync"
>
{{ (hasBillingSyncToken ? "manageBillingSync" : "setUpBillingSync") | i18n }}
</button>
</div>
<div class="mt-3" *ngIf="showDownloadLicense">
<app-download-license
[organizationId]="organizationId"
(onDownloaded)="closeDownloadLicense()"
(onCanceled)="closeDownloadLicense()"
></app-download-license>
</div>
<h2 class="spaced-header">{{ "additionalOptions" | i18n }}</h2>
<p class="mb-4">
{{ "additionalOptionsDesc" | i18n }}
</p>
<div class="d-flex">
<button
#cancelBtn
type="button"
class="btn btn-outline-danger btn-submit ml-1"
(click)="cancel()"
[appApiAction]="cancelPromise"
[disabled]="$any(cancelBtn).loading"
*ngIf="subscription && !subscription.cancelled && !subscriptionMarkedForCancel"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "cancelSubscription" | i18n }}</span>
</button>
</div>
</ng-container>
<ng-container *ngIf="selfHosted">
<dl>
<dt>{{ "billingPlan" | i18n }}</dt>
<dd>{{ sub.plan.name }}</dd>
<dt>{{ "expiration" | i18n }}</dt>
<dd *ngIf="sub.expiration">
{{ sub.expiration | date: "mediumDate" }}
<span *ngIf="isExpired" class="text-danger ml-2">
<i class="bwi bwi-exclamation-triangle" aria-hidden="true"></i>
{{ "licenseIsExpired" | i18n }}
</span>
</dd>
<dd *ngIf="!sub.expiration">{{ "neverExpires" | i18n }}</dd>
</dl>
<div>
<button type="button" class="btn btn-outline-secondary" (click)="updateLicense()">
{{ "updateLicense" | i18n }}
</button>
<a
href="https://vault.bitwarden.com"
target="_blank"
rel="noopener"
class="btn btn-outline-secondary"
>
{{ "manageSubscription" | i18n }}
</a>
</div>
<div class="card mt-3" *ngIf="showUpdateLicense">
<div class="card-body">
<button
type="button"
class="close"
appA11yTitle="{{ 'cancel' | i18n }}"
(click)="closeUpdateLicense(false)"
>
<span aria-hidden="true">&times;</span>
</button>
<h3 class="card-body-header">{{ "updateLicense" | i18n }}</h3>
<app-update-license
[organizationId]="organizationId"
(onUpdated)="closeUpdateLicense(true)"
(onCanceled)="closeUpdateLicense(false)"
></app-update-license>
</div>
</div>
<div *ngIf="showBillingSyncKey">
<h2 class="mt-5">
{{ "billingSync" | i18n }}
</h2>
<p>
{{ "billingSyncDesc" | i18n }}
</p>
<button
type="button"
class="btn btn-outline-secondary"
(click)="manageBillingSyncSelfHosted()"
>
{{ "manageBillingSync" | i18n }}
</button>
<small class="form-text text-muted" *ngIf="billingSyncSetUp">
{{ "lastSync" | i18n }}:
<span *ngIf="userOrg.familySponsorshipLastSyncDate != null">
{{ userOrg.familySponsorshipLastSyncDate | date: "medium" }}
</span>
<span *ngIf="userOrg.familySponsorshipLastSyncDate == null">
{{ "never" | i18n | lowercase }}
</span>
</small>
</div>
</ng-container>
</ng-container>
<ng-template #setupBillingSyncTemplate></ng-template>
<ng-template #rotateBillingSyncKeyTemplate></ng-template>

View File

@ -1,6 +1,8 @@
import { Component, Input } from "@angular/core";
import { svgIcon } from "@bitwarden/components";
export const SubscriptionHiddenIcon = svgIcon`
const SubscriptionHiddenIcon = svgIcon`
<svg width="216" height="231" viewBox="0 0 216 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M133.356 85.6618C133.136 85.43 132.871 85.2457 132.577 85.1198C132.283 84.9939 131.968 84.93 131.648 84.9318H87.8482C87.5289 84.93 87.2113 84.9939 86.9175 85.1198C86.6237 85.2457 86.359 85.43 86.14 85.6618C85.9083 85.8808 85.7239 86.1473 85.598 86.4411C85.4721 86.7349 85.4082 87.0506 85.41 87.37V116.57C85.4192 118.793 85.8499 120.994 86.6802 123.056C87.4705 125.091 88.5326 127.011 89.8375 128.761C91.1789 130.515 92.6808 132.137 94.3233 133.612C95.8472 135.01 97.4532 136.318 99.1304 137.528C100.59 138.565 102.123 139.547 103.729 140.474C105.335 141.401 106.469 142.027 107.131 142.354C107.799 142.682 108.339 142.941 108.741 143.113C109.055 143.264 109.4 143.339 109.748 143.332C110.091 143.337 110.431 143.257 110.737 143.102C111.146 142.923 111.679 142.671 112.354 142.343C113.03 142.014 114.179 141.386 115.756 140.463C117.333 139.539 118.884 138.554 120.355 137.517C122.034 136.306 123.642 134.999 125.169 133.601C126.814 132.128 128.316 130.504 129.655 128.75C130.958 126.998 132.021 125.08 132.813 123.045C133.645 120.983 134.075 118.782 134.083 116.559V87.3591C134.085 87.0415 134.021 86.7276 133.895 86.4356C133.769 86.1436 133.586 85.8808 133.356 85.6618ZM127.71 116.836C127.71 127.421 109.748 136.514 109.748 136.514V91.1879H127.71V116.836Z" fill="rgb(var(--color-secondary-700))"/>
<path d="M24.6216 122.3C24.7144 123.4 25.6819 124.217 26.7825 124.125C27.8832 124.032 28.7002 123.064 28.6074 121.964L24.6216 122.3ZM151.501 45.7445C152.57 45.4679 153.213 44.3768 152.936 43.3074L148.429 25.8809C148.152 24.8115 147.061 24.1688 145.992 24.4454C144.922 24.722 144.28 25.8131 144.556 26.8825L148.563 42.3728L133.073 46.3793C132.003 46.6559 131.361 47.747 131.637 48.8164C131.914 49.8858 133.005 50.5285 134.074 50.2519L151.501 45.7445ZM28.6074 121.964C26.6788 99.0874 34.4658 75.5543 51.9661 58.054L49.1377 55.2256C30.7695 73.5938 22.5982 98.2999 24.6216 122.3L28.6074 121.964ZM51.9661 58.054C78.5404 31.4797 119.036 27.3026 149.985 45.5315L152.015 42.0849C119.534 22.9534 77.0327 27.3306 49.1377 55.2256L51.9661 58.054Z" fill="rgb(var(--color-secondary-700))"/>
@ -22,3 +24,16 @@ export const SubscriptionHiddenIcon = svgIcon`
<path d="M50.9935 181.5H50.9126H50.8318H50.7512H50.6708H50.5905H50.5104H50.4304H50.3506H50.2709H50.1914H50.1121H50.0329H49.9538H49.8749H49.7962H49.7176H49.6392H49.5609H49.4828H49.4048H49.3269H49.2492H49.1717H49.0943H49.017H48.9399H48.8629H48.7861H48.7094H48.6329H48.5565H48.4802H48.4041H48.3281H48.2523H48.1766H48.101H48.0256H47.9503H47.8751H47.8001H47.7252H47.6504H47.5758H47.5013H47.4269H47.3527H47.2786H47.2046H47.1308H47.0571H46.9835H46.91H46.8367H46.7635H46.6904H46.6175H46.5446H46.4719H46.3993H46.3269H46.2545H46.1823H46.1102H46.0382H45.9664H45.8946H45.823H45.7515H45.6801H45.6088H45.5376H45.4666H45.3956H45.3248H45.2541H45.1835H45.113H45.0426H44.9724H44.9022H44.8322H44.7622H44.6924H44.6226H44.553H44.4835H44.4141H44.3448H44.2756H44.2065H44.1375H44.0686H43.9998H43.9311H43.8625H43.794H43.7256H43.6572H43.589H43.5209H43.4529H43.385H43.3172H43.2494H43.1818H43.1142H43.0468H42.9794H42.9121H42.8449H42.7779H42.7108H42.6439H42.5771H42.5103H42.4437H42.3771H42.3106H42.2442H42.1779H42.1117H42.0455H41.9794H41.9134H41.8475H41.7817H41.7159H41.6502H41.5846H41.5191H41.4537H41.3883H41.323H41.2578H41.1926H41.1275H41.0625H40.9976H40.9328H40.868H40.8032H40.7386H40.674H40.6095H40.5451H40.4807H40.4164H40.3521H40.2879H40.2238H40.1598H40.0958H40.0318H39.968H39.9042H39.8404H39.7767H39.7131H39.6495H39.586H39.5226H39.4592H39.3958H39.3325H39.2693H39.2061H39.143H39.0799H39.0169H38.9539H38.891H38.8281H38.7653H38.7025H38.6398H38.5771H38.5145H38.4519H38.3894H38.3269H38.2645H38.202H38.1397H38.0774H38.0151H37.9528H37.8907H37.8285H37.7664H37.7043H37.6423H37.5803H37.5183H37.4564H37.3945H37.3326H37.2708H37.209H37.1472H37.0855H37.0238H36.9621H36.9005H36.8389H36.7773H36.7157H36.6542H36.5927H36.5312H36.4698H36.4084H36.347H36.2856H36.2243H36.1629H36.1016H36.0404H35.9791H35.9178H35.8566H35.7954H35.7342H35.6731H35.6119H35.5508H35.4896H35.4285H35.3674H35.3064H35.2453H35.1842H35.1232H35.0622H35.0011H34.9401H34.8791H34.8181H34.7571H34.6962H34.6352H34.5742H34.5133H34.4523H34.3913H34.3304H34.2694H34.2085H34.1475H34.0866H34.0257H33.9647H33.9038H33.8428H33.7819H33.7209H33.6599H33.599H33.538H33.477H33.416H33.3551H33.2941H33.2331H33.172H33.111H33.05H32.9889H32.9279H32.8668H32.8057H32.7446H32.6835H32.6224H32.5612H32.5001H32.4389H32.3777H32.3165H32.2553H32.194H32.1328H32.0715H32.0102H31.9489H31.8875H31.8261H31.7647H31.7033H31.6418H31.5804H31.5189H31.4573H31.3958H31.3342H31.2726H31.2109H31.1493H31.0876H31.0258H30.9641H30.9023H30.8404H30.7786H30.7166H30.6547H30.5927H30.5307H30.4687H30.4066H30.3445H30.2823H30.2201H30.1578H30.0956H30.0332H29.9709H29.9084H29.846H29.7835H29.7209H29.6583H29.5957H29.533H29.4703H29.4075H29.3447H29.2818H29.2189H29.1559H29.0929H29.0298H28.9666H28.9034H28.8402H28.7769H28.7135H28.6501H28.5867H28.5231H28.4596H28.3959H28.3322H28.2685H28.2046H28.1408H28.0768H28.0128H27.9487H27.8846H27.8204H27.7562H27.6918H27.6274H27.563H27.4985H27.4339H27.3692H27.3045H27.2397H27.1748H27.1098H27.0448H26.9797H26.9146H26.8493H26.784H26.7186H26.6532H26.5876H26.522H26.4563H26.3905H26.3247H26.2588H26.1928H26.1267H26.0605H25.9942H25.9279H25.8615H25.795H25.7284H25.6617H25.5949H25.5281H25.4611H25.3941H25.327H25.2598H25.1925H25.1251H25.0576H24.9901H24.9224H24.8547H24.7868H24.7189H24.6508H24.5827H24.5145H24.4461H24.3777H24.3092H24.2406H24.1719H24.103H24.0341H23.9651H23.896H23.8267H23.7574H23.688H23.6184H23.5488H23.479H23.4092H23.3392H23.2691H23.199H23.1287H23.0583H22.9878H22.9171H22.8464H22.7755H22.7046H22.6335H22.5623H22.491H22.4196H22.3481H22.2764H22.2046H22.1328H22.0607H21.9886H21.9164H21.844H21.7715H21.6989H21.6262H21.5533H21.4803H21.4072H21.334H21.2606H21.1872H21.1135H21.0398H20.9659H20.8919H20.8178H20.7436H20.6692H20.5947H20.52H20.4452H20.3703H20.2952H20.2201H20.1447H20.0693H19.9937H19.9179H19.8421H19.7661H19.6899H19.6136H19.5372H19.4606H19.3839H19.3071H19.2301H19.1529H19.0756H18.9982H18.9206H18.8429H18.765H18.687H18.6088H18.5305H18.452H18.3734H18.2946H18.2157H18.1367H18.0574H17.978H17.8985H17.8188H17.739H17.659H17.5788H17.4985H17.418H17.3374H17.2566H17.1757H17.0946H17.0133H16.9318H16.8503H16.7685H16.6866H16.6045H16.5222H16.4398H16.3572H16.2745H16.1915C16.045 181.5 15.9628 181.465 15.9092 181.432C15.8479 181.394 15.772 181.324 15.6978 181.198C15.5354 180.922 15.4617 180.509 15.5193 180.153C16.9196 171.496 24.6325 164.823 33.9925 164.823C43.3524 164.823 51.0654 171.496 52.4657 180.153C52.574 180.823 52.4052 181.064 52.319 181.152C52.1962 181.279 51.8413 181.5 50.9935 181.5Z" fill="rgb(var(--color-background))" stroke="rgb(var(--color-secondary-700))" stroke-width="3"/>
</svg>
`;
@Component({
selector: "app-org-subscription-hidden",
template: `<div class="tw-flex tw-flex-col tw-items-center tw-text-info">
<bit-icon [icon]="subscriptionHiddenIcon"></bit-icon>
<p class="tw-font-bold">{{ "billingManagedByProvider" | i18n: providerName }}</p>
<p>{{ "billingContactProviderForAssistance" | i18n }}</p>
</div>`,
})
export class SubscriptionHiddenComponent {
@Input() providerName: string;
subscriptionHiddenIcon = SubscriptionHiddenIcon;
}

View File

@ -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<BillingSyncConfigApi>) => void;
}
@Component({
selector: "app-billing-sync-key",
templateUrl: "billing-sync-key.component.html",
@ -20,7 +29,17 @@ export class BillingSyncKeyComponent {
formPromise: Promise<OrganizationConnectionResponse<BillingSyncConfigApi>> | Promise<void>;
constructor(private apiService: ApiService, private logService: LogService) {}
constructor(
private apiService: ApiService,
private logService: LogService,
protected modalRef: ModalRef,
config: ModalConfig<BillingSyncKeyModalData>
) {
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();
}
}

View File

@ -14,7 +14,7 @@
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "submit" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" (click)="cancel()">
<button *ngIf="showCancel" type="button" class="btn btn-outline-secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</form>

View File

@ -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();

View File

@ -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

View File

@ -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"
},

View File

@ -58,4 +58,5 @@ export class OrganizationApiServiceAbstraction {
updateKeys: (id: string, request: OrganizationKeysRequest) => Promise<OrganizationKeysResponse>;
getSso: (id: string) => Promise<OrganizationSsoResponse>;
updateSso: (id: string, request: OrganizationSsoRequest) => Promise<OrganizationSsoResponse>;
selfHostedSyncLicense: (id: string) => Promise<void>;
}

View File

@ -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);
}
}
}

View File

@ -87,7 +87,13 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
}
async createLicense(data: FormData): Promise<OrganizationResponse> {
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<void> {
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<void> {
@ -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
);
}
}

View File

@ -98,9 +98,15 @@ const FullExampleTemplate: Story = (args) => ({
<bit-radio-group formControlName="updates">
<bit-label>Subscribe to updates?</bit-label>
<bit-radio-button value="yes">Yes</bit-radio-button>
<bit-radio-button value="no">No</bit-radio-button>
<bit-radio-button value="later">Decide later</bit-radio-button>
<bit-radio-button value="yes">
<bit-label>Yes</bit-label>
</bit-radio-button>
<bit-radio-button value="no">
<bit-label>No</bit-label>
</bit-radio-button>
<bit-radio-button value="later">
<bit-label>Decide later</bit-label>
</bit-radio-button>
</bit-radio-group>
<button type="submit" bitButton buttonType="primary">Submit</button>

View File

@ -10,5 +10,6 @@
(change)="onInputChange()"
(blur)="onBlur()"
/>
<bit-label><ng-content></ng-content></bit-label>
<ng-content select="bit-label" ngProjectAs="bit-label"></ng-content>
<ng-content select="bit-hint" ngProjectAs="bit-hint"></ng-content>
</bit-form-control>

View File

@ -72,7 +72,7 @@ class MockedButtonGroupComponent implements Partial<RadioGroupComponent> {
@Component({
selector: "test-app",
template: ` <bit-radio-button [value]="value">Element</bit-radio-button>`,
template: ` <bit-radio-button [value]="value"><bit-label>Element</bit-label></bit-radio-button>`,
})
class TestApp {
value?: string;

View File

@ -12,21 +12,26 @@ const template = `
<form [formGroup]="formObj">
<bit-radio-group formControlName="radio" aria-label="Example radio group">
<bit-label *ngIf="label">Group of radio buttons</bit-label>
<bit-radio-button [value]="TestValue.First" id="radio-first">First</bit-radio-button>
<bit-radio-button [value]="TestValue.Second" id="radio-second">Second</bit-radio-button>
<bit-radio-button [value]="TestValue.Third" [disabled]="optionDisabled" id="radio-third">Third</bit-radio-button>
<bit-radio-button *ngFor="let option of TestValue | keyvalue" [ngClass]="{ 'tw-block': blockLayout }"
[value]="option.value" id="radio-{{option.key}}" [disabled]="optionDisabled?.includes(option.value)">
<bit-label>{{ option.key }}</bit-label>
<bit-hint *ngIf="blockLayout">This is a hint for the {{option.key}} option</bit-hint>
</bit-radio-button>
</bit-radio-group>
</form>`;
enum TestValue {
First,
Second,
Third,
}
const TestValue = {
First: 0,
Second: 1,
Third: 2,
};
const reverseObject = (obj: Record<any, any>) =>
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<ExampleComponent> = (args: ExampleComponent) => ({
const storyTemplate = `<app-example [selected]="selected" [groupDisabled]="groupDisabled" [optionDisabled]="optionDisabled" [label]="label" [layout]="layout"></app-example>`;
const InlineTemplate: Story<ExampleComponent> = (args: ExampleComponent) => ({
props: args,
template: `<app-example [selected]="selected" [groupDisabled]="groupDisabled" [optionDisabled]="optionDisabled" [label]="label"></app-example>`,
template: storyTemplate,
});
export const Default = DefaultTemplate.bind({});
export const Inline = InlineTemplate.bind({});
Inline.args = {
layout: "inline",
};
const BlockTemplate: Story<ExampleComponent> = (args: ExampleComponent) => ({
props: args,
template: storyTemplate,
});
export const Block = BlockTemplate.bind({});
Block.args = {
layout: "block",
};