1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 06:13:38 +00:00

[PM-16664] Incorrect price value in "add new organization" modal (#12710)

This commit is contained in:
Jonas Hendrickx
2025-01-22 12:18:43 +01:00
committed by GitHub
parent e2629eeaa6
commit 8784602858
3 changed files with 103 additions and 57 deletions

View File

@@ -9298,6 +9298,9 @@
"monthPerMember": { "monthPerMember": {
"message": "month per member" "message": "month per member"
}, },
"monthPerMemberBilledAnnually": {
"message": "month per member billed annually"
},
"seats": { "seats": {
"message": "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 class="tw-grid tw-grid-flow-col tw-auto-cols-[minmax(0,_2fr)] tw-gap-4 tw-mb-4">
<div <div
*ngFor="let planCard of planCards" *ngFor="let planCard of planCards"
[ngClass]="getPlanCardContainerClasses(planCard.selected)" [ngClass]="planCard.getContainerClasses()"
(click)="selectPlan(planCard.name)" (click)="selectPlan(planCard.name)"
tabindex="0" tabindex="0"
> >
@@ -29,9 +29,11 @@
<div class="tw-pl-5 tw-py-4 tw-pr-4" [ngClass]="{ 'tw-pt-10': !planCard.selected }"> <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> <h3 class="tw-text-2xl tw-font-bold tw-uppercase">{{ planCard.name }}</h3>
<span class="tw-text-2xl tw-font-semibold">{{ <span class="tw-text-2xl tw-font-semibold">{{
planCard.cost | currency: "$" planCard.getMonthlyCost() | currency: "$"
}}</span> }}</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> </div>
</div> </div>
@@ -45,8 +47,8 @@
<input type="text" bitInput formControlName="organizationName" /> <input type="text" bitInput formControlName="organizationName" />
<bit-error-summary <bit-error-summary
*ngIf=" *ngIf="
formGroup.get('organizationName').errors?.['maxLength'] && formGroup.controls.organizationName.errors?.['maxLength'] &&
formGroup.get('organizationName').touched formGroup.controls.organizationName.touched
" "
> >
{{ "organizationNameMaxLength" | i18n }} {{ "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 { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { BasePortalOutlet } from "@angular/cdk/portal";
import { Component, Inject, OnInit } from "@angular/core"; import { Component, Inject, OnInit } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms"; import { FormControl, FormGroup, Validators } from "@angular/forms";
@@ -25,48 +24,42 @@ export enum CreateClientDialogResultType {
export const openCreateClientDialog = ( export const openCreateClientDialog = (
dialogService: DialogService, dialogService: DialogService,
dialogConfig: DialogConfig<CreateClientDialogParams>, dialogConfig: DialogConfig<
CreateClientDialogParams,
DialogRef<CreateClientDialogResultType, unknown>,
BasePortalOutlet
>,
) => ) =>
dialogService.open<CreateClientDialogResultType, CreateClientDialogParams>( dialogService.open<CreateClientDialogResultType, CreateClientDialogParams>(
CreateClientDialogComponent, CreateClientDialogComponent,
dialogConfig, dialogConfig,
); );
type PlanCard = { export class PlanCard {
name: string; readonly name: string;
cost: number; private readonly cost: number;
type: PlanType; readonly type: PlanType;
plan: PlanResponse; readonly plan: PlanResponse;
selected: boolean; selected: boolean;
};
@Component({ constructor(name: string, cost: number, type: PlanType, plan: PlanResponse, selected: boolean) {
templateUrl: "./create-client-dialog.component.html", this.name = name;
}) this.cost = cost;
export class CreateClientDialogComponent implements OnInit { this.type = type;
protected discountPercentage: number; this.plan = plan;
protected formGroup = new FormGroup({ this.selected = selected;
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;
private providerPlans: ProviderPlanResponse[]; getMonthlyCost(): number {
return this.plan.isAnnual ? this.cost / 12 : this.cost;
}
constructor( getTimePerMemberLabel(): string {
private billingApiService: BillingApiServiceAbstraction, return this.plan.isAnnual ? "monthPerMemberBilledAnnually" : "monthPerMember";
@Inject(DIALOG_DATA) private dialogParams: CreateClientDialogParams, }
private dialogRef: DialogRef<CreateClientDialogResultType>,
private i18nService: I18nService,
private toastService: ToastService,
private webProviderService: WebProviderService,
) {}
protected getPlanCardContainerClasses(selected: boolean) { getContainerClasses() {
switch (selected) { switch (this.selected) {
case true: { case true: {
return [ return [
"tw-group/plan-card-container", "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> { async ngOnInit(): Promise<void> {
const response = await this.billingApiService.getProviderSubscription( const response = await this.billingApiService.getProviderSubscription(
@@ -114,6 +142,10 @@ export class CreateClientDialogComponent implements OnInit {
const providerPlan = this.providerPlans[i]; const providerPlan = this.providerPlans[i];
const plan = this.dialogParams.plans.find((plan) => plan.type === providerPlan.type); const plan = this.dialogParams.plans.find((plan) => plan.type === providerPlan.type);
if (!plan) {
continue;
}
let planName: string; let planName: string;
switch (plan.productTier) { switch (plan.productTier) {
case ProductTierType.Teams: { case ProductTierType.Teams: {
@@ -124,23 +156,28 @@ export class CreateClientDialogComponent implements OnInit {
planName = this.i18nService.t("planNameEnterprise"); planName = this.i18nService.t("planNameEnterprise");
break; break;
} }
default:
continue;
} }
this.planCards.push({ this.planCards.push(
name: planName, new PlanCard(
cost: plan.PasswordManager.providerPortalSeatPrice * discountFactor, planName,
type: plan.type, plan.PasswordManager.providerPortalSeatPrice * discountFactor,
plan: plan, plan.type,
selected: i === 0, plan,
}); i === 0,
),
);
} }
this.loading = false; this.loading = false;
} }
protected selectPlan(name: string) { protected selectPlan(name: string) {
this.planCards.find((planCard) => planCard.name === name).selected = true; this.planCards.forEach((planCard) => {
this.planCards.find((planCard) => planCard.name !== name).selected = false; planCard.selected = planCard.name === name;
});
} }
submit = async () => { submit = async () => {
@@ -152,17 +189,21 @@ export class CreateClientDialogComponent implements OnInit {
const selectedPlanCard = this.planCards.find((planCard) => planCard.selected); const selectedPlanCard = this.planCards.find((planCard) => planCard.selected);
if (!selectedPlanCard) {
return;
}
await this.webProviderService.createClientOrganization( await this.webProviderService.createClientOrganization(
this.dialogParams.providerId, this.dialogParams.providerId,
this.formGroup.value.organizationName, this.formGroup.controls.organizationName.value,
this.formGroup.value.clientOwnerEmail, this.formGroup.controls.clientOwnerEmail.value,
selectedPlanCard.type, selectedPlanCard.type,
this.formGroup.value.seats, this.formGroup.controls.seats.value,
); );
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null, title: "",
message: this.i18nService.t("createdNewClient"), message: this.i18nService.t("createdNewClient"),
}); });
@@ -178,7 +219,7 @@ export class CreateClientDialogComponent implements OnInit {
const openSeats = selectedProviderPlan.seatMinimum - selectedProviderPlan.assignedSeats; 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; return unassignedSeats > 0 ? unassignedSeats : 0;
} }
@@ -191,22 +232,22 @@ export class CreateClientDialogComponent implements OnInit {
} }
if (selectedProviderPlan.purchasedSeats > 0) { if (selectedProviderPlan.purchasedSeats > 0) {
return this.formGroup.value.seats; return this.formGroup.controls.seats.value;
} }
const additionalSeatsPurchased = const additionalSeatsPurchased =
this.formGroup.value.seats + this.formGroup.controls.seats.value +
selectedProviderPlan.assignedSeats - selectedProviderPlan.assignedSeats -
selectedProviderPlan.seatMinimum; selectedProviderPlan.seatMinimum;
return additionalSeatsPurchased > 0 ? additionalSeatsPurchased : 0; return additionalSeatsPurchased > 0 ? additionalSeatsPurchased : 0;
} }
private getSelectedProviderPlan(): ProviderPlanResponse { private getSelectedProviderPlan(): ProviderPlanResponse | null {
if (this.loading || !this.planCards) { if (this.loading || !this.planCards) {
return null; return null;
} }
const selectedPlan = this.planCards.find((planCard) => planCard.selected).plan; const selectedPlan = this.planCards.find((planCard) => planCard.selected)!.plan;
return this.providerPlans.find((providerPlan) => providerPlan.planName === selectedPlan.name); return this.providerPlans.find((providerPlan) => providerPlan.planName === selectedPlan.name)!;
} }
} }