mirror of
https://github.com/bitwarden/browser
synced 2026-02-15 07:54:55 +00:00
Merge remote-tracking branch 'origin/main' into ps/pm-15333/portable-desktop
This commit is contained in:
@@ -9298,6 +9298,9 @@
|
||||
"monthPerMember": {
|
||||
"message": "month per member"
|
||||
},
|
||||
"monthPerMemberBilledAnnually": {
|
||||
"message": "month per member billed annually"
|
||||
},
|
||||
"seats": {
|
||||
"message": "Seats"
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="tw-grid tw-grid-flow-col tw-auto-cols-[minmax(0,_2fr)] tw-gap-4 tw-mb-4">
|
||||
<div
|
||||
*ngFor="let planCard of planCards"
|
||||
[ngClass]="getPlanCardContainerClasses(planCard.selected)"
|
||||
[ngClass]="planCard.getContainerClasses()"
|
||||
(click)="selectPlan(planCard.name)"
|
||||
tabindex="0"
|
||||
>
|
||||
@@ -29,9 +29,11 @@
|
||||
<div class="tw-pl-5 tw-py-4 tw-pr-4" [ngClass]="{ 'tw-pt-10': !planCard.selected }">
|
||||
<h3 class="tw-text-2xl tw-font-bold tw-uppercase">{{ planCard.name }}</h3>
|
||||
<span class="tw-text-2xl tw-font-semibold">{{
|
||||
planCard.cost | currency: "$"
|
||||
planCard.getMonthlyCost() | currency: "$"
|
||||
}}</span>
|
||||
<span class="tw-text-sm tw-font-bold">/ {{ "monthPerMember" | i18n }}</span>
|
||||
<span class="tw-text-sm tw-font-bold"
|
||||
>/ {{ planCard.getTimePerMemberLabel() | i18n }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,8 +47,8 @@
|
||||
<input type="text" bitInput formControlName="organizationName" />
|
||||
<bit-error-summary
|
||||
*ngIf="
|
||||
formGroup.get('organizationName').errors?.['maxLength'] &&
|
||||
formGroup.get('organizationName').touched
|
||||
formGroup.controls.organizationName.errors?.['maxLength'] &&
|
||||
formGroup.controls.organizationName.touched
|
||||
"
|
||||
>
|
||||
{{ "organizationNameMaxLength" | i18n }}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { BasePortalOutlet } from "@angular/cdk/portal";
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
|
||||
@@ -25,48 +24,42 @@ export enum CreateClientDialogResultType {
|
||||
|
||||
export const openCreateClientDialog = (
|
||||
dialogService: DialogService,
|
||||
dialogConfig: DialogConfig<CreateClientDialogParams>,
|
||||
dialogConfig: DialogConfig<
|
||||
CreateClientDialogParams,
|
||||
DialogRef<CreateClientDialogResultType, unknown>,
|
||||
BasePortalOutlet
|
||||
>,
|
||||
) =>
|
||||
dialogService.open<CreateClientDialogResultType, CreateClientDialogParams>(
|
||||
CreateClientDialogComponent,
|
||||
dialogConfig,
|
||||
);
|
||||
|
||||
type PlanCard = {
|
||||
name: string;
|
||||
cost: number;
|
||||
type: PlanType;
|
||||
plan: PlanResponse;
|
||||
export class PlanCard {
|
||||
readonly name: string;
|
||||
private readonly cost: number;
|
||||
readonly type: PlanType;
|
||||
readonly plan: PlanResponse;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
@Component({
|
||||
templateUrl: "./create-client-dialog.component.html",
|
||||
})
|
||||
export class CreateClientDialogComponent implements OnInit {
|
||||
protected discountPercentage: number;
|
||||
protected formGroup = new FormGroup({
|
||||
clientOwnerEmail: new FormControl<string>("", [Validators.required, Validators.email]),
|
||||
organizationName: new FormControl<string>("", [Validators.required, Validators.maxLength(50)]),
|
||||
seats: new FormControl<number>(null, [Validators.required, Validators.min(1)]),
|
||||
});
|
||||
protected loading = true;
|
||||
protected planCards: PlanCard[];
|
||||
protected ResultType = CreateClientDialogResultType;
|
||||
constructor(name: string, cost: number, type: PlanType, plan: PlanResponse, selected: boolean) {
|
||||
this.name = name;
|
||||
this.cost = cost;
|
||||
this.type = type;
|
||||
this.plan = plan;
|
||||
this.selected = selected;
|
||||
}
|
||||
|
||||
private providerPlans: ProviderPlanResponse[];
|
||||
getMonthlyCost(): number {
|
||||
return this.plan.isAnnual ? this.cost / 12 : this.cost;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
@Inject(DIALOG_DATA) private dialogParams: CreateClientDialogParams,
|
||||
private dialogRef: DialogRef<CreateClientDialogResultType>,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
private webProviderService: WebProviderService,
|
||||
) {}
|
||||
getTimePerMemberLabel(): string {
|
||||
return this.plan.isAnnual ? "monthPerMemberBilledAnnually" : "monthPerMember";
|
||||
}
|
||||
|
||||
protected getPlanCardContainerClasses(selected: boolean) {
|
||||
switch (selected) {
|
||||
getContainerClasses() {
|
||||
switch (this.selected) {
|
||||
case true: {
|
||||
return [
|
||||
"tw-group/plan-card-container",
|
||||
@@ -97,6 +90,41 @@ export class CreateClientDialogComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
templateUrl: "./create-client-dialog.component.html",
|
||||
})
|
||||
export class CreateClientDialogComponent implements OnInit {
|
||||
protected discountPercentage: number | null | undefined;
|
||||
protected formGroup = new FormGroup({
|
||||
clientOwnerEmail: new FormControl<string>("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.email],
|
||||
}),
|
||||
organizationName: new FormControl<string>("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.maxLength(50)],
|
||||
}),
|
||||
seats: new FormControl<number>(1, {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.min(1)],
|
||||
}),
|
||||
});
|
||||
protected loading = true;
|
||||
protected planCards: PlanCard[] = [];
|
||||
protected ResultType = CreateClientDialogResultType;
|
||||
|
||||
private providerPlans: ProviderPlanResponse[] = [];
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
@Inject(DIALOG_DATA) private dialogParams: CreateClientDialogParams,
|
||||
private dialogRef: DialogRef<CreateClientDialogResultType>,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
private webProviderService: WebProviderService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const response = await this.billingApiService.getProviderSubscription(
|
||||
@@ -114,6 +142,10 @@ export class CreateClientDialogComponent implements OnInit {
|
||||
const providerPlan = this.providerPlans[i];
|
||||
const plan = this.dialogParams.plans.find((plan) => plan.type === providerPlan.type);
|
||||
|
||||
if (!plan) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let planName: string;
|
||||
switch (plan.productTier) {
|
||||
case ProductTierType.Teams: {
|
||||
@@ -124,23 +156,28 @@ export class CreateClientDialogComponent implements OnInit {
|
||||
planName = this.i18nService.t("planNameEnterprise");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
this.planCards.push({
|
||||
name: planName,
|
||||
cost: plan.PasswordManager.providerPortalSeatPrice * discountFactor,
|
||||
type: plan.type,
|
||||
plan: plan,
|
||||
selected: i === 0,
|
||||
});
|
||||
this.planCards.push(
|
||||
new PlanCard(
|
||||
planName,
|
||||
plan.PasswordManager.providerPortalSeatPrice * discountFactor,
|
||||
plan.type,
|
||||
plan,
|
||||
i === 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
protected selectPlan(name: string) {
|
||||
this.planCards.find((planCard) => planCard.name === name).selected = true;
|
||||
this.planCards.find((planCard) => planCard.name !== name).selected = false;
|
||||
this.planCards.forEach((planCard) => {
|
||||
planCard.selected = planCard.name === name;
|
||||
});
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
@@ -152,17 +189,21 @@ export class CreateClientDialogComponent implements OnInit {
|
||||
|
||||
const selectedPlanCard = this.planCards.find((planCard) => planCard.selected);
|
||||
|
||||
if (!selectedPlanCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.webProviderService.createClientOrganization(
|
||||
this.dialogParams.providerId,
|
||||
this.formGroup.value.organizationName,
|
||||
this.formGroup.value.clientOwnerEmail,
|
||||
this.formGroup.controls.organizationName.value,
|
||||
this.formGroup.controls.clientOwnerEmail.value,
|
||||
selectedPlanCard.type,
|
||||
this.formGroup.value.seats,
|
||||
this.formGroup.controls.seats.value,
|
||||
);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
title: "",
|
||||
message: this.i18nService.t("createdNewClient"),
|
||||
});
|
||||
|
||||
@@ -178,7 +219,7 @@ export class CreateClientDialogComponent implements OnInit {
|
||||
|
||||
const openSeats = selectedProviderPlan.seatMinimum - selectedProviderPlan.assignedSeats;
|
||||
|
||||
const unassignedSeats = openSeats - this.formGroup.value.seats;
|
||||
const unassignedSeats = openSeats - this.formGroup.controls.seats.value;
|
||||
|
||||
return unassignedSeats > 0 ? unassignedSeats : 0;
|
||||
}
|
||||
@@ -191,22 +232,22 @@ export class CreateClientDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
if (selectedProviderPlan.purchasedSeats > 0) {
|
||||
return this.formGroup.value.seats;
|
||||
return this.formGroup.controls.seats.value;
|
||||
}
|
||||
|
||||
const additionalSeatsPurchased =
|
||||
this.formGroup.value.seats +
|
||||
this.formGroup.controls.seats.value +
|
||||
selectedProviderPlan.assignedSeats -
|
||||
selectedProviderPlan.seatMinimum;
|
||||
|
||||
return additionalSeatsPurchased > 0 ? additionalSeatsPurchased : 0;
|
||||
}
|
||||
|
||||
private getSelectedProviderPlan(): ProviderPlanResponse {
|
||||
private getSelectedProviderPlan(): ProviderPlanResponse | null {
|
||||
if (this.loading || !this.planCards) {
|
||||
return null;
|
||||
}
|
||||
const selectedPlan = this.planCards.find((planCard) => planCard.selected).plan;
|
||||
return this.providerPlans.find((providerPlan) => providerPlan.planName === selectedPlan.name);
|
||||
const selectedPlan = this.planCards.find((planCard) => planCard.selected)!.plan;
|
||||
return this.providerPlans.find((providerPlan) => providerPlan.planName === selectedPlan.name)!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<tr bitRow *ngFor="let i of activePlans">
|
||||
<td bitCell class="tw-pl-0 tw-py-3">
|
||||
{{ getFormattedPlanName(i.planName) }} {{ "orgSeats" | i18n }} ({{
|
||||
i.cadence.toLowerCase()
|
||||
getFormattedPlanNameCadence(i.cadence) | i18n
|
||||
}}) {{ "×" }}{{ getFormattedSeatCount(i.seatMinimum, i.purchasedSeats) }}
|
||||
@
|
||||
{{
|
||||
@@ -38,12 +38,11 @@
|
||||
}}
|
||||
</td>
|
||||
<td bitCell class="tw-text-right tw-py-3">
|
||||
{{ ((100 - subscription.discountPercentage) / 100) * i.cost | currency: "$" }} /{{
|
||||
"month" | i18n
|
||||
}}
|
||||
{{ ((100 - subscription.discountPercentage) / 100) * i.cost | currency: "$" }} /
|
||||
{{ getBillingCadenceLabel(i) | i18n }}
|
||||
<div *ngIf="subscription.discountPercentage">
|
||||
<bit-hint class="tw-text-sm tw-line-through">
|
||||
{{ i.cost | currency: "$" }} /{{ "month" | i18n }}
|
||||
{{ i.cost | currency: "$" }} / {{ getBillingCadenceLabel(i) | i18n }}
|
||||
</bit-hint>
|
||||
</div>
|
||||
</td>
|
||||
@@ -52,8 +51,9 @@
|
||||
<tr bitRow>
|
||||
<td bitCell class="tw-pl-0 tw-py-3"></td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span class="tw-font-bold">Total:</span> {{ totalCost | currency: "$" }} /{{
|
||||
"month" | i18n
|
||||
<span class="tw-font-bold">Total:</span> {{ totalCost | currency: "$" }} /
|
||||
{{
|
||||
getBillingCadenceLabel(activePlans.length > 0 ? activePlans[0] : null) | i18n
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -19,14 +19,13 @@ import { ToastService } from "@bitwarden/components";
|
||||
templateUrl: "./provider-subscription.component.html",
|
||||
})
|
||||
export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
|
||||
providerId: string;
|
||||
subscription: ProviderSubscriptionResponse;
|
||||
private providerId: string;
|
||||
protected subscription: ProviderSubscriptionResponse;
|
||||
|
||||
firstLoaded = false;
|
||||
loading: boolean;
|
||||
protected firstLoaded = false;
|
||||
protected loading: boolean;
|
||||
private destroy$ = new Subject<void>();
|
||||
totalCost: number;
|
||||
currentDate = new Date();
|
||||
protected totalCost: number;
|
||||
|
||||
protected readonly TaxInformation = TaxInformation;
|
||||
|
||||
@@ -50,11 +49,7 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
get isExpired() {
|
||||
return this.subscription.status !== "active";
|
||||
}
|
||||
|
||||
async load() {
|
||||
protected async load() {
|
||||
if (this.loading) {
|
||||
return;
|
||||
}
|
||||
@@ -65,7 +60,7 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
updateTaxInformation = async (taxInformation: TaxInformation) => {
|
||||
protected updateTaxInformation = async (taxInformation: TaxInformation) => {
|
||||
const request = ExpandedTaxInfoUpdateRequest.From(taxInformation);
|
||||
await this.billingApiService.updateProviderTaxInformation(this.providerId, request);
|
||||
this.toastService.showToast({
|
||||
@@ -75,7 +70,7 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
};
|
||||
|
||||
getFormattedCost(
|
||||
protected getFormattedCost(
|
||||
cost: number,
|
||||
seatMinimum: number,
|
||||
purchasedSeats: number,
|
||||
@@ -85,17 +80,21 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
|
||||
return costPerSeat - (costPerSeat * discountPercentage) / 100;
|
||||
}
|
||||
|
||||
getFormattedPlanName(planName: string): string {
|
||||
protected getFormattedPlanName(planName: string): string {
|
||||
const spaceIndex = planName.indexOf(" ");
|
||||
return planName.substring(0, spaceIndex);
|
||||
}
|
||||
|
||||
getFormattedSeatCount(seatMinimum: number, purchasedSeats: number): string {
|
||||
protected getFormattedSeatCount(seatMinimum: number, purchasedSeats: number): string {
|
||||
const totalSeats = seatMinimum + purchasedSeats;
|
||||
return totalSeats > 1 ? totalSeats.toString() : "";
|
||||
}
|
||||
|
||||
sumCost(plans: ProviderPlanResponse[]): number {
|
||||
protected getFormattedPlanNameCadence(cadence: string) {
|
||||
return cadence === "Annual" ? "annually" : "monthly";
|
||||
}
|
||||
|
||||
private sumCost(plans: ProviderPlanResponse[]): number {
|
||||
return plans.reduce((acc, plan) => acc + plan.cost, 0);
|
||||
}
|
||||
|
||||
@@ -104,7 +103,7 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
get activePlans(): ProviderPlanResponse[] {
|
||||
protected get activePlans(): ProviderPlanResponse[] {
|
||||
return this.subscription.plans.filter((plan) => {
|
||||
if (plan.purchasedSeats === 0) {
|
||||
return plan.seatMinimum > 0;
|
||||
@@ -113,4 +112,19 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected getBillingCadenceLabel(providerPlanResponse: ProviderPlanResponse): string {
|
||||
if (providerPlanResponse == null || providerPlanResponse == undefined) {
|
||||
return "month";
|
||||
}
|
||||
|
||||
switch (providerPlanResponse.cadence) {
|
||||
case "Monthly":
|
||||
return "month";
|
||||
case "Annual":
|
||||
return "year";
|
||||
default:
|
||||
return "month";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
<button
|
||||
bitSuffix
|
||||
type="button"
|
||||
*ngIf="this.cipher.viewPassword"
|
||||
bitIconButton
|
||||
bitPasswordInputToggle
|
||||
*ngIf="canViewPassword"
|
||||
@@ -45,7 +44,6 @@
|
||||
<button
|
||||
bitIconButton="bwi-clone"
|
||||
bitSuffix
|
||||
*ngIf="this.cipher.viewPassword"
|
||||
type="button"
|
||||
[appCopyClick]="field.value"
|
||||
showToast
|
||||
|
||||
Reference in New Issue
Block a user