diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 34551f68cc0..6cf04fee7f2 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10346,5 +10346,32 @@ "example": "10" } } + }, + "existingOrganization": { + "message": "Existing organization" + }, + "selectOrganizationProviderPortal": { + "message": "Select an organization to add to your Provider Portal." + }, + "noOrganizations": { + "message": "There are no organizations to list" + }, + "yourProviderSubscriptionCredit": { + "message": "Your provider subscription will receive a credit for any remaining time in the organization's subscription." + }, + "doYouWantToAddThisOrg": { + "message": "Do you want to add this organization to $PROVIDER$?", + "placeholders": { + "provider": { + "content": "$1", + "example": "Cool MSP" + } + } + }, + "addedExistingOrganization": { + "message": "Added existing organization" + }, + "assignedExceedsAvailable": { + "message": "Assigned seats exceed available seats." } } 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 37cb9618b60..3310be7ba36 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 @@ -17,6 +17,7 @@ import { ProviderSubscriptionComponent, ProviderSubscriptionStatusComponent, } from "../../billing/providers"; +import { AddExistingOrganizationDialogComponent } from "../../billing/providers/clients/add-existing-organization-dialog.component"; import { AddOrganizationComponent } from "./clients/add-organization.component"; import { CreateOrganizationComponent } from "./clients/create-organization.component"; @@ -63,6 +64,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr SetupProviderComponent, UserAddEditComponent, AddEditMemberDialogComponent, + AddExistingOrganizationDialogComponent, CreateClientDialogComponent, ManageClientNameDialogComponent, ManageClientSubscriptionDialogComponent, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts index 264b43aee9d..6b9765e9e05 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts @@ -1,8 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable } from "@angular/core"; +import { firstValueFrom, map } from "rxjs"; +import { switchMap } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { ProviderAddOrganizationRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-add-organization.request"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; @@ -10,6 +13,8 @@ import { PlanType } from "@bitwarden/common/billing/enums"; import { CreateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/create-client-organization.request"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { KeyService } from "@bitwarden/key-management"; @@ -23,6 +28,8 @@ export class WebProviderService { private i18nService: I18nService, private encryptService: EncryptService, private billingApiService: BillingApiServiceAbstraction, + private stateProvider: StateProvider, + private providerApiService: ProviderApiServiceAbstraction, ) {} async addOrganizationToProvider(providerId: string, organizationId: string) { @@ -40,6 +47,22 @@ export class WebProviderService { return response; } + async addOrganizationToProviderVNext(providerId: string, organizationId: string): Promise { + const orgKey = await firstValueFrom( + this.stateProvider.activeUserId$.pipe( + switchMap((userId) => this.keyService.orgKeys$(userId)), + map((organizationKeysById) => organizationKeysById[organizationId as OrganizationId]), + ), + ); + const providerKey = await this.keyService.getProviderKey(providerId); + const encryptedOrgKey = await this.encryptService.encrypt(orgKey.key, providerKey); + await this.providerApiService.addOrganizationToProvider(providerId, { + key: encryptedOrgKey.encryptedString, + organizationId, + }); + await this.syncService.fullSync(true); + } + async createClientOrganization( providerId: string, name: string, diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/add-existing-organization-dialog.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/add-existing-organization-dialog.component.html new file mode 100644 index 00000000000..a22484ed92d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/add-existing-organization-dialog.component.html @@ -0,0 +1,73 @@ + + + {{ "addExistingOrganization" | i18n }} + + + +

{{ "selectOrganizationProviderPortal" | i18n }}

+ + + + {{ "name" | i18n }} + {{ "assigned" | i18n }} + + + + + + + + + {{ addable.name }} +
+ {{ "assignedExceedsAvailable" | i18n }} +
+ + {{ addable.seats }} + + + + +
+
+

+ {{ "noOrganizations" | i18n }} +

+
+ +

{{ "yourProviderSubscriptionCredit" | i18n }}

+

{{ "doYouWantToAddThisOrg" | i18n: dialogParams.provider.name }}

+
+
{{ "organization" | i18n }}: {{ selectedOrganization.name }}
+
{{ "billingPlan" | i18n }}: {{ selectedOrganization.plan }}
+
{{ "assignedSeats" | i18n }}: {{ selectedOrganization.seats }}
+
+
+
+ + + + +
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/add-existing-organization-dialog.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/add-existing-organization-dialog.component.ts new file mode 100644 index 00000000000..3df0693d091 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/add-existing-organization-dialog.component.ts @@ -0,0 +1,82 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; + +import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; +import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; + +export type AddExistingOrganizationDialogParams = { + provider: Provider; +}; + +export enum AddExistingOrganizationDialogResultType { + Closed = "closed", + Submitted = "submitted", +} + +@Component({ + templateUrl: "./add-existing-organization-dialog.component.html", +}) +export class AddExistingOrganizationDialogComponent implements OnInit { + protected loading: boolean = true; + + addableOrganizations: AddableOrganizationResponse[] = []; + selectedOrganization?: AddableOrganizationResponse; + + protected readonly ResultType = AddExistingOrganizationDialogResultType; + + constructor( + @Inject(DIALOG_DATA) protected dialogParams: AddExistingOrganizationDialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + private providerApiService: ProviderApiServiceAbstraction, + private toastService: ToastService, + private webProviderService: WebProviderService, + ) {} + + async ngOnInit() { + this.addableOrganizations = await this.providerApiService.getProviderAddableOrganizations( + this.dialogParams.provider.id, + ); + this.loading = false; + } + + addExistingOrganization = async (): Promise => { + if (this.selectedOrganization) { + await this.webProviderService.addOrganizationToProviderVNext( + this.dialogParams.provider.id, + this.selectedOrganization.id, + ); + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("addedExistingOrganization"), + }); + + this.dialogRef.close(this.ResultType.Submitted); + } + }; + + selectOrganization(organizationId: string) { + this.selectedOrganization = this.addableOrganizations.find( + (organization) => organization.id === organizationId, + ); + } + + static open = ( + dialogService: DialogService, + dialogConfig: DialogConfig< + AddExistingOrganizationDialogParams, + DialogRef + >, + ) => + dialogService.open< + AddExistingOrganizationDialogResultType, + AddExistingOrganizationDialogParams + >(AddExistingOrganizationDialogComponent, dialogConfig); +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html index 7c560e49579..077aeb6c124 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html @@ -1,9 +1,39 @@ - - - {{ "addNewOrganization" | i18n }} - + + + + + + + + + + + {{ "addNewOrganization" | i18n }} + + diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts index ee2c541e72f..07434369122 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts @@ -11,6 +11,8 @@ import { Provider } from "@bitwarden/common/admin-console/models/domain/provider import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { @@ -25,6 +27,10 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; +import { + AddExistingOrganizationDialogComponent, + AddExistingOrganizationDialogResultType, +} from "./add-existing-organization-dialog.component"; import { CreateClientDialogResultType, openCreateClientDialog, @@ -62,6 +68,9 @@ export class ManageClientsComponent { protected searchControl = new FormControl("", { nonNullable: true }); protected plans: PlanResponse[] = []; + protected addExistingOrgsFromProviderPortal$ = this.configService.getFeatureFlag$( + FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal, + ); constructor( private billingApiService: BillingApiServiceAbstraction, @@ -73,6 +82,7 @@ export class ManageClientsComponent { private toastService: ToastService, private validationService: ValidationService, private webProviderService: WebProviderService, + private configService: ConfigService, ) { this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => { this.searchControl.setValue(queryParams.search); @@ -111,19 +121,30 @@ export class ManageClientsComponent { async load() { this.provider = await firstValueFrom(this.providerService.get$(this.providerId)); - this.isProviderAdmin = this.provider?.type === ProviderUserType.ProviderAdmin; - - const clients = (await this.billingApiService.getProviderClientOrganizations(this.providerId)) - .data; - - this.dataSource.data = clients; - + this.dataSource.data = ( + await this.billingApiService.getProviderClientOrganizations(this.providerId) + ).data; this.plans = (await this.billingApiService.getPlans()).data; - this.loading = false; } + addExistingOrganization = async () => { + if (this.provider) { + const reference = AddExistingOrganizationDialogComponent.open(this.dialogService, { + data: { + provider: this.provider, + }, + }); + + const result = await lastValueFrom(reference.closed); + + if (result === AddExistingOrganizationDialogResultType.Submitted) { + await this.load(); + } + } + }; + createClient = async () => { const reference = openCreateClientDialog(this.dialogService, { data: { diff --git a/libs/common/src/admin-console/abstractions/provider/provider-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/provider/provider-api.service.abstraction.ts index f348e7487de..ffe79f0ad3b 100644 --- a/libs/common/src/admin-console/abstractions/provider/provider-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/provider/provider-api.service.abstraction.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response"; + import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request"; import { ProviderUpdateRequest } from "../../models/request/provider/provider-update.request"; import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request"; @@ -14,4 +16,12 @@ export class ProviderApiServiceAbstraction { request: ProviderVerifyRecoverDeleteRequest, ) => Promise; deleteProvider: (id: string) => Promise; + getProviderAddableOrganizations: (providerId: string) => Promise; + addOrganizationToProvider: ( + providerId: string, + request: { + key: string; + organizationId: string; + }, + ) => Promise; } diff --git a/libs/common/src/admin-console/models/response/addable-organization.response.ts b/libs/common/src/admin-console/models/response/addable-organization.response.ts new file mode 100644 index 00000000000..74ae5f45690 --- /dev/null +++ b/libs/common/src/admin-console/models/response/addable-organization.response.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class AddableOrganizationResponse extends BaseResponse { + id: string; + plan: string; + name: string; + seats: number; + disabled: boolean; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("id"); + this.plan = this.getResponseProperty("plan"); + this.name = this.getResponseProperty("name"); + this.seats = this.getResponseProperty("seats"); + this.disabled = this.getResponseProperty("disabled"); + } +} diff --git a/libs/common/src/admin-console/services/provider/provider-api.service.ts b/libs/common/src/admin-console/services/provider/provider-api.service.ts index 2ee921393ff..dc82ec011f4 100644 --- a/libs/common/src/admin-console/services/provider/provider-api.service.ts +++ b/libs/common/src/admin-console/services/provider/provider-api.service.ts @@ -1,3 +1,5 @@ +import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response"; + import { ApiService } from "../../../abstractions/api.service"; import { ProviderApiServiceAbstraction } from "../../abstractions/provider/provider-api.service.abstraction"; import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request"; @@ -44,4 +46,34 @@ export class ProviderApiService implements ProviderApiServiceAbstraction { async deleteProvider(id: string): Promise { await this.apiService.send("DELETE", "/providers/" + id, null, true, false); } + + async getProviderAddableOrganizations( + providerId: string, + ): Promise { + const response = await this.apiService.send( + "GET", + "/providers/" + providerId + "/clients/addable", + null, + true, + true, + ); + + return response.map((data: any) => new AddableOrganizationResponse(data)); + } + + addOrganizationToProvider( + providerId: string, + request: { + key: string; + organizationId: string; + }, + ): Promise { + return this.apiService.send( + "POST", + "/providers/" + providerId + "/clients/existing", + request, + true, + false, + ); + } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 613572bb75b..9b081ce0261 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -49,6 +49,7 @@ export enum FeatureFlag { ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs", NewDeviceVerification = "new-device-verification", EnableRiskInsightsNotifications = "enable-risk-insights-notifications", + PM15179_AddExistingOrgsFromProviderPortal = "PM-15179-add-existing-orgs-from-provider-portal", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -108,6 +109,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.ResellerManagedOrgAlert]: FALSE, [FeatureFlag.NewDeviceVerification]: FALSE, [FeatureFlag.EnableRiskInsightsNotifications]: FALSE, + [FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;