1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-01-07 19:07:45 +01:00

Merge branch 'main' of github.com:bitwarden/clients

This commit is contained in:
gbubemismith 2024-06-06 13:59:17 -04:00
commit cf802fc32c
No known key found for this signature in database
17 changed files with 391 additions and 187 deletions

View File

@ -4,25 +4,6 @@
[title]="'autofillSuggestions' | i18n" [title]="'autofillSuggestions' | i18n"
[showRefresh]="showRefresh" [showRefresh]="showRefresh"
(onRefresh)="refreshCurrentTab()" (onRefresh)="refreshCurrentTab()"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : null"
showAutofillButton showAutofillButton
></app-vault-list-items-container> ></app-vault-list-items-container>
<ng-container *ngIf="showEmptyAutofillTip$ | async">
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">
{{ "autofillSuggestions" | i18n }}
</h2>
<button
*ngIf="showRefresh"
bitIconButton="bwi-refresh"
size="small"
type="button"
[appA11yTitle]="'refresh' | i18n"
(click)="refreshCurrentTab()"
></button>
</bit-section-header>
<span class="tw-text-muted tw-px-1" bitTypography="body2">{{
"autofillSuggestionsTip" | i18n
}}</span>
</bit-section>
</ng-container>

View File

@ -3,6 +3,7 @@ import { Component } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs"; import { combineLatest, map, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CipherType } from "@bitwarden/common/vault/enums";
import { import {
IconButtonModule, IconButtonModule,
SectionComponent, SectionComponent,
@ -45,7 +46,7 @@ export class AutofillVaultListItemsComponent {
/** /**
* Observable that determines whether the empty autofill tip should be shown. * 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). * the current context (e.g. not in a popout).
* @protected * @protected
*/ */
@ -54,7 +55,10 @@ export class AutofillVaultListItemsComponent {
this.autofillCiphers$, this.autofillCiphers$,
this.vaultPopupItemsService.autofillAllowed$, this.vaultPopupItemsService.autofillAllowed$,
]).pipe( ]).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) { constructor(private vaultPopupItemsService: VaultPopupItemsService) {

View File

@ -1,4 +1,4 @@
<bit-section *ngIf="ciphers?.length > 0"> <bit-section *ngIf="ciphers?.length > 0 || description">
<bit-section-header> <bit-section-header>
<h2 bitTypography="h6"> <h2 bitTypography="h6">
{{ title }} {{ title }}
@ -13,6 +13,9 @@
></button> ></button>
<span bitTypography="body2" slot="end">{{ ciphers.length }}</span> <span bitTypography="body2" slot="end">{{ ciphers.length }}</span>
</bit-section-header> </bit-section-header>
<div *ngIf="description" class="tw-text-muted tw-px-1 tw-mb-2" bitTypography="body2">
{{ description }}
</div>
<bit-item-group> <bit-item-group>
<bit-item *ngFor="let cipher of ciphers"> <bit-item *ngFor="let cipher of ciphers">
<a <a

View File

@ -50,6 +50,13 @@ export class VaultListItemsContainerComponent {
@Input() @Input()
title: string; title: string;
/**
* Optional description for the vault list item section. Will be shown below the title even when
* no ciphers are available.
*/
@Input()
description: string;
/** /**
* Option to show a refresh button in the section header. * Option to show a refresh button in the section header.
*/ */

View File

@ -1,106 +1,77 @@
<app-header></app-header> <app-header></app-header>
<bit-container> <bit-container>
<p>{{ "preferencesDesc" | i18n }}</p> <p bitTypography="body1">{{ "preferencesDesc" | i18n }}</p>
<form [formGroup]="form" (ngSubmit)="submit()" ngNativeValidate> <form [formGroup]="form" [bitSubmit]="submit" class="tw-w-1/2">
<div class="row"> <app-callout type="info" *ngIf="vaultTimeoutPolicyCallout | async as policy">
<div class="col-6"> <span *ngIf="policy.timeout && policy.action">
<app-callout type="info" *ngIf="vaultTimeoutPolicyCallout | async as policy"> {{
<span *ngIf="policy.timeout && policy.action"> "vaultTimeoutPolicyWithActionInEffect"
{{ | i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n)
"vaultTimeoutPolicyWithActionInEffect" }}
| i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n) </span>
}} <span *ngIf="policy.timeout && !policy.action">
</span> {{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }}
<span *ngIf="policy.timeout && !policy.action"> </span>
{{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }} <span *ngIf="!policy.timeout && policy.action">
</span> {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }}
<span *ngIf="!policy.timeout && policy.action"> </span>
{{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} </app-callout>
</span> <app-vault-timeout-input
</app-callout> [vaultTimeoutOptions]="vaultTimeoutOptions"
<app-vault-timeout-input [formControl]="form.controls.vaultTimeout"
[vaultTimeoutOptions]="vaultTimeoutOptions" ngDefaultControl
[formControl]="form.controls.vaultTimeout" >
ngDefaultControl </app-vault-timeout-input>
>
</app-vault-timeout-input>
</div>
</div>
<ng-container *ngIf="availableVaultTimeoutActions$ | async as availableVaultTimeoutActions"> <ng-container *ngIf="availableVaultTimeoutActions$ | async as availableVaultTimeoutActions">
<div *ngIf="availableVaultTimeoutActions.length > 1" class="form-group"> <bit-radio-group
<label>{{ "vaultTimeoutAction" | i18n }}</label> formControlName="vaultTimeoutAction"
<div *ngIf="availableVaultTimeoutActions.length > 1"
>
<bit-label>{{ "vaultTimeoutAction" | i18n }}</bit-label>
<bit-radio-button
*ngIf="availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)" *ngIf="availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)"
class="form-check form-check-block" id="vaultTimeoutActionLock"
[value]="VaultTimeoutAction.Lock"
> >
<input <bit-label>{{ "lock" | i18n }}</bit-label>
class="form-check-input" <bit-hint>{{ "vaultTimeoutActionLockDesc" | i18n }}</bit-hint>
type="radio" </bit-radio-button>
name="vaultTimeoutAction" <bit-radio-button
id="vaultTimeoutActionLock"
value="{{ VaultTimeoutAction.Lock }}"
formControlName="vaultTimeoutAction"
/>
<label class="form-check-label" for="vaultTimeoutActionLock">
{{ "lock" | i18n }}
<small>{{ "vaultTimeoutActionLockDesc" | i18n }}</small>
</label>
</div>
<div
*ngIf="availableVaultTimeoutActions.includes(VaultTimeoutAction.LogOut)" *ngIf="availableVaultTimeoutActions.includes(VaultTimeoutAction.LogOut)"
class="form-check mt-2 form-check-block" id="vaultTimeoutActionLogOut"
[value]="VaultTimeoutAction.LogOut"
> >
<input <bit-label>{{ "logOut" | i18n }}</bit-label>
class="form-check-input" <bit-hint>{{ "vaultTimeoutActionLogOutDesc" | i18n }}</bit-hint>
type="radio" </bit-radio-button>
name="vaultTimeoutAction" </bit-radio-group>
id="vaultTimeoutActionLogOut"
value="{{ VaultTimeoutAction.LogOut }}"
formControlName="vaultTimeoutAction"
/>
<label class="form-check-label" for="vaultTimeoutActionLogOut">
{{ "logOut" | i18n }}
<small>{{ "vaultTimeoutActionLogOutDesc" | i18n }}</small>
</label>
</div>
</div>
</ng-container> </ng-container>
<div class="row"> <bit-form-field>
<div class="col-6"> <bit-label
<div class="form-group"> >{{ "language" | i18n }}
<div class="d-flex">
<label for="locale">{{ "language" | i18n }}</label>
<a
class="ml-auto"
href="https://bitwarden.com/help/localization/"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</div>
<select id="locale" name="Locale" formControlName="locale" class="form-control">
<option *ngFor="let o of localeOptions" [ngValue]="o.value">{{ o.name }}</option>
</select>
<small class="form-text text-muted">{{ "languageDesc" | i18n }}</small>
</div>
</div>
</div>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enableFavicons"
name="enableFavicons"
formControlName="enableFavicons"
/>
<label class="form-check-label" for="enableFavicons">
{{ "enableFavicon" | i18n }}
</label>
<a <a
bitLink
class="tw-float-right"
href="https://bitwarden.com/help/localization/"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</bit-label>
<bit-select formControlName="locale" id="locale">
<bit-option *ngFor="let o of localeOptions" [value]="o.value" [label]="o.name"></bit-option>
</bit-select>
<bit-hint>{{ "languageDesc" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="enableFavicons" />
<bit-label
>{{ "enableFavicon" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/website-icons/" href="https://bitwarden.com/help/website-icons/"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@ -108,22 +79,16 @@
> >
<i class="bwi bwi-question-circle" aria-hidden="true"></i> <i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a> </a>
</div> </bit-label>
<small class="form-text text-muted">{{ "faviconDesc" | i18n }}</small> <bit-hint>{{ "faviconDesc" | i18n }}</bit-hint>
</div> </bit-form-control>
<div class="row"> <bit-form-field>
<div class="col-6"> <bit-label>{{ "theme" | i18n }}</bit-label>
<div class="form-group"> <bit-select formControlName="theme" id="theme">
<label for="theme">{{ "theme" | i18n }}</label> <bit-option *ngFor="let o of themeOptions" [value]="o.value" [label]="o.name"></bit-option>
<select id="theme" name="theme" formControlName="theme" class="form-control"> </bit-select>
<option *ngFor="let o of themeOptions" [ngValue]="o.value">{{ o.name }}</option> <bit-hint>{{ "themeDesc" | i18n }}</bit-hint>
</select> </bit-form-field>
<small class="form-text text-muted">{{ "themeDesc" | i18n }}</small> <button bitButton bitFormButton type="submit" buttonType="primary">{{ "save" | i18n }}</button>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">
{{ "save" | i18n }}
</button>
</form> </form>
</bit-container> </bit-container>

View File

@ -158,7 +158,7 @@ export class PreferencesComponent implements OnInit {
this.form.setValue(initialFormValues, { emitEvent: false }); this.form.setValue(initialFormValues, { emitEvent: false });
} }
async submit() { submit = async () => {
if (!this.form.controls.vaultTimeout.valid) { if (!this.form.controls.vaultTimeout.valid) {
this.platformUtilsService.showToast( this.platformUtilsService.showToast(
"error", "error",
@ -188,7 +188,7 @@ export class PreferencesComponent implements OnInit {
this.i18nService.t("preferencesUpdated"), this.i18nService.t("preferencesUpdated"),
); );
} }
} };
ngOnDestroy() { ngOnDestroy() {
this.destroy$.next(); this.destroy$.next();

View File

@ -18,6 +18,7 @@ import {
ProviderSelectPaymentMethodDialogComponent, ProviderSelectPaymentMethodDialogComponent,
ProviderSubscriptionComponent, ProviderSubscriptionComponent,
} from "../../billing/providers"; } from "../../billing/providers";
import { SubscriptionStatusComponent } from "../../billing/providers/subscription/subscription-status.component";
import { AddOrganizationComponent } from "./clients/add-organization.component"; import { AddOrganizationComponent } from "./clients/add-organization.component";
import { ClientsComponent } from "./clients/clients.component"; import { ClientsComponent } from "./clients/clients.component";
@ -70,6 +71,7 @@ import { SetupComponent } from "./setup/setup.component";
ProviderSubscriptionComponent, ProviderSubscriptionComponent,
ProviderSelectPaymentMethodDialogComponent, ProviderSelectPaymentMethodDialogComponent,
ProviderPaymentMethodComponent, ProviderPaymentMethodComponent,
SubscriptionStatusComponent,
], ],
providers: [WebProviderService, ProviderPermissionsGuard], providers: [WebProviderService, ProviderPermissionsGuard],
}) })

View File

@ -1,5 +1,5 @@
<form [formGroup]="formGroup" [bitSubmit]="submit"> <form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large"> <bit-dialog dialogSize="large" [loading]="loading">
<span bitDialogTitle class="tw-font-semibold"> <span bitDialogTitle class="tw-font-semibold">
{{ "newClientOrganization" | i18n }} {{ "newClientOrganization" | i18n }}
</span> </span>
@ -49,11 +49,21 @@
</bit-form-field> </bit-form-field>
</div> </div>
<div class="tw-grid tw-grid-flow-col tw-grid-cols-2 tw-gap-4"> <div class="tw-grid tw-grid-flow-col tw-grid-cols-2 tw-gap-4">
<bit-form-field> <bit-form-field disableMargin>
<bit-label> <bit-label>
{{ "seats" | i18n }} {{ "seats" | i18n }}
</bit-label> </bit-label>
<input type="text" bitInput formControlName="seats" /> <input type="text" bitInput formControlName="seats" />
<bit-hint
class="tw-text-muted tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1 tw-grid-rows-2"
*ngIf="unassignedSeatsForSelectedPlan > 0"
>
<span class="tw-col-span-1"
>{{ unassignedSeatsForSelectedPlan }}
{{ "unassignedSeatsDescription" | i18n | lowercase }}</span
>
<span class="tw-col-span-1">0 {{ "purchaseSeatDescription" | i18n | lowercase }}</span>
</bit-hint>
</bit-form-field> </bit-form-field>
</div> </div>
</div> </div>

View File

@ -2,11 +2,12 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core"; import { Component, Inject, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms"; 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 { PlanType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components";
import { DialogService } from "@bitwarden/components";
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
@ -33,6 +34,7 @@ type PlanCard = {
name: string; name: string;
cost: number; cost: number;
type: PlanType; type: PlanType;
plan: PlanResponse;
selected: boolean; selected: boolean;
}; };
@ -41,20 +43,24 @@ type PlanCard = {
templateUrl: "./create-client-organization.component.html", templateUrl: "./create-client-organization.component.html",
}) })
export class CreateClientOrganizationComponent implements OnInit { export class CreateClientOrganizationComponent implements OnInit {
protected ResultType = CreateClientOrganizationResultType;
protected formGroup = this.formBuilder.group({ protected formGroup = this.formBuilder.group({
clientOwnerEmail: ["", [Validators.required, Validators.email]], clientOwnerEmail: ["", [Validators.required, Validators.email]],
organizationName: ["", Validators.required], organizationName: ["", Validators.required],
seats: [null, [Validators.required, Validators.min(1)]], seats: [null, [Validators.required, Validators.min(1)]],
}); });
protected loading = true;
protected planCards: PlanCard[]; protected planCards: PlanCard[];
protected ResultType = CreateClientOrganizationResultType;
private providerPlans: ProviderPlanResponse[];
constructor( constructor(
private billingApiService: BillingApiServiceAbstraction,
@Inject(DIALOG_DATA) private dialogParams: CreateClientOrganizationParams, @Inject(DIALOG_DATA) private dialogParams: CreateClientOrganizationParams,
private dialogRef: DialogRef<CreateClientOrganizationResultType>, private dialogRef: DialogRef<CreateClientOrganizationResultType>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private i18nService: I18nService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService, private toastService: ToastService,
private webProviderService: WebProviderService, private webProviderService: WebProviderService,
) {} ) {}
@ -92,6 +98,11 @@ export class CreateClientOrganizationComponent implements OnInit {
} }
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
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 teamsPlan = this.dialogParams.plans.find((plan) => plan.type === PlanType.TeamsMonthly);
const enterprisePlan = this.dialogParams.plans.find( const enterprisePlan = this.dialogParams.plans.find(
(plan) => plan.type === PlanType.EnterpriseMonthly, (plan) => plan.type === PlanType.EnterpriseMonthly,
@ -102,15 +113,19 @@ export class CreateClientOrganizationComponent implements OnInit {
name: this.i18nService.t("planNameTeams"), name: this.i18nService.t("planNameTeams"),
cost: teamsPlan.PasswordManager.seatPrice * 0.65, // 35% off for MSPs, cost: teamsPlan.PasswordManager.seatPrice * 0.65, // 35% off for MSPs,
type: teamsPlan.type, type: teamsPlan.type,
plan: teamsPlan,
selected: true, selected: true,
}, },
{ {
name: this.i18nService.t("planNameEnterprise"), name: this.i18nService.t("planNameEnterprise"),
cost: enterprisePlan.PasswordManager.seatPrice * 0.65, // 35% off for MSPs, cost: enterprisePlan.PasswordManager.seatPrice * 0.65, // 35% off for MSPs,
type: enterprisePlan.type, type: enterprisePlan.type,
plan: enterprisePlan,
selected: false, selected: false,
}, },
]; ];
this.loading = false;
} }
protected selectPlan(name: string) { protected selectPlan(name: string) {
@ -135,8 +150,23 @@ export class CreateClientOrganizationComponent implements OnInit {
this.formGroup.value.seats, 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); 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;
}
} }

View File

@ -7,22 +7,20 @@
<p> <p>
{{ "manageSeatsDescription" | i18n }} {{ "manageSeatsDescription" | i18n }}
</p> </p>
<bit-form-field> <bit-form-field disableMargin>
<bit-label> <bit-label>
{{ "assignedSeats" | i18n }} {{ "assignedSeats" | i18n }}
</bit-label> </bit-label>
<input id="assignedSeats" type="number" bitInput required [(ngModel)]="assignedSeats" /> <input id="assignedSeats" type="number" bitInput required [(ngModel)]="assignedSeats" />
<bit-hint class="tw-text-muted" *ngIf="remainingOpenSeats > 0">
<div class="tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1 tw-grid-rows-2">
<span class="tw-col-span-1"
>{{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }}</span
>
<span class="tw-col-span-1">0 {{ "purchaseSeatDescription" | i18n | lowercase }}</span>
</div>
</bit-hint>
</bit-form-field> </bit-form-field>
<ng-container *ngIf="remainingOpenSeats > 0">
<p>
<small class="tw-text-muted">{{ unassignedSeats }}</small>
<small class="tw-text-muted">{{ "unassignedSeatsDescription" | i18n }}</small>
</p>
<p>
<small class="tw-text-muted">{{ AdditionalSeatPurchased }}</small>
<small class="tw-text-muted">{{ "purchaseSeatDescription" | i18n }}</small>
</p>
</ng-container>
</div> </div>
<ng-container bitDialogFooter> <ng-container bitDialogFooter>
<button <button

View File

@ -4,7 +4,7 @@ import { Component, Inject, OnInit } from "@angular/core";
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/update-client-organization.request"; import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/update-client-organization.request";
import { Plans } from "@bitwarden/common/billing/models/response/provider-subscription-response"; import { ProviderPlanResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
@ -83,7 +83,7 @@ export class ManageClientOrganizationSubscriptionComponent implements OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
getPurchasedSeatsByPlan(planName: string, plans: Plans[]): number { getPurchasedSeatsByPlan(planName: string, plans: ProviderPlanResponse[]): number {
const plan = plans.find((plan) => plan.planName === planName); const plan = plans.find((plan) => plan.planName === planName);
if (plan) { if (plan) {
return plan.purchasedSeats; return plan.purchasedSeats;
@ -92,7 +92,7 @@ export class ManageClientOrganizationSubscriptionComponent implements OnInit {
} }
} }
getAssignedByPlan(planName: string, plans: Plans[]): number { getAssignedByPlan(planName: string, plans: ProviderPlanResponse[]): number {
const plan = plans.find((plan) => plan.planName === planName); const plan = plans.find((plan) => plan.planName === planName);
if (plan) { if (plan) {
return plan.assignedSeats; return plan.assignedSeats;
@ -101,7 +101,7 @@ export class ManageClientOrganizationSubscriptionComponent implements OnInit {
} }
} }
getProviderSeatMinimumByPlan(planName: string, plans: Plans[]) { getProviderSeatMinimumByPlan(planName: string, plans: ProviderPlanResponse[]) {
const plan = plans.find((plan) => plan.planName === planName); const plan = plans.find((plan) => plan.planName === planName);
if (plan) { if (plan) {
return plan.seatMinimum; return plan.seatMinimum;

View File

@ -158,6 +158,4 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent {
await this.load(); await this.load();
}; };
protected readonly openManageClientOrganizationNameDialog =
openManageClientOrganizationNameDialog;
} }

View File

@ -1,32 +1,10 @@
<app-header></app-header> <app-header></app-header>
<bit-container> <bit-container>
<ng-container *ngIf="!firstLoaded && loading"> <ng-container *ngIf="!firstLoaded && loading">
<i class="bwi bwi-spinner bwi-spin text-muted" title="{{ 'loading' | i18n }}"></i> <i class="bwi bwi-spinner bwi-spin text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="sr-only">{{ "loading" | i18n }}</span> <span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container> </ng-container>
<app-subscription-status [providerSubscriptionResponse]="subscription"> </app-subscription-status>
<ng-container *ngIf="subscription && firstLoaded">
<bit-callout type="warning" title="{{ 'canceled' | i18n }}" *ngIf="false">
{{ "subscriptionCanceled" | i18n }}</bit-callout
>
<dl class="tw-grid tw-grid-flow-col tw-grid-rows-2">
<dt>{{ "billingPlan" | i18n }}</dt>
<dd>{{ "providerPlan" | i18n }}</dd>
<ng-container *ngIf="subscription">
<dt>{{ "status" | i18n }}</dt>
<dd>
<span class="tw-capitalize">{{ subscription.status }}</span>
</dd>
<dt [ngClass]="{ 'tw-text-danger': isExpired }">{{ "nextCharge" | i18n }}</dt>
<dd [ngClass]="{ 'tw-text-danger': isExpired }">
{{ subscription.currentPeriodEndDate | date: "mediumDate" }}
</dd>
</ng-container>
</dl>
</ng-container>
<ng-container> <ng-container>
<div class="tw-flex-col"> <div class="tw-flex-col">
<strong class="tw-block tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 pb-2" <strong class="tw-block tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 pb-2"

View File

@ -4,7 +4,7 @@ import { Subject, concatMap, takeUntil } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
import { import {
Plans, ProviderPlanResponse,
ProviderSubscriptionResponse, ProviderSubscriptionResponse,
} from "@bitwarden/common/billing/models/response/provider-subscription-response"; } from "@bitwarden/common/billing/models/response/provider-subscription-response";
@ -75,7 +75,7 @@ export class ProviderSubscriptionComponent {
return totalSeats > 1 ? totalSeats.toString() : ""; return totalSeats > 1 ? totalSeats.toString() : "";
} }
sumCost(plans: Plans[]): number { sumCost(plans: ProviderPlanResponse[]): number {
return plans.reduce((acc, plan) => acc + plan.cost, 0); return plans.reduce((acc, plan) => acc + plan.cost, 0);
} }

View File

@ -0,0 +1,32 @@
<ng-container>
<bit-callout *ngIf="data.callout" [type]="data.callout.severity" [title]="data.callout.header">
<p>{{ data.callout.body }}</p>
<button
*ngIf="data.callout.showReinstatementButton"
bitButton
buttonType="secondary"
[bitAction]="requestReinstatement"
type="button"
>
{{ "reinstateSubscription" | i18n }}
</button>
</bit-callout>
<dl class="tw-grid tw-grid-flow-col tw-grid-rows-2">
<dt>{{ "billingPlan" | i18n }}</dt>
<dd>{{ "providerPlan" | i18n }}</dd>
<ng-container *ngIf="data.status && data.date">
<dt>{{ data.status.label }}</dt>
<dd>
<span class="tw-capitalize">
{{ displayedStatus }}
</span>
</dd>
<dt [ngClass]="{ 'tw-text-danger': isExpired }">
{{ data.date.label | titlecase }}
</dt>
<dd [ngClass]="{ 'tw-text-danger': isExpired }">
{{ data.date.value | date: "mediumDate" }}
</dd>
</ng-container>
</dl>
</ng-container>

View File

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

View File

@ -4,21 +4,29 @@ export class ProviderSubscriptionResponse extends BaseResponse {
status: string; status: string;
currentPeriodEndDate: Date; currentPeriodEndDate: Date;
discountPercentage?: number | null; discountPercentage?: number | null;
plans: Plans[] = []; plans: ProviderPlanResponse[] = [];
collectionMethod: string;
unpaidPeriodEndDate?: string;
gracePeriod?: number | null;
suspensionDate?: string;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
this.status = this.getResponseProperty("status"); this.status = this.getResponseProperty("status");
this.currentPeriodEndDate = new Date(this.getResponseProperty("currentPeriodEndDate")); this.currentPeriodEndDate = new Date(this.getResponseProperty("currentPeriodEndDate"));
this.discountPercentage = this.getResponseProperty("discountPercentage"); 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"); const plans = this.getResponseProperty("plans");
if (plans != null) { 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; planName: string;
seatMinimum: number; seatMinimum: number;
assignedSeats: number; assignedSeats: number;