diff --git a/apps/web/src/app/admin-console/icons/admin-console-logo.ts b/apps/web/src/app/admin-console/icons/admin-console-logo.ts index 32b2b7a13a5..25b463217eb 100644 --- a/apps/web/src/app/admin-console/icons/admin-console-logo.ts +++ b/apps/web/src/app/admin-console/icons/admin-console-logo.ts @@ -1,5 +1,27 @@ import { svgIcon } from "@bitwarden/components"; export const AdminConsoleLogo = svgIcon` - + + + + + + + + + + + + + + + + + + + + + + + `; diff --git a/apps/web/src/app/admin-console/icons/business-unit-portal-logo.icon.ts b/apps/web/src/app/admin-console/icons/business-unit-portal-logo.icon.ts new file mode 100644 index 00000000000..f913ee68ef0 --- /dev/null +++ b/apps/web/src/app/admin-console/icons/business-unit-portal-logo.icon.ts @@ -0,0 +1,33 @@ +import { svgIcon } from "@bitwarden/components"; + +export const BusinessUnitPortalLogo = svgIcon` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/apps/web/src/app/admin-console/icons/provider-portal-logo.ts b/apps/web/src/app/admin-console/icons/provider-portal-logo.ts index 16f7b05d671..bc77c3f7902 100644 --- a/apps/web/src/app/admin-console/icons/provider-portal-logo.ts +++ b/apps/web/src/app/admin-console/icons/provider-portal-logo.ts @@ -1,5 +1,29 @@ import { svgIcon } from "@bitwarden/components"; export const ProviderPortalLogo = svgIcon` - + + + + + + + + + + + + + + + + + + + + + + + + + `; diff --git a/apps/web/src/app/billing/services/billing-notification.service.ts b/apps/web/src/app/billing/services/billing-notification.service.ts index 6695e516ca8..155a5011ed4 100644 --- a/apps/web/src/app/billing/services/billing-notification.service.ts +++ b/apps/web/src/app/billing/services/billing-notification.service.ts @@ -32,4 +32,12 @@ export class BillingNotificationService { message: message, }); } + + showError(message: string, title: string = "") { + this.toastService.showToast({ + variant: "error", + title, + message, + }); + } } diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts index 1cc5c92c120..ec0d2c2651c 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts @@ -19,6 +19,7 @@ import { OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -153,6 +154,11 @@ export class ProductSwitcherService { // TODO: This should be migrated to an Observable provided by the provider service and moved to the combineLatest above. See AC-2092. const providers = await this.providerService.getAll(); + const providerPortalName = + providers[0]?.providerType === ProviderType.BusinessUnit + ? "Business Unit Portal" + : "Provider Portal"; + const orgsMarketingRoute = this.platformUtilsService.isSelfHost() ? { route: "https://bitwarden.com/products/business/", @@ -201,7 +207,7 @@ export class ProductSwitcherService { isActive: this.router.url.includes("/organizations/"), }, provider: { - name: "Provider Portal", + name: providerPortalName, icon: "bwi-provider", appRoute: ["/providers", providers[0]?.id], isActive: this.router.url.includes("/providers/"), diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3b63921935a..8fe74a5a2d2 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10616,5 +10616,14 @@ }, "cannotCreateCollection": { "message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections." + }, + "businessUnit": { + "message": "Business Unit" + }, + "businessUnits": { + "message": "Business Units" + }, + "newBusinessUnit": { + "message": "New business unit" } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index 1623fd34a3a..8266b20b306 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -1,10 +1,14 @@ - + (); protected provider$: Observable; + protected logo$: Observable; + protected isBillable: Observable; protected canAccessBilling$: Observable; + protected clientsTranslationKey$: Observable; + constructor( private route: ActivatedRoute, private providerService: ProviderService, @@ -42,16 +47,28 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { takeUntil(this.destroy$), ); + this.logo$ = this.provider$.pipe( + map((provider) => + provider.providerType === ProviderType.BusinessUnit + ? BusinessUnitPortalLogo + : ProviderPortalLogo, + ), + ); + this.isBillable = this.provider$.pipe( map((provider) => provider?.providerStatus === ProviderStatusType.Billable), - takeUntil(this.destroy$), ); this.canAccessBilling$ = combineLatest([this.isBillable, this.provider$]).pipe( map( ([hasConsolidatedBilling, provider]) => hasConsolidatedBilling && provider.isProviderAdmin, ), - takeUntil(this.destroy$), + ); + + this.clientsTranslationKey$ = this.provider$.pipe( + map((provider) => + provider.providerType === ProviderType.BusinessUnit ? "businessUnits" : "clients", + ), ); } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts index 00c944e69bb..9be09c295ae 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts @@ -13,6 +13,7 @@ import { hasConsolidatedBilling, ProviderBillingHistoryComponent, } from "../../billing/providers"; +import { SetupBusinessUnitComponent } from "../../billing/providers/setup/setup-business-unit.component"; import { ClientsComponent } from "./clients/clients.component"; import { CreateOrganizationComponent } from "./clients/create-organization.component"; @@ -49,6 +50,11 @@ const routes: Routes = [ component: SetupProviderComponent, data: { titleId: "setupProvider" }, }, + { + path: "setup-business-unit", + component: SetupBusinessUnitComponent, + data: { titleId: "setupProvider" }, + }, ], }, { 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 b8fe60814a8..dd9baa99948 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 @@ -19,6 +19,7 @@ import { ProviderSubscriptionStatusComponent, } from "../../billing/providers"; import { AddExistingOrganizationDialogComponent } from "../../billing/providers/clients/add-existing-organization-dialog.component"; +import { SetupBusinessUnitComponent } from "../../billing/providers/setup/setup-business-unit.component"; import { AddOrganizationComponent } from "./clients/add-organization.component"; import { CreateOrganizationComponent } from "./clients/create-organization.component"; @@ -75,6 +76,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr ProviderSubscriptionStatusComponent, ProvidersComponent, VerifyRecoverDeleteProviderComponent, + SetupBusinessUnitComponent, ], providers: [WebProviderService], }) 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 f75c4f3d651..ffcda9fccfa 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,4 +1,4 @@ - + + + + diff --git a/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts new file mode 100644 index 00000000000..4c8d483a0c5 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts @@ -0,0 +1,118 @@ +import { Component } from "@angular/core"; +import { ActivatedRoute, Params, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; +import { filter, map, switchMap } from "rxjs/operators"; + +import { BitwardenLogo } from "@bitwarden/auth/angular"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { ProviderKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; +import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service"; +import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.component"; + +@Component({ + templateUrl: "./setup-business-unit.component.html", +}) +export class SetupBusinessUnitComponent extends BaseAcceptComponent { + protected bitwardenLogo = BitwardenLogo; + + failedMessage = "emergencyInviteAcceptFailed"; + failedShortMessage = "emergencyInviteAcceptFailedShort"; + requiredParameters = ["organizationId", "email", "token"]; + + constructor( + activatedRoute: ActivatedRoute, + authService: AuthService, + private billingNotificationService: BillingNotificationService, + private encryptService: EncryptService, + i18nService: I18nService, + private keyService: KeyService, + private organizationBillingApiService: OrganizationBillingApiServiceAbstraction, + platformUtilsService: PlatformUtilsService, + router: Router, + private stateProvider: StateProvider, + private syncService: SyncService, + ) { + super(router, platformUtilsService, i18nService, activatedRoute, authService); + } + + async authedHandler(queryParams: Params) { + await this.process(queryParams); + } + + async unauthedHandler(_: Params) {} + + async login() { + await this.router.navigate(["/login"], { queryParams: { email: this.email } }); + } + + process = async (queryParams: Params): Promise => { + const fail = async () => { + this.billingNotificationService.showError(this.i18nService.t(this.failedMessage)); + return await this.router.navigate(["/"]); + }; + + const organizationId = queryParams.organizationId as string; + const token = queryParams.token as string; + + if (!organizationId || !token) { + return await fail(); + } + + const activeUserId$ = this.stateProvider.activeUserId$.pipe( + filter((userId): userId is NonNullable => userId != null), + ); + + const organizationKey$ = activeUserId$.pipe( + switchMap((userId) => this.keyService.orgKeys$(userId)), + filter( + (organizationKeysById): organizationKeysById is NonNullable => + organizationKeysById != null && organizationId in organizationKeysById, + ), + map((organizationKeysById) => organizationKeysById[organizationId as OrganizationId]), + ); + + const [{ encryptedString: encryptedProviderKey }, providerKey] = + await this.keyService.makeOrgKey(); + + const organizationKey = await firstValueFrom(organizationKey$); + + const { encryptedString: encryptedOrganizationKey } = await this.encryptService.encrypt( + organizationKey.key, + providerKey, + ); + + if (!encryptedProviderKey || !encryptedOrganizationKey) { + return await fail(); + } + + const userId = await firstValueFrom(activeUserId$); + + const request = { + userId, + token, + providerKey: encryptedProviderKey, + organizationKey: encryptedOrganizationKey, + }; + + try { + const providerId = await this.organizationBillingApiService.setupBusinessUnit( + organizationId, + request, + ); + await this.syncService.fullSync(true); + this.billingNotificationService.showSuccess(this.i18nService.t("providerSetup")); + return await this.router.navigate(["/providers", providerId]); + } catch (error) { + this.billingNotificationService.handleError(error); + return false; + } + }; +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts index 6cf0b21169b..c49509427b8 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts @@ -39,8 +39,8 @@ export class ProviderSubscriptionStatusComponent { switch (this.subscription.providerType) { case ProviderType.Msp: return "managedServiceProvider"; - case ProviderType.MultiOrganizationEnterprise: - return "multiOrganizationEnterprise"; + case ProviderType.BusinessUnit: + return "businessUnit"; } } @@ -72,6 +72,18 @@ export class ProviderSubscriptionStatusComponent { }, }; } + case "trialing": { + return { + status: { + label: defaultStatusLabel, + value: this.i18nService.t("trial"), + }, + date: { + label: nextChargeDateLabel, + value: this.subscription.currentPeriodEndDate, + }, + }; + } case "past_due": { const pastDueText = this.i18nService.t("pastDue"); const suspensionDate = this.datePipe.transform( diff --git a/libs/common/src/admin-console/enums/provider-type.enum.ts b/libs/common/src/admin-console/enums/provider-type.enum.ts index d802c659f6f..eb48e362e7d 100644 --- a/libs/common/src/admin-console/enums/provider-type.enum.ts +++ b/libs/common/src/admin-console/enums/provider-type.enum.ts @@ -1,5 +1,5 @@ export enum ProviderType { Msp = 0, Reseller = 1, - MultiOrganizationEnterprise = 2, + BusinessUnit = 2, } diff --git a/libs/common/src/admin-console/models/data/provider.data.ts b/libs/common/src/admin-console/models/data/provider.data.ts index ff060ae2704..076c628db3e 100644 --- a/libs/common/src/admin-console/models/data/provider.data.ts +++ b/libs/common/src/admin-console/models/data/provider.data.ts @@ -1,4 +1,9 @@ -import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../../enums"; +import { + ProviderStatusType, + ProviderType, + ProviderUserStatusType, + ProviderUserType, +} from "../../enums"; import { ProfileProviderResponse } from "../response/profile-provider.response"; export class ProviderData { @@ -10,6 +15,7 @@ export class ProviderData { userId: string; useEvents: boolean; providerStatus: ProviderStatusType; + providerType: ProviderType; constructor(response: ProfileProviderResponse) { this.id = response.id; @@ -20,5 +26,6 @@ export class ProviderData { this.userId = response.userId; this.useEvents = response.useEvents; this.providerStatus = response.providerStatus; + this.providerType = response.providerType; } } diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index 6f7ff561f04..c5c5b53cce7 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -331,8 +331,7 @@ export class Organization { get hasBillableProvider() { return ( this.hasProvider && - (this.providerType === ProviderType.Msp || - this.providerType === ProviderType.MultiOrganizationEnterprise) + (this.providerType === ProviderType.Msp || this.providerType === ProviderType.BusinessUnit) ); } diff --git a/libs/common/src/admin-console/models/domain/provider.ts b/libs/common/src/admin-console/models/domain/provider.ts index a70cb72995a..d081644887c 100644 --- a/libs/common/src/admin-console/models/domain/provider.ts +++ b/libs/common/src/admin-console/models/domain/provider.ts @@ -1,6 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../../enums"; +import { + ProviderStatusType, + ProviderType, + ProviderUserStatusType, + ProviderUserType, +} from "../../enums"; import { ProviderData } from "../data/provider.data"; export class Provider { @@ -12,6 +17,7 @@ export class Provider { userId: string; useEvents: boolean; providerStatus: ProviderStatusType; + providerType: ProviderType; constructor(obj?: ProviderData) { if (obj == null) { @@ -26,6 +32,7 @@ export class Provider { this.userId = obj.userId; this.useEvents = obj.useEvents; this.providerStatus = obj.providerStatus; + this.providerType = obj.providerType; } get canAccess() { diff --git a/libs/common/src/admin-console/models/response/profile-provider.response.ts b/libs/common/src/admin-console/models/response/profile-provider.response.ts index 701fe843de8..ce35b064d52 100644 --- a/libs/common/src/admin-console/models/response/profile-provider.response.ts +++ b/libs/common/src/admin-console/models/response/profile-provider.response.ts @@ -1,5 +1,10 @@ import { BaseResponse } from "../../../models/response/base.response"; -import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../../enums"; +import { + ProviderStatusType, + ProviderType, + ProviderUserStatusType, + ProviderUserType, +} from "../../enums"; import { PermissionsApi } from "../api/permissions.api"; export class ProfileProviderResponse extends BaseResponse { @@ -13,6 +18,7 @@ export class ProfileProviderResponse extends BaseResponse { userId: string; useEvents: boolean; providerStatus: ProviderStatusType; + providerType: ProviderType; constructor(response: any) { super(response); @@ -26,5 +32,6 @@ export class ProfileProviderResponse extends BaseResponse { this.userId = this.getResponseProperty("UserId"); this.useEvents = this.getResponseProperty("UseEvents"); this.providerStatus = this.getResponseProperty("ProviderStatus"); + this.providerType = this.getResponseProperty("ProviderType"); } } diff --git a/libs/common/src/admin-console/services/provider.service.spec.ts b/libs/common/src/admin-console/services/provider.service.spec.ts index 0f4414804b8..92d5ae2e801 100644 --- a/libs/common/src/admin-console/services/provider.service.spec.ts +++ b/libs/common/src/admin-console/services/provider.service.spec.ts @@ -4,7 +4,12 @@ import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from ". import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state"; import { Utils } from "../../platform/misc/utils"; import { UserId } from "../../types/guid"; -import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../enums"; +import { + ProviderStatusType, + ProviderType, + ProviderUserStatusType, + ProviderUserType, +} from "../enums"; import { ProviderData } from "../models/data/provider.data"; import { Provider } from "../models/domain/provider"; @@ -67,6 +72,7 @@ describe("PROVIDERS key definition", () => { userId: "string", useEvents: true, providerStatus: ProviderStatusType.Pending, + providerType: ProviderType.Msp, }, }; const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult))); diff --git a/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts index 2ed25491049..e1f7ad49012 100644 --- a/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts @@ -1,19 +1,27 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { BillingInvoiceResponse, BillingTransactionResponse, } from "../../models/response/billing.response"; -export class OrganizationBillingApiServiceAbstraction { - getBillingInvoices: ( +export abstract class OrganizationBillingApiServiceAbstraction { + abstract getBillingInvoices: ( id: string, status?: string, startAfter?: string, ) => Promise; - getBillingTransactions: ( + abstract getBillingTransactions: ( id: string, startAfter?: string, ) => Promise; + + abstract setupBusinessUnit: ( + id: string, + request: { + userId: string; + token: string; + providerKey: string; + organizationKey: string; + }, + ) => Promise; } diff --git a/libs/common/src/billing/services/organization/organization-billing-api.service.ts b/libs/common/src/billing/services/organization/organization-billing-api.service.ts index 9bf1e6ee6d9..405bd41957f 100644 --- a/libs/common/src/billing/services/organization/organization-billing-api.service.ts +++ b/libs/common/src/billing/services/organization/organization-billing-api.service.ts @@ -49,4 +49,24 @@ export class OrganizationBillingApiService implements OrganizationBillingApiServ ); return r?.map((i: any) => new BillingTransactionResponse(i)) || []; } + + async setupBusinessUnit( + id: string, + request: { + userId: string; + token: string; + providerKey: string; + organizationKey: string; + }, + ): Promise { + const response = await this.apiService.send( + "POST", + `/organizations/${id}/billing/setup-business-unit`, + request, + true, + true, + ); + + return response as string; + } }