diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html index e52018ab27f..52ae387e8b9 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -4,25 +4,6 @@ [title]="'autofillSuggestions' | i18n" [showRefresh]="showRefresh" (onRefresh)="refreshCurrentTab()" + [description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : null" showAutofillButton > - - - -

- {{ "autofillSuggestions" | i18n }} -

- -
- {{ - "autofillSuggestionsTip" | i18n - }} -
-
diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts index 1b9876759f0..eb8737d5139 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -3,6 +3,7 @@ import { Component } from "@angular/core"; import { combineLatest, map, Observable } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { IconButtonModule, SectionComponent, @@ -45,7 +46,7 @@ export class AutofillVaultListItemsComponent { /** * Observable that determines whether the empty autofill tip should be shown. - * The tip is shown when there are no ciphers to autofill, no filter is applied, and autofill is allowed in + * The tip is shown when there are no login ciphers to autofill, no filter is applied, and autofill is allowed in * the current context (e.g. not in a popout). * @protected */ @@ -54,7 +55,10 @@ export class AutofillVaultListItemsComponent { this.autofillCiphers$, this.vaultPopupItemsService.autofillAllowed$, ]).pipe( - map(([hasFilter, ciphers, canAutoFill]) => !hasFilter && canAutoFill && ciphers.length === 0), + map( + ([hasFilter, ciphers, canAutoFill]) => + !hasFilter && canAutoFill && ciphers.filter((c) => c.type == CipherType.Login).length === 0, + ), ); constructor(private vaultPopupItemsService: VaultPopupItemsService) { diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index c2c345fd757..7b8fdf7a8e5 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -1,4 +1,4 @@ - +

{{ title }} @@ -13,6 +13,9 @@ > {{ ciphers.length }} +
+ {{ description }} +
-

{{ "preferencesDesc" | i18n }}

-
-
-
- - - {{ - "vaultTimeoutPolicyWithActionInEffect" - | i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n) - }} - - - {{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }} - - - {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} - - - - -
-
+

{{ "preferencesDesc" | i18n }}

+ + + + {{ + "vaultTimeoutPolicyWithActionInEffect" + | i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n) + }} + + + {{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }} + + + {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} + + + + -
- -
+ {{ "vaultTimeoutAction" | i18n }} + - - -
-
{{ "lock" | i18n }} + {{ "vaultTimeoutActionLockDesc" | i18n }} + + - - -
-
+ {{ "logOut" | i18n }} + {{ "vaultTimeoutActionLogOutDesc" | i18n }} + +
-
-
-
- - - {{ "languageDesc" | i18n }} -
-
-
-
-
- - + + {{ "language" | i18n }} + + + + + + + {{ "languageDesc" | i18n }} + + + + {{ "enableFavicon" | i18n }} + -
- {{ "faviconDesc" | i18n }} -
-
-
-
- - - {{ "themeDesc" | i18n }} -
-
-
- + + {{ "faviconDesc" | i18n }} + + + {{ "theme" | i18n }} + + + + {{ "themeDesc" | i18n }} + +
diff --git a/apps/web/src/app/settings/preferences.component.ts b/apps/web/src/app/settings/preferences.component.ts index a6443b453ef..1092a31d5c2 100644 --- a/apps/web/src/app/settings/preferences.component.ts +++ b/apps/web/src/app/settings/preferences.component.ts @@ -158,7 +158,7 @@ export class PreferencesComponent implements OnInit { this.form.setValue(initialFormValues, { emitEvent: false }); } - async submit() { + submit = async () => { if (!this.form.controls.vaultTimeout.valid) { this.platformUtilsService.showToast( "error", @@ -188,7 +188,7 @@ export class PreferencesComponent implements OnInit { this.i18nService.t("preferencesUpdated"), ); } - } + }; ngOnDestroy() { this.destroy$.next(); diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index 2d4b39fa8bd..baa3e5e1bbd 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -18,6 +18,7 @@ import { ProviderSelectPaymentMethodDialogComponent, ProviderSubscriptionComponent, } from "../../billing/providers"; +import { SubscriptionStatusComponent } from "../../billing/providers/subscription/subscription-status.component"; import { AddOrganizationComponent } from "./clients/add-organization.component"; import { ClientsComponent } from "./clients/clients.component"; @@ -70,6 +71,7 @@ import { SetupComponent } from "./setup/setup.component"; ProviderSubscriptionComponent, ProviderSelectPaymentMethodDialogComponent, ProviderPaymentMethodComponent, + SubscriptionStatusComponent, ], providers: [WebProviderService, ProviderPermissionsGuard], }) diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.html index 87169b6d9c0..110990d709d 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.html @@ -1,5 +1,5 @@
- + {{ "newClientOrganization" | i18n }} @@ -49,11 +49,21 @@
- + {{ "seats" | i18n }} + + {{ unassignedSeatsForSelectedPlan }} + {{ "unassignedSeatsDescription" | i18n | lowercase }} + 0 {{ "purchaseSeatDescription" | i18n | lowercase }} +
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.ts index 8427572516a..13d74136cf4 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-organization.component.ts @@ -2,11 +2,12 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { Component, Inject, OnInit } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; import { PlanType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { ProviderPlanResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { DialogService } from "@bitwarden/components"; +import { DialogService, ToastService } from "@bitwarden/components"; import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; @@ -33,6 +34,7 @@ type PlanCard = { name: string; cost: number; type: PlanType; + plan: PlanResponse; selected: boolean; }; @@ -41,20 +43,24 @@ type PlanCard = { templateUrl: "./create-client-organization.component.html", }) export class CreateClientOrganizationComponent implements OnInit { - protected ResultType = CreateClientOrganizationResultType; protected formGroup = this.formBuilder.group({ clientOwnerEmail: ["", [Validators.required, Validators.email]], organizationName: ["", Validators.required], seats: [null, [Validators.required, Validators.min(1)]], }); + protected loading = true; protected planCards: PlanCard[]; + protected ResultType = CreateClientOrganizationResultType; + + private providerPlans: ProviderPlanResponse[]; constructor( + private billingApiService: BillingApiServiceAbstraction, @Inject(DIALOG_DATA) private dialogParams: CreateClientOrganizationParams, private dialogRef: DialogRef, private formBuilder: FormBuilder, private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, private webProviderService: WebProviderService, ) {} @@ -92,6 +98,11 @@ export class CreateClientOrganizationComponent implements OnInit { } async ngOnInit(): Promise { + const subscription = await this.billingApiService.getProviderSubscription( + this.dialogParams.providerId, + ); + this.providerPlans = subscription?.plans ?? []; + const teamsPlan = this.dialogParams.plans.find((plan) => plan.type === PlanType.TeamsMonthly); const enterprisePlan = this.dialogParams.plans.find( (plan) => plan.type === PlanType.EnterpriseMonthly, @@ -102,15 +113,19 @@ export class CreateClientOrganizationComponent implements OnInit { name: this.i18nService.t("planNameTeams"), cost: teamsPlan.PasswordManager.seatPrice * 0.65, // 35% off for MSPs, type: teamsPlan.type, + plan: teamsPlan, selected: true, }, { name: this.i18nService.t("planNameEnterprise"), cost: enterprisePlan.PasswordManager.seatPrice * 0.65, // 35% off for MSPs, type: enterprisePlan.type, + plan: enterprisePlan, selected: false, }, ]; + + this.loading = false; } protected selectPlan(name: string) { @@ -135,8 +150,23 @@ export class CreateClientOrganizationComponent implements OnInit { this.formGroup.value.seats, ); - this.platformUtilsService.showToast("success", null, this.i18nService.t("createdNewClient")); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("createdNewClient"), + }); this.dialogRef.close(this.ResultType.Submitted); }; + + protected get unassignedSeatsForSelectedPlan(): number { + if (this.loading || !this.planCards) { + return 0; + } + const selectedPlan = this.planCards.find((planCard) => planCard.selected).plan; + const selectedProviderPlan = this.providerPlans.find( + (providerPlan) => providerPlan.planName === selectedPlan.name, + ); + return selectedProviderPlan.seatMinimum - selectedProviderPlan.assignedSeats; + } } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html index d1e4fe8b1f3..8181c285c28 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html @@ -7,22 +7,20 @@

{{ "manageSeatsDescription" | i18n }}

- + {{ "assignedSeats" | i18n }} + +
+ {{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }} + 0 {{ "purchaseSeatDescription" | i18n | lowercase }} +
+
- -

- {{ unassignedSeats }} - {{ "unassignedSeatsDescription" | i18n }} -

-

- {{ AdditionalSeatPurchased }} - {{ "purchaseSeatDescription" | i18n }} -

-
+ +
+
{{ "billingPlan" | i18n }}
+
{{ "providerPlan" | i18n }}
+ +
{{ data.status.label }}
+
+ + {{ displayedStatus }} + +
+
+ {{ data.date.label | titlecase }} +
+
+ {{ data.date.value | date: "mediumDate" }} +
+
+
+
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/subscription-status.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/subscription/subscription-status.component.ts new file mode 100644 index 00000000000..fa9a892254e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/subscription-status.component.ts @@ -0,0 +1,188 @@ +import { DatePipe } from "@angular/common"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; + +import { ProviderSubscriptionResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +type ComponentData = { + status?: { + label: string; + value: string; + }; + date?: { + label: string; + value: string; + }; + callout?: { + severity: "danger" | "warning"; + header: string; + body: string; + showReinstatementButton: boolean; + }; +}; + +@Component({ + selector: "app-subscription-status", + templateUrl: "subscription-status.component.html", +}) +export class SubscriptionStatusComponent { + @Input({ required: true }) providerSubscriptionResponse: ProviderSubscriptionResponse; + @Output() reinstatementRequested = new EventEmitter(); + + constructor( + private datePipe: DatePipe, + private i18nService: I18nService, + ) {} + + get displayedStatus(): string { + return this.data.status.value; + } + + get planName() { + return this.providerSubscriptionResponse.plans[0]; + } + + get status(): string { + return this.subscription.status; + } + + get isExpired() { + return this.subscription.status !== "active"; + } + + get subscription() { + return this.providerSubscriptionResponse; + } + + get data(): ComponentData { + const defaultStatusLabel = this.i18nService.t("status"); + + const nextChargeDateLabel = this.i18nService.t("nextCharge"); + const subscriptionExpiredDateLabel = this.i18nService.t("subscriptionExpired"); + const cancellationDateLabel = this.i18nService.t("cancellationDate"); + + switch (this.status) { + case "free": { + return {}; + } + case "trialing": { + return { + status: { + label: defaultStatusLabel, + value: this.i18nService.t("trial"), + }, + date: { + label: nextChargeDateLabel, + value: this.subscription.currentPeriodEndDate.toDateString(), + }, + }; + } + case "active": { + return { + status: { + label: defaultStatusLabel, + value: this.i18nService.t("active"), + }, + date: { + label: nextChargeDateLabel, + value: this.subscription.currentPeriodEndDate.toDateString(), + }, + }; + } + case "past_due": { + const pastDueText = this.i18nService.t("pastDue"); + const suspensionDate = this.datePipe.transform( + this.subscription.suspensionDate, + "mediumDate", + ); + const calloutBody = + this.subscription.collectionMethod === "charge_automatically" + ? this.i18nService.t( + "pastDueWarningForChargeAutomatically", + this.subscription.gracePeriod, + suspensionDate, + ) + : this.i18nService.t( + "pastDueWarningForSendInvoice", + this.subscription.gracePeriod, + suspensionDate, + ); + return { + status: { + label: defaultStatusLabel, + value: pastDueText, + }, + date: { + label: subscriptionExpiredDateLabel, + value: this.subscription.unpaidPeriodEndDate, + }, + callout: { + severity: "warning", + header: pastDueText, + body: calloutBody, + showReinstatementButton: false, + }, + }; + } + case "unpaid": { + return { + status: { + label: defaultStatusLabel, + value: this.i18nService.t("unpaid"), + }, + date: { + label: subscriptionExpiredDateLabel, + value: this.subscription.currentPeriodEndDate.toDateString(), + }, + callout: { + severity: "danger", + header: this.i18nService.t("unpaidInvoice"), + body: this.i18nService.t("toReactivateYourSubscription"), + showReinstatementButton: false, + }, + }; + } + case "pending_cancellation": { + const pendingCancellationText = this.i18nService.t("pendingCancellation"); + return { + status: { + label: defaultStatusLabel, + value: pendingCancellationText, + }, + date: { + label: cancellationDateLabel, + value: this.subscription.currentPeriodEndDate.toDateString(), + }, + callout: { + severity: "warning", + header: pendingCancellationText, + body: this.i18nService.t("subscriptionPendingCanceled"), + showReinstatementButton: true, + }, + }; + } + case "incomplete_expired": + case "canceled": { + const canceledText = this.i18nService.t("canceled"); + return { + status: { + label: defaultStatusLabel, + value: canceledText, + }, + date: { + label: cancellationDateLabel, + value: this.subscription.currentPeriodEndDate.toDateString(), + }, + callout: { + severity: "danger", + header: canceledText, + body: this.i18nService.t("subscriptionCanceled"), + showReinstatementButton: false, + }, + }; + } + } + } + + requestReinstatement = () => this.reinstatementRequested.emit(); +} diff --git a/libs/common/src/billing/models/response/provider-subscription-response.ts b/libs/common/src/billing/models/response/provider-subscription-response.ts index 522c5187254..fb8818fb39d 100644 --- a/libs/common/src/billing/models/response/provider-subscription-response.ts +++ b/libs/common/src/billing/models/response/provider-subscription-response.ts @@ -4,21 +4,29 @@ export class ProviderSubscriptionResponse extends BaseResponse { status: string; currentPeriodEndDate: Date; discountPercentage?: number | null; - plans: Plans[] = []; + plans: ProviderPlanResponse[] = []; + collectionMethod: string; + unpaidPeriodEndDate?: string; + gracePeriod?: number | null; + suspensionDate?: string; constructor(response: any) { super(response); this.status = this.getResponseProperty("status"); this.currentPeriodEndDate = new Date(this.getResponseProperty("currentPeriodEndDate")); this.discountPercentage = this.getResponseProperty("discountPercentage"); + this.collectionMethod = this.getResponseProperty("collectionMethod"); + this.unpaidPeriodEndDate = this.getResponseProperty("unpaidPeriodEndDate"); + this.gracePeriod = this.getResponseProperty("gracePeriod"); + this.suspensionDate = this.getResponseProperty("suspensionDate"); const plans = this.getResponseProperty("plans"); if (plans != null) { - this.plans = plans.map((i: any) => new Plans(i)); + this.plans = plans.map((i: any) => new ProviderPlanResponse(i)); } } } -export class Plans extends BaseResponse { +export class ProviderPlanResponse extends BaseResponse { planName: string; seatMinimum: number; assignedSeats: number;