1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-07-04 11:55:58 +02:00

Merge branch 'main' into autofill/pm-6426-create-alarms-manager-and-update-usage-of-long-lived-timeouts-rework

This commit is contained in:
Cesar Gonzalez 2024-05-09 10:36:34 -05:00 committed by GitHub
commit 332336ae2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 308 additions and 274 deletions

View File

@ -1,6 +1,6 @@
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { concatMap, takeUntil, map, lastValueFrom } from "rxjs"; import { concatMap, takeUntil, map } from "rxjs";
import { tap } from "rxjs/operators"; import { tap } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@ -16,7 +16,6 @@ import { DialogService } from "@bitwarden/components";
import { TwoFactorDuoComponent } from "../../../auth/settings/two-factor-duo.component"; import { TwoFactorDuoComponent } from "../../../auth/settings/two-factor-duo.component";
import { TwoFactorSetupComponent as BaseTwoFactorSetupComponent } from "../../../auth/settings/two-factor-setup.component"; import { TwoFactorSetupComponent as BaseTwoFactorSetupComponent } from "../../../auth/settings/two-factor-setup.component";
import { TwoFactorVerifyComponent } from "../../../auth/settings/two-factor-verify.component";
@Component({ @Component({
selector: "app-two-factor-setup", selector: "app-two-factor-setup",
@ -66,17 +65,17 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent {
async manage(type: TwoFactorProviderType) { async manage(type: TwoFactorProviderType) {
switch (type) { switch (type) {
case TwoFactorProviderType.OrganizationDuo: { case TwoFactorProviderType.OrganizationDuo: {
const twoFactorVerifyDialogRef = TwoFactorVerifyComponent.open(this.dialogService, { const result: AuthResponse<TwoFactorDuoResponse> = await this.callTwoFactorVerifyDialog(
data: { type: type, organizationId: this.organizationId }, TwoFactorProviderType.OrganizationDuo,
});
const result: AuthResponse<TwoFactorDuoResponse> = await lastValueFrom(
twoFactorVerifyDialogRef.closed,
); );
if (!result) { if (!result) {
return; return;
} }
const duoComp = await this.openModal(this.duoModalRef, TwoFactorDuoComponent); const duoComp = await this.openModal(this.duoModalRef, TwoFactorDuoComponent);
duoComp.type = TwoFactorProviderType.OrganizationDuo;
duoComp.organizationId = this.organizationId;
duoComp.auth(result); duoComp.auth(result);
duoComp.onUpdated.pipe(takeUntil(this.destroy$)).subscribe((enabled: boolean) => { duoComp.onUpdated.pipe(takeUntil(this.destroy$)).subscribe((enabled: boolean) => {
this.updateStatus(enabled, TwoFactorProviderType.OrganizationDuo); this.updateStatus(enabled, TwoFactorProviderType.OrganizationDuo);

View File

@ -1,129 +1,143 @@
<div *ngIf="selfHosted" class="page-header"> <bit-section>
<h1>{{ "subscription" | i18n }}</h1> <h2 *ngIf="!selfHosted" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
</div> <bit-callout
<div *ngIf="!selfHosted" class="tabbed-header"> type="info"
<h1>{{ "goPremium" | i18n }}</h1> *ngIf="canAccessPremium$ | async"
</div> title="{{ 'youHavePremiumAccess' | i18n }}"
<bit-callout icon="bwi bwi-star-f"
type="info"
*ngIf="canAccessPremium$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p class="text-lg" [ngClass]="{ 'mb-0': !selfHosted }">
{{
"premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<a routerLink="/create-organization" [queryParams]="{ plan: 'families' }">{{
"bitwardenFamiliesPlan" | i18n
}}</a>
</p>
<a
bitButton
href="{{ this.cloudWebVaultUrl }}/#/settings/subscription/premium"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="selfHosted"
> >
{{ "purchasePremium" | i18n }} {{ "alreadyPremiumFromOrg" | i18n }}
</a> </bit-callout>
</bit-callout> <bit-callout type="success">
<ng-container *ngIf="selfHosted"> <p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<p>{{ "uploadLicenseFilePremium" | i18n }}</p> <ul class="bwi-ul">
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate> <li>
<div class="form-group"> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
<label for="file">{{ "licenseFile" | i18n }}</label> {{ "premiumSignUpStorage" | i18n }}
<input type="file" id="file" class="form-control-file" name="file" required /> </li>
<small class="form-text text-muted">{{ <li>
"licenseFileDesc" | i18n: "bitwarden_premium_license.json" <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
}}</small> {{ "premiumSignUpTwoStepOptions" | i18n }}
</div> </li>
<button type="submit" buttonType="primary" bitButton [loading]="form.loading"> <li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !selfHosted }">
{{
"premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>{{ "bitwardenFamiliesPlan" | i18n }}</a
>
</p>
<a
bitButton
href="{{ this.cloudWebVaultUrl }}/#/settings/subscription/premium"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="selfHosted"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="selfHosted">
<p bitTypography="body1">{{ "uploadLicenseFilePremium" | i18n }}</p>
<form [formGroup]="licenseForm" [bitSubmit]="submit">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div>
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{ this.licenseFile ? this.licenseFile.name : ("noFileChosen" | i18n) }}
</div>
<input
bitInput
#fileSelector
type="file"
formControlName="file"
(change)="setSelectedFile($event)"
hidden
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }}</bit-hint>
</bit-form-field>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }} {{ "submit" | i18n }}
</button> </button>
</form> </form>
</ng-container> </bit-section>
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate *ngIf="!selfHosted"> <form [formGroup]="addonForm" [bitSubmit]="submit" *ngIf="!selfHosted">
<h2 class="mt-5">{{ "addons" | i18n }}</h2> <bit-section>
<div class="row"> <h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="form-group col-6"> <div class="tw-grid tw-grid-cols-12 tw-gap-4">
<label for="additionalStorage">{{ "additionalStorageGb" | i18n }}</label> <bit-form-field class="tw-col-span-6">
<input <bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
id="additionalStorage" <input
class="form-control" bitInput
type="number" formControlName="additionalStorage"
name="AdditionalStorageGb" type="number"
[(ngModel)]="additionalStorage" step="1"
min="0" placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
max="99" />
step="1" <bit-hint>{{
placeholder="{{ 'additionalStorageGbDesc' | i18n }}" "additionalStorageIntervalDesc"
/> | i18n: "1 GB" : (storageGbPrice | currency: "$") : ("year" | i18n)
<small class="text-muted form-text">{{ }}</bit-hint>
"additionalStorageIntervalDesc" </bit-form-field>
| i18n: "1 GB" : (storageGbPrice | currency: "$") : ("year" | i18n)
}}</small>
</div> </div>
</div> </bit-section>
<h2 class="spaced-header">{{ "summary" | i18n }}</h2> <bit-section>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br /> <h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "additionalStorageGb" | i18n }}: {{ additionalStorage || 0 }} GB &times; {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ storageGbPrice | currency: "$" }} = {{ "additionalStorageGb" | i18n }}: {{ additionalStorage || 0 }} GB &times;
{{ additionalStorageTotal | currency: "$" }} {{ storageGbPrice | currency: "$" }} =
<hr class="my-3" /> {{ additionalStorageTotal | currency: "$" }}
<h2 class="spaced-header mb-4">{{ "paymentInformation" | i18n }}</h2> <hr class="tw-my-3" />
<app-payment [hideBank]="true"></app-payment> </bit-section>
<app-tax-info></app-tax-info> <bit-section>
<div id="price" class="my-4"> <h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<div class="text-muted text-sm"> <app-payment [hideBank]="true"></app-payment>
{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }} <app-tax-info></app-tax-info>
<br /> <div id="price" class="tw-my-4">
<ng-container> <div class="tw-text-muted tw-text-sm">
{{ "estimatedTax" | i18n }}: {{ taxCharges | currency: "USD $" }} {{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}
</ng-container> <br />
<ng-container>
{{ "estimatedTax" | i18n }}: {{ taxCharges | currency: "USD $" }}
</ng-container>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
</div> </div>
<hr class="my-1 col-3 ml-0" /> <p bitTypography="body2">{{ "paymentChargedAnnually" | i18n }}</p>
<p class="text-lg"> <button type="submit" bitButton bitFormButton>
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }} {{ "submit" | i18n }}
</p> </button>
</div> </bit-section>
<small class="text-muted font-italic">{{ "paymentChargedAnnually" | i18n }}</small>
<button type="submit" bitButton [loading]="form.loading">
{{ "submit" | i18n }}
</button>
</form> </form>

View File

@ -1,4 +1,5 @@
import { Component, OnInit, ViewChild } from "@angular/core"; import { Component, OnInit, ViewChild } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { firstValueFrom, Observable } from "rxjs"; import { firstValueFrom, Observable } from "rxjs";
@ -7,7 +8,6 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
@ -26,11 +26,16 @@ export class PremiumComponent implements OnInit {
premiumPrice = 10; premiumPrice = 10;
familyPlanMaxUserCount = 6; familyPlanMaxUserCount = 6;
storageGbPrice = 4; storageGbPrice = 4;
additionalStorage = 0;
cloudWebVaultUrl: string; cloudWebVaultUrl: string;
licenseFile: File = null;
formPromise: Promise<any>; formPromise: Promise<any>;
protected licenseForm = new FormGroup({
file: new FormControl(null, [Validators.required]),
});
protected addonForm = new FormGroup({
additionalStorage: new FormControl(0, [Validators.max(99), Validators.min(0)]),
});
constructor( constructor(
private apiService: ApiService, private apiService: ApiService,
private i18nService: I18nService, private i18nService: I18nService,
@ -39,14 +44,17 @@ export class PremiumComponent implements OnInit {
private router: Router, private router: Router,
private messagingService: MessagingService, private messagingService: MessagingService,
private syncService: SyncService, private syncService: SyncService,
private logService: LogService,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private billingAccountProfileStateService: BillingAccountProfileStateService, private billingAccountProfileStateService: BillingAccountProfileStateService,
) { ) {
this.selfHosted = platformUtilsService.isSelfHost(); this.selfHosted = platformUtilsService.isSelfHost();
this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$; this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$;
} }
protected setSelectedFile(event: Event) {
const fileInputEl = <HTMLInputElement>event.target;
const file: File = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null;
this.licenseFile = file;
}
async ngOnInit() { async ngOnInit() {
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$); this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
if (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumPersonally$)) { if (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumPersonally$)) {
@ -56,13 +64,11 @@ export class PremiumComponent implements OnInit {
return; return;
} }
} }
submit = async () => {
async submit() { this.licenseForm.markAllAsTouched();
let files: FileList = null; this.addonForm.markAllAsTouched();
if (this.selfHosted) { if (this.selfHosted) {
const fileEl = document.getElementById("file") as HTMLInputElement; if (this.licenseFile == null) {
files = fileEl.files;
if (files == null || files.length === 0) {
this.platformUtilsService.showToast( this.platformUtilsService.showToast(
"error", "error",
this.i18nService.t("errorOccurred"), this.i18nService.t("errorOccurred"),
@ -72,53 +78,48 @@ export class PremiumComponent implements OnInit {
} }
} }
try { if (this.selfHosted) {
if (this.selfHosted) { // eslint-disable-next-line @typescript-eslint/no-misused-promises
// eslint-disable-next-line @typescript-eslint/no-misused-promises if (!this.tokenService.getEmailVerified()) {
if (!this.tokenService.getEmailVerified()) { this.platformUtilsService.showToast(
this.platformUtilsService.showToast( "error",
"error", this.i18nService.t("errorOccurred"),
this.i18nService.t("errorOccurred"), this.i18nService.t("verifyEmailFirst"),
this.i18nService.t("verifyEmailFirst"), );
); return;
return;
}
const fd = new FormData();
fd.append("license", files[0]);
this.formPromise = this.apiService.postAccountLicense(fd).then(() => {
return this.finalizePremium();
});
} else {
this.formPromise = this.paymentComponent
.createPaymentToken()
.then((result) => {
const fd = new FormData();
fd.append("paymentMethodType", result[1].toString());
if (result[0] != null) {
fd.append("paymentToken", result[0]);
}
fd.append("additionalStorageGb", (this.additionalStorage || 0).toString());
fd.append("country", this.taxInfoComponent.taxInfo.country);
fd.append("postalCode", this.taxInfoComponent.taxInfo.postalCode);
return this.apiService.postPremium(fd);
})
.then((paymentResponse) => {
if (!paymentResponse.success && paymentResponse.paymentIntentClientSecret != null) {
return this.paymentComponent.handleStripeCardPayment(
paymentResponse.paymentIntentClientSecret,
() => this.finalizePremium(),
);
} else {
return this.finalizePremium();
}
});
} }
await this.formPromise;
} catch (e) { const fd = new FormData();
this.logService.error(e); fd.append("license", this.licenseFile);
await this.apiService.postAccountLicense(fd).then(() => {
return this.finalizePremium();
});
} else {
await this.paymentComponent
.createPaymentToken()
.then((result) => {
const fd = new FormData();
fd.append("paymentMethodType", result[1].toString());
if (result[0] != null) {
fd.append("paymentToken", result[0]);
}
fd.append("additionalStorageGb", (this.additionalStorage || 0).toString());
fd.append("country", this.taxInfoComponent.taxInfo.country);
fd.append("postalCode", this.taxInfoComponent.taxInfo.postalCode);
return this.apiService.postPremium(fd);
})
.then((paymentResponse) => {
if (!paymentResponse.success && paymentResponse.paymentIntentClientSecret != null) {
return this.paymentComponent.handleStripeCardPayment(
paymentResponse.paymentIntentClientSecret,
() => this.finalizePremium(),
);
} else {
return this.finalizePremium();
}
});
} }
} };
async finalizePremium() { async finalizePremium() {
await this.apiService.refreshIdentityToken(); await this.apiService.refreshIdentityToken();
@ -127,6 +128,9 @@ export class PremiumComponent implements OnInit {
await this.router.navigate(["/settings/subscription/user-subscription"]); await this.router.navigate(["/settings/subscription/user-subscription"]);
} }
get additionalStorage(): number {
return this.addonForm.get("additionalStorage").value;
}
get additionalStorageTotal(): number { get additionalStorageTotal(): number {
return this.storageGbPrice * Math.abs(this.additionalStorage || 0); return this.storageGbPrice * Math.abs(this.additionalStorage || 0);
} }

View File

@ -1,65 +1,57 @@
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate> <form [formGroup]="adjustSubscriptionForm" [bitSubmit]="submit">
<div> <div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="row"> <div class="tw-col-span-8">
<div class="form-group col-8"> <bit-form-field>
<label for="newSeatCount">{{ "subscriptionSeats" | i18n }}</label> <bit-label>{{ "subscriptionSeats" | i18n }}</bit-label>
<input <input bitInput formControlName="newSeatCount" type="number" min="0" step="1" />
id="newSeatCount" <bit-hint>
class="form-control"
type="number"
name="NewSeatCount"
[(ngModel)]="newSeatCount"
min="0"
step="1"
required
/>
<small class="d-block text-muted mb-4">
<strong>{{ "total" | i18n }}:</strong> {{ additionalSeatCount || 0 }} &times; <strong>{{ "total" | i18n }}:</strong> {{ additionalSeatCount || 0 }} &times;
{{ seatPrice | currency: "$" }} = {{ adjustedSeatTotal | currency: "$" }} / {{ seatPrice | currency: "$" }} = {{ adjustedSeatTotal | currency: "$" }} /
{{ interval | i18n }} {{ interval | i18n }}</bit-hint
</small> >
</div> </bit-form-field>
</div> </div>
<div class="row mb-4"> </div>
<div class="form-group col-sm"> <div>
<div class="form-check"> <bit-form-control>
<input <input
id="limitSubscription" bitCheckbox
class="form-check-input" formControlName="limitSubscription"
type="checkbox" type="checkbox"
name="LimitSubscription" (change)="limitSubscriptionChanged()"
[(ngModel)]="limitSubscription" />
(change)="limitSubscriptionChanged()" <bit-label>{{ "limitSubscription" | i18n }}</bit-label>
/> <bit-hint> {{ "limitSubscriptionDesc" | i18n }}</bit-hint>
<label for="limitSubscription">{{ "limitSubscription" | i18n }}</label> </bit-form-control>
</div> </div>
<small class="d-block text-muted">{{ "limitSubscriptionDesc" | i18n }}</small> <div
</div> class="tw-grid tw-grid-cols-12 tw-gap-4 tw-mb-4"
</div> [hidden]="!adjustSubscriptionForm.value.limitSubscription"
<div class="row mb-4" [hidden]="!limitSubscription"> >
<div class="form-group col-sm"> <div class="tw-col-span-8">
<label for="maxAutoscaleSeats">{{ "maxSeatLimit" | i18n }}</label> <bit-form-field>
<bit-label>{{ "maxSeatLimit" | i18n }}</bit-label>
<input <input
id="maxAutoscaleSeats" bitInput
class="form-control col-8" formControlName="newMaxSeats"
type="number" type="number"
name="MaxAutoscaleSeats" [min]="
[(ngModel)]="newMaxSeats" adjustSubscriptionForm.value.newSeatCount == null
[min]="newSeatCount == null ? 1 : newSeatCount" ? 1
: adjustSubscriptionForm.value.newSeatCount
"
step="1" step="1"
[required]="limitSubscription"
/> />
<small class="d-block text-muted"> <bit-hint>
<strong>{{ "maxSeatCost" | i18n }}:</strong> {{ additionalMaxSeatCount || 0 }} &times; <strong>{{ "maxSeatCost" | i18n }}:</strong> {{ additionalMaxSeatCount || 0 }} &times;
{{ seatPrice | currency: "$" }} = {{ maxSeatTotal | currency: "$" }} / {{ seatPrice | currency: "$" }} = {{ maxSeatTotal | currency: "$" }} /
{{ interval | i18n }} {{ interval | i18n }}</bit-hint
</small> >
</div> </bit-form-field>
</div> </div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
</div> </div>
<button bitButton buttonType="primary" bitFormButton type="submit">
{{ "save" | i18n }}
</button>
</form> </form>
<app-payment [showMethods]="false"></app-payment> <app-payment [showMethods]="false"></app-payment>

View File

@ -1,77 +1,102 @@
import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/organization-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/organization-subscription-update.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@Component({ @Component({
selector: "app-adjust-subscription", selector: "app-adjust-subscription",
templateUrl: "adjust-subscription.component.html", templateUrl: "adjust-subscription.component.html",
}) })
export class AdjustSubscription { export class AdjustSubscription implements OnInit, OnDestroy {
@Input() organizationId: string; @Input() organizationId: string;
@Input() maxAutoscaleSeats: number; @Input() maxAutoscaleSeats: number;
@Input() currentSeatCount: number; @Input() currentSeatCount: number;
@Input() seatPrice = 0; @Input() seatPrice = 0;
@Input() interval = "year"; @Input() interval = "year";
@Output() onAdjusted = new EventEmitter(); @Output() onAdjusted = new EventEmitter();
private destroy$ = new Subject<void>();
formPromise: Promise<void>; adjustSubscriptionForm = this.formBuilder.group({
limitSubscription: boolean; newSeatCount: [0, [Validators.min(0)]],
newSeatCount: number; limitSubscription: [false],
newMaxSeats: number; newMaxSeats: [0, [Validators.min(0)]],
});
get limitSubscription(): boolean {
return this.adjustSubscriptionForm.value.limitSubscription;
}
constructor( constructor(
private i18nService: I18nService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction, private organizationApiService: OrganizationApiServiceAbstraction,
private formBuilder: FormBuilder,
) {} ) {}
ngOnInit() { ngOnInit() {
this.limitSubscription = this.maxAutoscaleSeats != null; this.adjustSubscriptionForm.patchValue({
this.newSeatCount = this.currentSeatCount; newSeatCount: this.currentSeatCount,
this.newMaxSeats = this.maxAutoscaleSeats; limitSubscription: this.maxAutoscaleSeats != null,
newMaxSeats: this.maxAutoscaleSeats,
});
this.adjustSubscriptionForm
.get("limitSubscription")
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((value: boolean) => {
if (value) {
this.adjustSubscriptionForm
.get("newMaxSeats")
.addValidators([
Validators.min(
this.adjustSubscriptionForm.value.newSeatCount == null
? 1
: this.adjustSubscriptionForm.value.newSeatCount,
),
Validators.required,
]);
}
this.adjustSubscriptionForm.get("newMaxSeats").updateValueAndValidity();
});
} }
async submit() { ngOnDestroy() {
try { this.destroy$.next();
const request = new OrganizationSubscriptionUpdateRequest( this.destroy$.complete();
this.additionalSeatCount, }
this.newMaxSeats, submit = async () => {
); this.adjustSubscriptionForm.markAllAsTouched();
this.formPromise = this.organizationApiService.updatePasswordManagerSeats( if (this.adjustSubscriptionForm.invalid) {
this.organizationId, return;
request,
);
await this.formPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("subscriptionUpdated"),
);
} catch (e) {
this.logService.error(e);
} }
const request = new OrganizationSubscriptionUpdateRequest(
this.additionalSeatCount,
this.adjustSubscriptionForm.value.newMaxSeats,
);
await this.organizationApiService.updatePasswordManagerSeats(this.organizationId, request);
this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated"));
this.onAdjusted.emit(); this.onAdjusted.emit();
} };
limitSubscriptionChanged() { limitSubscriptionChanged() {
if (!this.limitSubscription) { if (!this.adjustSubscriptionForm.value.limitSubscription) {
this.newMaxSeats = null; this.adjustSubscriptionForm.value.newMaxSeats = null;
} }
} }
get additionalSeatCount(): number { get additionalSeatCount(): number {
return this.newSeatCount ? this.newSeatCount - this.currentSeatCount : 0; return this.adjustSubscriptionForm.value.newSeatCount
? this.adjustSubscriptionForm.value.newSeatCount - this.currentSeatCount
: 0;
} }
get additionalMaxSeatCount(): number { get additionalMaxSeatCount(): number {
return this.newMaxSeats ? this.newMaxSeats - this.currentSeatCount : 0; return this.adjustSubscriptionForm.value.newMaxSeats
? this.adjustSubscriptionForm.value.newMaxSeats - this.currentSeatCount
: 0;
} }
get adjustedSeatTotal(): number { get adjustedSeatTotal(): number {