From 0f9f7f4df6bccab8db60ae9634d44097e98cd04d Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 6 Jun 2024 15:53:52 +0100 Subject: [PATCH 1/4] [AC-2721][Defect] Apply Subscription Status Updates in Provider Subscription details (#9484) * Add status banner to the provider subscription page * Add the isexpired method * Add the unpaid status banner --- .../providers/providers.module.ts | 2 + .../provider-subscription.component.html | 24 +-- .../subscription-status.component.html | 32 +++ .../subscription-status.component.ts | 188 ++++++++++++++++++ .../provider-subscription-response.ts | 8 + 5 files changed, 231 insertions(+), 23 deletions(-) create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/subscription/subscription-status.component.html create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/subscription/subscription-status.component.ts 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 2d4b39fa8b..baa3e5e1bb 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/subscription/provider-subscription.component.html b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html index fdcb8a6701..47f8aa375c 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html @@ -1,32 +1,10 @@ - {{ "loading" | i18n }} - - - - {{ "subscriptionCanceled" | i18n }} - -
-
{{ "billingPlan" | i18n }}
-
{{ "providerPlan" | i18n }}
- -
{{ "status" | i18n }}
-
- {{ subscription.status }} -
-
{{ "nextCharge" | i18n }}
-
- {{ subscription.currentPeriodEndDate | date: "mediumDate" }} -
-
-
-
- +
+ +

{{ data.callout.body }}

+ +
+
+
{{ "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 0000000000..fa9a892254 --- /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 522c518725..9f6823f71f 100644 --- a/libs/common/src/billing/models/response/provider-subscription-response.ts +++ b/libs/common/src/billing/models/response/provider-subscription-response.ts @@ -4,6 +4,10 @@ export class ProviderSubscriptionResponse extends BaseResponse { status: string; currentPeriodEndDate: Date; discountPercentage?: number | null; + collectionMethod: string; + unpaidPeriodEndDate?: string; + gracePeriod?: number | null; + suspensionDate?: string; plans: Plans[] = []; constructor(response: any) { @@ -11,6 +15,10 @@ export class ProviderSubscriptionResponse extends BaseResponse { 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)); From c8eac6fa1213ae65134007300790d25ceeaf5af1 Mon Sep 17 00:00:00 2001 From: KiruthigaManivannan <162679756+KiruthigaManivannan@users.noreply.github.com> Date: Thu, 6 Jun 2024 20:46:59 +0530 Subject: [PATCH 2/4] PM-4977 Migrate Preferences component (#8663) * PM-4977 Migrate Preferences component * PM-4977 Addressed the review comments * PM-4977 Updated css in preferences html * PM-4977 Removed the class applied on bit-hint --- .../app/settings/preferences.component.html | 185 +++++++----------- .../src/app/settings/preferences.component.ts | 4 +- 2 files changed, 77 insertions(+), 112 deletions(-) diff --git a/apps/web/src/app/settings/preferences.component.html b/apps/web/src/app/settings/preferences.component.html index 828c251989..984ae7536a 100644 --- a/apps/web/src/app/settings/preferences.component.html +++ b/apps/web/src/app/settings/preferences.component.html @@ -1,106 +1,77 @@ -

{{ "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 a6443b453e..1092a31d5c 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(); From 7d12d1a74fdcaa5c791a6e95e229c41e61ccf26f Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 6 Jun 2024 13:22:15 -0400 Subject: [PATCH 3/4] Fix spacing for provider unassigned seats hint' (#9460) --- .../create-client-organization.component.html | 14 ++++++- .../create-client-organization.component.ts | 40 ++++++++++++++++--- ...t-organization-subscription.component.html | 20 +++++----- ...ent-organization-subscription.component.ts | 8 ++-- .../manage-client-organizations.component.ts | 2 - .../provider-subscription.component.ts | 4 +- .../provider-subscription-response.ts | 6 +-- 7 files changed, 65 insertions(+), 29 deletions(-) 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 87169b6d9c..110990d709 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 8427572516..13d74136cf 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 d1e4fe8b1f..8181c285c2 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 }} -

-
- - {{ - "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 1b9876759f..eb8737d513 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 c2c345fd75..7b8fdf7a8e 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 }} +