1
0
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:
Justin Baur
2025-01-22 08:15:59 -05:00
6 changed files with 141 additions and 83 deletions

View File

@@ -9298,6 +9298,9 @@
"monthPerMember": {
"message": "month per member"
},
"monthPerMemberBilledAnnually": {
"message": "month per member billed annually"
},
"seats": {
"message": "Seats"
},

View File

@@ -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 }}

View File

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

View File

@@ -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
}}) {{ "&times;" }}{{ getFormattedSeatCount(i.seatMinimum, i.purchasedSeats) }}
&#64;
{{
@@ -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>

View File

@@ -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";
}
}
}

View File

@@ -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