From f6702cd2d74c32dda2d60bc6df0e46b563978867 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:36:31 -0400 Subject: [PATCH] [AC-2595] [AC-2596] Empty clients placeholder and setup provider hint (#9505) * Added empty state to providers clients page * Added bitForm to Setup component and added billing email hint --- apps/web/src/locales/en/messages.json | 7 + .../providers/providers.module.ts | 2 + .../providers/setup/setup.component.html | 55 +++--- .../providers/setup/setup.component.ts | 173 ++++++++++-------- .../app/billing/providers/clients/index.ts | 1 + ...manage-client-organizations.component.html | 149 ++++++++------- .../providers/clients/no-clients.component.ts | 40 ++++ .../src/app/billing/providers/index.ts | 5 +- .../provider-payment-method.component.html | 2 +- .../manage-tax-information.component.html | 4 +- .../manage-tax-information.component.ts | 69 ++++--- 11 files changed, 304 insertions(+), 203 deletions(-) create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 39423cbd90..9cbd44a963 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -8330,5 +8330,12 @@ }, "viewSecret": { "message": "View secret" + }, + "noClients": { + "message": "There are no clients to list" + }, + "providerBillingEmailHint": { + "message": "This email address will receive all invoices pertaining to this provider", + "description": "A hint that shows up on the Provider setup page to inform the admin the billing email will receive the provider's invoices." } } 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 baa3e5e1bb..00a3872584 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 @@ -11,6 +11,7 @@ import { OssModule } from "@bitwarden/web-vault/app/oss.module"; import { CreateClientOrganizationComponent, + NoClientsComponent, ManageClientOrganizationNameComponent, ManageClientOrganizationsComponent, ManageClientOrganizationSubscriptionComponent, @@ -65,6 +66,7 @@ import { SetupComponent } from "./setup/setup.component"; SetupProviderComponent, UserAddEditComponent, CreateClientOrganizationComponent, + NoClientsComponent, ManageClientOrganizationsComponent, ManageClientOrganizationNameComponent, ManageClientOrganizationSubscriptionComponent, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html index d1cf666874..0fd6725304 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html @@ -1,40 +1,41 @@ -
+ + + {{ "loading" | i18n }} + +

{{ "setupProviderDesc" | i18n }}

- -
+

{{ "generalInformation" | i18n }}

-
-
- - +
+
+ + {{ "providerName" | i18n }} + +
-
- - -
-
- +
+ + {{ "billingEmail" | i18n }} + + {{ + "providerBillingEmailHint" | i18n + }} +
- -
- -
+ +
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts index 258088257d..845f2834b3 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts @@ -1,38 +1,41 @@ -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; +import { firstValueFrom, Subject, switchMap } from "rxjs"; +import { first, takeUntil } from "rxjs/operators"; +import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request"; +import { TaxInformation } from "@bitwarden/common/billing/models/domain"; import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ProviderKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { TaxInfoComponent } from "@bitwarden/web-vault/app/billing"; +import { ToastService } from "@bitwarden/components"; @Component({ selector: "provider-setup", templateUrl: "setup.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class SetupComponent implements OnInit { - @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; +export class SetupComponent implements OnInit, OnDestroy { + @ViewChild(ManageTaxInformationComponent) + manageTaxInformationComponent: ManageTaxInformationComponent; loading = true; - authed = false; - email: string; - formPromise: Promise; - providerId: string; token: string; - name: string; - billingEmail: string; + + protected formGroup = this.formBuilder.group({ + name: ["", Validators.required], + billingEmail: ["", [Validators.required, Validators.email]], + }); + + protected readonly TaxInformation = TaxInformation; protected showPaymentMethodWarningBanners$ = this.configService.getFeatureFlag$( FeatureFlag.ShowPaymentMethodWarningBanners, @@ -42,9 +45,10 @@ export class SetupComponent implements OnInit { FeatureFlag.EnableConsolidatedBilling, ); + private destroy$ = new Subject(); + constructor( private router: Router, - private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private route: ActivatedRoute, private cryptoService: CryptoService, @@ -52,61 +56,81 @@ export class SetupComponent implements OnInit { private validationService: ValidationService, private configService: ConfigService, private providerApiService: ProviderApiServiceAbstraction, + private formBuilder: FormBuilder, + private toastService: ToastService, ) {} ngOnInit() { document.body.classList.remove("layout_frontend"); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - const error = qParams.providerId == null || qParams.email == null || qParams.token == null; - if (error) { - this.platformUtilsService.showToast( - "error", - null, - this.i18nService.t("emergencyInviteAcceptFailed"), - { timeout: 10000 }, - ); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/"]); + this.route.queryParams + .pipe( + first(), + switchMap(async (queryParams) => { + const error = + queryParams.providerId == null || + queryParams.email == null || + queryParams.token == null; + + if (error) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("emergencyInviteAcceptFailed"), + timeout: 10000, + }); + + return await this.router.navigate(["/"]); + } + + this.providerId = queryParams.providerId; + this.token = queryParams.token; + + try { + const provider = await this.providerApiService.getProvider(this.providerId); + + if (provider.name != null) { + /* + This is currently always going to result in a redirect to the Vault because the `provider-permissions.guard` + checks for the existence of the Provider in state. However, when accessing the Setup page via the email link, + this `navigate` invocation will be hit before the sync can complete, thus resulting in a null Provider. If we want + to resolve it, we'd either need to use the ProviderApiService in the provider-permissions.guard (added expense) + or somehow check that the previous route was /setup. + */ + return await this.router.navigate(["/providers", provider.id], { + replaceUrl: true, + }); + } + this.loading = false; + } catch (error) { + this.validationService.showError(error); + return await this.router.navigate(["/"]); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + submit = async () => { + try { + this.formGroup.markAllAsTouched(); + const taxInformationValid = this.manageTaxInformationComponent.touch(); + if (this.formGroup.invalid || !taxInformationValid) { return; } - this.providerId = qParams.providerId; - this.token = qParams.token; - - // Check if provider exists, redirect if it does - try { - const provider = await this.providerApiService.getProvider(this.providerId); - if (provider.name != null) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/providers", provider.id], { replaceUrl: true }); - } - } catch (e) { - this.validationService.showError(e); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/"]); - } - }); - } - - async submit() { - this.formPromise = this.doSubmit(); - await this.formPromise; - this.formPromise = null; - } - - async doSubmit() { - try { const providerKey = await this.cryptoService.makeOrgKey(); const key = providerKey[0].encryptedString; const request = new ProviderSetupRequest(); - request.name = this.name; - request.billingEmail = this.billingEmail; + request.name = this.formGroup.value.name; + request.billingEmail = this.formGroup.value.billingEmail; request.token = this.token; request.key = key; @@ -114,27 +138,32 @@ export class SetupComponent implements OnInit { if (enableConsolidatedBilling) { request.taxInfo = new ExpandedTaxInfoUpdateRequest(); - const taxInfoView = this.taxInfoComponent.taxInfo; - request.taxInfo.country = taxInfoView.country; - request.taxInfo.postalCode = taxInfoView.postalCode; - if (taxInfoView.includeTaxId) { - request.taxInfo.taxId = taxInfoView.taxId; - request.taxInfo.line1 = taxInfoView.line1; - request.taxInfo.line2 = taxInfoView.line2; - request.taxInfo.city = taxInfoView.city; - request.taxInfo.state = taxInfoView.state; + const taxInformation = this.manageTaxInformationComponent.getTaxInformation(); + + request.taxInfo.country = taxInformation.country; + request.taxInfo.postalCode = taxInformation.postalCode; + if (taxInformation.includeTaxId) { + request.taxInfo.taxId = taxInformation.taxId; + request.taxInfo.line1 = taxInformation.line1; + request.taxInfo.line2 = taxInformation.line2; + request.taxInfo.city = taxInformation.city; + request.taxInfo.state = taxInformation.state; } } const provider = await this.providerApiService.postProviderSetup(this.providerId, request); - this.platformUtilsService.showToast("success", null, this.i18nService.t("providerSetup")); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("providerSetup"), + }); + await this.syncService.fullSync(true); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/providers", provider.id]); + await this.router.navigate(["/providers", provider.id]); } catch (e) { this.validationService.showError(e); } - } + }; } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts index 1968302766..fa1bc137fc 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts @@ -2,3 +2,4 @@ export * from "./create-client-organization.component"; export * from "./manage-client-organizations.component"; export * from "./manage-client-organization-name.component"; export * from "./manage-client-organization-subscription.component"; +export * from "./no-clients.component"; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html index d2f8ab7a85..9a84b92837 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html @@ -21,80 +21,77 @@ {{ "loading" | i18n }} - -

{{ "noClientsInList" | i18n }}

- - - - - {{ "client" | i18n }} - {{ "assigned" | i18n }} - {{ "used" | i18n }} - {{ "remaining" | i18n }} - {{ "billingPlan" | i18n }} - - - - - - - - - - - - - {{ client.seats }} - - - {{ client.userCount }} - - - {{ client.seats - client.userCount }} - - - {{ client.plan }} - - - - - - - - - - - - - + + + + + {{ "client" | i18n }} + {{ "assigned" | i18n }} + {{ "used" | i18n }} + {{ "remaining" | i18n }} + {{ "billingPlan" | i18n }} + + + + + + + + + + + + + {{ client.seats }} + + + {{ client.userCount }} + + + {{ client.seats - client.userCount }} + + + {{ client.plan }} + + + + + + + + + + + + +
+ +
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts new file mode 100644 index 0000000000..c785ee8bd0 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts @@ -0,0 +1,40 @@ +import { Component, EventEmitter, Output } from "@angular/core"; + +import { svgIcon } from "@bitwarden/components"; + +const gearIcon = svgIcon` + + + + + + + + + + + + + + + + +`; + +@Component({ + selector: "app-no-clients", + template: `
+ +

{{ "noClients" | i18n }}

+ + + {{ "addNewOrganization" | i18n }} + +
`, +}) +export class NoClientsComponent { + icon = gearIcon; + @Output() addNewOrganizationClicked = new EventEmitter(); + + addNewOrganization = () => this.addNewOrganizationClicked.emit(); +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/index.ts index 9b899f1741..71af56f7b0 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/index.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/index.ts @@ -1,7 +1,4 @@ -export * from "./clients/create-client-organization.component"; -export * from "./clients/manage-client-organization-name.component"; -export * from "./clients/manage-client-organization-subscription.component"; -export * from "./clients/manage-client-organizations.component"; +export * from "./clients"; export * from "./guards/has-consolidated-billing.guard"; export * from "./payment-method/provider-select-payment-method-dialog.component"; export * from "./payment-method/provider-payment-method.component"; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-payment-method.component.html b/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-payment-method.component.html index 1a1e0529fc..e2457294eb 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-payment-method.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-payment-method.component.html @@ -44,7 +44,7 @@

{{ "taxInformationDesc" | i18n }}

diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html index f9cfa8e0fa..0b041bd4c0 100644 --- a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html +++ b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html @@ -1,7 +1,7 @@
- + {{ "country" | i18n }}
- + {{ "zipPostalCode" | i18n }} diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts index 58342548ca..f048cf0d36 100644 --- a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts +++ b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts @@ -1,5 +1,6 @@ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; +import { Subject, takeUntil } from "rxjs"; import { TaxInformation } from "@bitwarden/common/billing/models/domain"; @@ -13,8 +14,8 @@ type Country = { selector: "app-manage-tax-information", templateUrl: "./manage-tax-information.component.html", }) -export class ManageTaxInformationComponent implements OnInit { - @Input({ required: true }) taxInformation: TaxInformation; +export class ManageTaxInformationComponent implements OnInit, OnDestroy { + @Input() startWith: TaxInformation; @Input() onSubmit?: (taxInformation: TaxInformation) => Promise; @Output() taxInformationUpdated = new EventEmitter(); @@ -29,35 +30,61 @@ export class ManageTaxInformationComponent implements OnInit { state: "", }); + private destroy$ = new Subject(); + + private taxInformation: TaxInformation; + constructor(private formBuilder: FormBuilder) {} - submit = async () => { - await this.onSubmit({ - country: this.formGroup.value.country, - postalCode: this.formGroup.value.postalCode, - taxId: this.formGroup.value.taxId, - line1: this.formGroup.value.line1, - line2: this.formGroup.value.line2, - city: this.formGroup.value.city, - state: this.formGroup.value.state, - }); + getTaxInformation = (): TaxInformation & { includeTaxId: boolean } => ({ + ...this.taxInformation, + includeTaxId: this.formGroup.value.includeTaxId, + }); + submit = async () => { + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { + return; + } + await this.onSubmit(this.taxInformation); this.taxInformationUpdated.emit(); }; + touch = (): boolean => { + this.formGroup.markAllAsTouched(); + return this.formGroup.valid; + }; + async ngOnInit() { - if (this.taxInformation) { + if (this.startWith) { this.formGroup.patchValue({ - ...this.taxInformation, + ...this.startWith, includeTaxId: - this.countrySupportsTax(this.taxInformation.country) && - (!!this.taxInformation.taxId || - !!this.taxInformation.line1 || - !!this.taxInformation.line2 || - !!this.taxInformation.city || - !!this.taxInformation.state), + this.countrySupportsTax(this.startWith.country) && + (!!this.startWith.taxId || + !!this.startWith.line1 || + !!this.startWith.line2 || + !!this.startWith.city || + !!this.startWith.state), }); } + + this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => { + this.taxInformation = { + country: values.country, + postalCode: values.postalCode, + taxId: values.taxId, + line1: values.line1, + line2: values.line2, + city: values.city, + state: values.state, + }; + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } protected countrySupportsTax(countryCode: string) {